Продолжаю автоматизировать рутину. На этот раз проблема не столько в рутине как таковой, сколько в несовершенстве мира (да-да, немного немало).
Я большой любитель музыки. В смысле, слушатель. Как и всякий человек, что-то люблю больше, что-то меньше. Для сохранения моих вкусовых пристрастий современное программное обеспечение хочет мне в этом помочь. А именно - Winamp и iTunes позволяют расставлять рейтинги для композиций в своей библиотеке. Чтобы потом по ним можно было формировать умные плейлисты (например, хранить в iPhone / iPod только высокорейтинговые песни добавленные за последние 4 года). Вроде бы все хорошо.
Я большой любитель музыки. В смысле, слушатель. Как и всякий человек, что-то люблю больше, что-то меньше. Для сохранения моих вкусовых пристрастий современное программное обеспечение хочет мне в этом помочь. А именно - Winamp и iTunes позволяют расставлять рейтинги для композиций в своей библиотеке. Чтобы потом по ним можно было формировать умные плейлисты (например, хранить в iPhone / iPod только высокорейтинговые песни добавленные за последние 4 года). Вроде бы все хорошо.
Проблема
Вступает в силу закон дырявых абстракций и функционал "медиа-библиотека" начинает "течь". А именно:- Рейтинг привязывается к конкретному файлу, а не песне как таковой (а точнее - триплету "артист - альбом - песня"). Что приводит к потере рейтинга песни при физическом перемещении файла песни с одного диска/папки/компа на другой.
- При хранении двух версий одной и той же песни (например lossless и lossy) рейтинг надо проставлять для каждой в отдельности
- Никакого способа автоматически сохранить / бекапить информацию о рейтингах. Есть только ручные операции "экспортировать медиа-библиотеку" и "импортировать медиа-библиотеку". Которые могут сломаться по тем же причинам что и в п.1. Кроме того, операция мануальная и требует, чтобы человек (то есть я), про это вспоминал и куда-то файл с инфой о рейтингах сохранял.
Решение
- С помощью AutoIt экспортировать медиа-библиотеку winamp в файл iTunes-xml файл. Рейтинги я расставляю в winamp, поэтому именно он первоисточник.
- Питоном разбираю этот XML (он очень тривиальный) и сохраняю в LastFM информацию о рейтинге композиции.
- Еще раз прохожу по XML и обновляю рейтинг исходя из LastFM рейтинга. Этот шаг позволит расставить рейтинг в "дубликатах песен" в случае когда рейтинг проставлен для FLAC песни и не проставлен для её mp3 версии.
- Тем же AutoIt импортирую получившийся iTunes XML файл в winamp.
- Импортировать тот же файл непосредственно в iTunes
Если периодически запускать скрипт который описан выше, в LastFm всегда будет вся информация о рейтингах, которую можно будет легко восстановить при различных катаклизмах с музыкальными файлами или системой.
Детали
Рейтинг будет сохраняться в LastFm в виде тега "N-stars" к каждой песне. N - количество звезд на песне в библиотеке winamp. Благо, LastFm позволяет любое количество тэгов для пользователей. Причем тэги можно как добавлять, так и удалять через LastFm API. Эта особенность нам нужна, если рейтинг песни будет меняться (разонравилась). Натуральным ограничением для тэга является использование только латиницы и всего пары знаков пунктуации в названии.
Еще сохраняем в виде тэга к песне название альбома, на котором эта песня получила такой рейтинг. Нужно это для того, чтобы отличать разные исполнения одной и той же песни и помнить этот выбор. Если таких альбомов несколько - не проблема, мы сохраним их все. LastFm за нас эту проблему не решает. Для него если только пара "исполнитель - песня". Альбом - это вторичное свойство песни и по-умолчанию LastFm считает песню с самого популярного альбома.
В процессе работы пришлось дописать lastfmapi (на который я уже ссылался). Дело в том, что pyLast (другой пакет для работы с lastfm) для авторизации методов записи просит md5 пользовательского пароля. Что меня сильно смутило. Кроме того, этот метод признан в LastFm устаревшим и, видимо, скоро его совсем выключат.
Пришлось реализовывать поддержку методов записи в lastfmapi. Это не заняло много времени и не потребовало много изменений. Мою версию lastfmapi можно найти на гитхабе (надеюсь автор замержится обратно). Ну и чтобы два раза не вставать, сделал еще пару write вызовов: композиции с рейтингом 3+ будут скроблиться в LastFm, а композиции с рейтингом "5" - добавляться в категорию "любимых".
Код ниже работает в 2х режимах:
Код ниже работает в 2х режимах:
- обновить LastFm рейтингами из iTunes xml (при этом LaftFm рейтинг не может понизится) - т.н. "бэкап"
- обновить iTunes xml рейтингами из LastFm (рейтинги из winamp xml перезапишутся) - т.н. "восстановление"
А вот собственно "вкусо-БД".
Код для AutoIt смотрите в следующих сериях.
Код для AutoIt смотрите в следующих сериях.
1 # coding: utf-8 2 __author__ = 'Smog' 3 4 import re, logging, sys, lastfmapi, time 5 from xml.dom.minidom import parse 6 7 API_KEY = "" 8 API_SECRET = "" 9 SESS_KEY = '' 10 USERNAME = '' 11 TIME = {'time' : int(time.time())} 12 13 14 def updateLastFmFromiTunesXML(finput): 15 log = logging.getLogger('mdb') 16 log.info('Updating ' + USERNAME + ' LastFm library with iTunes data from ' + finput + ' file') 17 18 log.info('LastFm is beining initialized') 19 api = lastfmapi.LastFmApi(API_KEY, API_SECRET) 20 log.info('LastFm initialized') 21 22 log.info('Parsing iTunes media library file: ' + finput) 23 d = parse(finput) 24 log.info('Parsed') 25 26 root = d.getElementsByTagName('plist')[0].getElementsByTagName('dict')[0].getElementsByTagName('dict')[0] 27 tracks = root.getElementsByTagName('dict') 28 log.info('Found ' + str(len(tracks)) + ' tracks in iTunes XML library file') 29 30 inc = 0 31 for t in tracks: 32 inc += 1 33 tt = None 34 ta = None 35 tal = None 36 trt = None 37 38 for i in range(0, len(t.childNodes)): 39 node = t.childNodes[i] 40 if (node.nodeName == u'key'): 41 if (node.childNodes[0].nodeValue == u'Name'): 42 tt = t.childNodes[i + 1].childNodes[0].nodeValue 43 if (node.childNodes[0].nodeValue == u'Artist'): 44 ta = t.childNodes[i + 1].childNodes[0].nodeValue 45 if (node.childNodes[0].nodeValue == u'Album'): 46 tal = t.childNodes[i + 1].childNodes[0].nodeValue 47 if (node.childNodes[0].nodeValue == u'Rating'): 48 trt = t.childNodes[i + 1].childNodes[0].nodeValue 49 50 if (tt != None and ta != None and tal != None and trt != None and int(trt) > 59): 51 log.debug(str(inc) + ': ' + ta + ' - ' + tt + ' with rating ' + trt + ' found') 52 uploadTrackToLastFm(api, ta, tt, tal, trt) 53 54 log.info('Done.') 55 56 57 def uploadTrackToLastFm(api, artist, track, album, rating): 58 log = logging.getLogger('mdb') 59 60 61 lastfmTagsJSON = api.track_getTags(artist=artist, track=track, user=USERNAME) 62 tags = getTags(lastfmTagsJSON['tags']) 63 lastfmRating = getRating(tags) 64 65 if lastfmRating > 0 and int(lastfmRating) < int(rating): 66 log.info(artist + ' - ' + track + ' (' + album + "): local rating (" + str(rating) + ") is bigger than lastfm (" + 67 str(lastfmRating) + "). Remove old rating and albums.") 68 clearTags(api, tags, artist, track) 69 lastfmRating = 0 70 71 if lastfmRating == 0: #new track 72 log.info(artist + ' - ' + track + ' (' + album + "): rating (" + str(rating) + ") be added to LastFm") 73 addRatingTag(api, artist, track, rating) 74 addAlbumTag(api, artist, track, album) 75 76 api.track_scrobble(artist=artist, track=track, sk=SESS_KEY, timestamp=str(TIME['time'])) 77 TIME['time']=TIME['time']-300 78 return 79 80 if int(lastfmRating) == int(rating): #existing track 81 if not isAlbumInTags(album, tags): #same rating - check album exists 82 log.info(artist + ' - ' + track + ' (' + album + "): album to be added to LastFm") 83 addAlbumTag(api, artist, track, album) 84 85 86 def getTags(tags): 87 if not tags.has_key('tag'): 88 return set([]) 89 90 if isinstance(tags['tag'], dict): 91 return {tags['tag']['name']} 92 93 return set(map(lambda x: x.get('name').lower(), tags['tag'])) 94 95 96 def getRating(tags): 97 if '5-stars' in tags: 98 return 100 99 if '4-stars' in tags: 100 return 80 101 if '3-stars' in tags: 102 return 60 103 if '2-stars' in tags: 104 return 40 105 if '1-stars' in tags: 106 return 20 107 return 0 108 109 110 def addRatingTag(api, artist, track, rating): 111 api.track_addTags(artist=artist, track=track, tags=str(int(rating) / 20) + "-stars", sk=SESS_KEY) 112 if (rating == '100'): 113 api.track_love(artist=artist, track=track, sk=SESS_KEY) 114 115 116 def addAlbumTag(api, artist, track, album): 117 api.track_addTags(artist=artist, track=track, tags=normalizeAlbumName(album), sk=SESS_KEY) 118 119 120 def isAlbumInTags(album, tags): 121 return normalizeAlbumName(album) in tags 122 123 124 def normalizeAlbumName(album): ##только буквы, цифры, дефисы, пробелы или двоеточия 125 rus2lat = { 126 u'й': 'i', 127 u'ц': 'ts', 128 u'у': 'u', 129 u'к': 'k', 130 u'е': 'e', 131 u'н': 'n', 132 u'г': 'g', 133 u'ш': 'sh', 134 u'щ': 'sch', 135 u'з': 'z', 136 u'х': 'h', 137 u'ъ': '', 138 u'ф': 'f', 139 u'ы': 'y', 140 u'в': 'v', 141 u'а': 'a', 142 u'п': 'p', 143 u'р': 'r', 144 u'о': 'o', 145 u'л': 'l', 146 u'д': 'd', 147 u'ж': 'zh', 148 u'э': 'e', 149 u'я': 'ya', 150 u'ч': 'ch', 151 u'с': 's', 152 u'м': 'm', 153 u'и': 'i', 154 u'т': 't', 155 u'ь': '', 156 u'б': 'b', 157 u'ю': 'u', 158 u'ё': 'e' 159 } 160 allowedChars = re.compile('[a-z0-9\\- :]') 161 res = '' 162 163 for a in album.lower(): 164 if (allowedChars.match(a) == None): 165 if (rus2lat.has_key(a)): 166 res += rus2lat[a] 167 else: 168 res += a 169 170 return res 171 172 173 def clearTags(api, tags, artist, track): 174 for t in tags: 175 api.track_removeTag(artist=artist, track=track, tag=t, sk=SESS_KEY) 176 177 178 def updateiTunesXMLFromLastFm(finput, foutput): 179 log = logging.getLogger('mdb') 180 log.info('Updating iTunes data (' + finput + ' file) with ' + USERNAME + ' LastFm library ratings') 181 182 log.info('LastFm is beining initialized') 183 api = lastfmapi.LastFmApi(API_KEY, None) 184 log.info('LastFm initialized') 185 186 log.info('Parsing iTunes library file:' + finput) 187 d = parse(finput) 188 log.info("Parsed") 189 190 root = d.getElementsByTagName('plist')[0].getElementsByTagName('dict')[0].getElementsByTagName('dict')[0] 191 tracks = root.getElementsByTagName('dict') 192 log.info('Found ' + str(len(tracks)) + ' tracks in iTunes XML library file') 193 194 inc = 0 195 for t in tracks: 196 inc += 1 197 tt = None 198 ta = None 199 tal = None 200 ratingValue = None 201 202 for i in range(0, len(t.childNodes)): 203 node = t.childNodes[i] 204 if (node.nodeName == u'key'): 205 if (node.childNodes[0].nodeValue == u'Name'): 206 tt = t.childNodes[i + 1].childNodes[0].nodeValue 207 if (node.childNodes[0].nodeValue == u'Artist'): 208 ta = t.childNodes[i + 1].childNodes[0].nodeValue 209 if (node.childNodes[0].nodeValue == u'Album'): 210 tal = t.childNodes[i + 1].childNodes[0].nodeValue 211 if (node.childNodes[0].nodeValue == u'Rating'): 212 ratingValue = t.childNodes[i + 1].childNodes[0] #.nodeValue 213 214 if (tt != None and ta != None and tal != None): 215 log.debug(str(inc) + ": " + ta + ' - ' + tt + ' (' + tal + '). Loading LastFm info...') 216 lastfmTagsJSON = api.track_getTags(artist=ta, track=tt, user=USERNAME) 217 tags = getTags(lastfmTagsJSON['tags']) 218 newrating = getRating(tags) 219 log.debug('Got ' + str(newrating) + ' rating in ' + unicode(tags)) 220 221 if (newrating > 0 and isAlbumInTags(tal, tags)): 222 if (ratingValue == None): 223 log.info(ta + ' - ' + tt + ' (' + tal + '). Rating created.') 224 addRatingXMLEntries(d, t, str(newrating)) 225 else: 226 log.info(ta + ' - ' + tt + ' (' + tal + '). Rating updated.') 227 ratingValue.nodeValue = str(newrating) 228 229 log.info('Saving results to: ' + foutput) 230 f = open(foutput, 'wb') 231 f.write(d.toprettyxml(indent='', newl='', encoding='utf-8')) 232 f.close() 233 log.info('Done.') 234 235 236 def addRatingXMLEntries(xmlDocument, trackElement, rating): 237 ratingK = xmlDocument.createElement('key') 238 ratingK.appendChild(xmlDocument.createTextNode('Rating')) 239 trackElement.appendChild(ratingK) 240 241 ratingV = xmlDocument.createElement('integer') 242 ratingV.appendChild(xmlDocument.createTextNode(rating)) 243 trackElement.appendChild(ratingV) 244 245 246 #------------------------- MAIN ---------------------------------------------------------------------------------------- 247 248 249 def initializeLogging(): 250 logger = logging.getLogger('mdb') 251 logger.setLevel(logging.DEBUG) 252 253 h = logging.FileHandler('log.txt') 254 logger.addHandler(h) 255 h.setLevel(logging.DEBUG) 256 h.setFormatter(logging.Formatter('%(asctime)s %(funcName)s %(levelname)s\t%(message)s')) 257 258 h = logging.StreamHandler(sys.stdout) 259 h.setLevel(logging.INFO) 260 logger.addHandler(h) 261 262 if __name__ == "__main__": 263 initializeLogging() 264 265 if (sys.argv[1] == '--lastFm2iTunes'): 266 updateiTunesXMLFromLastFm(sys.argv[2], sys.argv[3]) 267 268 if (sys.argv[1] == '--iTunes2LastFm'): 269 updateLastFmFromiTunesXML(sys.argv[2]) 270