Продолжаю
автоматизировать рутину. На этот раз проблема не столько в рутине как таковой, сколько в несовершенстве мира (да-да, немного немало).
Я большой любитель музыки. В смысле, слушатель. Как и всякий человек, что-то люблю больше, что-то меньше. Для сохранения моих вкусовых пристрастий современное программное обеспечение хочет мне в этом помочь. А именно -
Winamp и
iTunes позволяют расставлять рейтинги для композиций в своей библиотеке. Чтобы потом по ним можно было формировать
умные плейлисты (например, хранить в iPhone / iPod только высокорейтинговые песни добавленные за последние 4 года). Вроде бы все хорошо.
Проблема
Вступает в силу
закон дырявых абстракций и функционал "медиа-библиотека" начинает "течь". А именно:
- Рейтинг привязывается к конкретному файлу, а не песне как таковой (а точнее - триплету "артист - альбом - песня"). Что приводит к потере рейтинга песни при физическом перемещении файла песни с одного диска/папки/компа на другой.
- При хранении двух версий одной и той же песни (например lossless и lossy) рейтинг надо проставлять для каждой в отдельности
- Никакого способа автоматически сохранить / бекапить информацию о рейтингах. Есть только ручные операции "экспортировать медиа-библиотеку" и "импортировать медиа-библиотеку". Которые могут сломаться по тем же причинам что и в п.1. Кроме того, операция мануальная и требует, чтобы человек (то есть я), про это вспоминал и куда-то файл с инфой о рейтингах сохранял.
Пару раз я так уже с трудом и любовью выставленные рейтинги терял. То RAID рассыплется, то Windows надо обновлять.
Решение
- С помощью 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. Это не заняло много времени и не потребовало много изменений.
Мою версию lastfmapi можно найти на гитхабе (надеюсь автор замержится обратно). Ну и чтобы два раза не вставать, сделал еще пару write вызовов: композиции с рейтингом 3+ будут скроблиться в LastFm, а композиции с рейтингом "5" - добавляться в категорию "любимых".
Код ниже работает в 2х режимах:
- обновить LastFm рейтингами из iTunes xml (при этом LaftFm рейтинг не может понизится) - т.н. "бэкап"
- обновить iTunes xml рейтингами из LastFm (рейтинги из winamp xml перезапишутся) - т.н. "восстановление"
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