среда, 17 июля 2013 г.

Бэкап в облака - музыка

Продолжаю автоматизировать рутину. На этот раз проблема не столько в рутине как таковой, сколько в несовершенстве мира (да-да, немного немало).
Я большой любитель музыки. В смысле, слушатель. Как и всякий человек, что-то люблю больше, что-то меньше. Для сохранения моих вкусовых пристрастий современное программное обеспечение хочет мне в этом помочь. А именно - Winamp и iTunes позволяют расставлять рейтинги для композиций в своей библиотеке. Чтобы потом по ним можно было формировать умные плейлисты (например, хранить в iPhone / iPod только высокорейтинговые песни добавленные за последние 4 года). Вроде бы все хорошо.

Проблема

Вступает в силу закон дырявых абстракций и функционал "медиа-библиотека" начинает "течь". А именно:
  1. Рейтинг привязывается к конкретному файлу, а не песне как таковой (а точнее - триплету "артист - альбом - песня"). Что приводит к потере рейтинга песни при физическом перемещении файла песни с одного диска/папки/компа на другой.
  2. При хранении двух версий одной и той же песни (например lossless и lossy) рейтинг надо проставлять для каждой в отдельности
  3. Никакого способа автоматически сохранить / бекапить информацию о рейтингах. Есть только ручные операции "экспортировать медиа-библиотеку" и "импортировать медиа-библиотеку". Которые могут сломаться по тем же причинам что и в п.1. Кроме того, операция мануальная и требует, чтобы человек (то есть я), про это вспоминал и куда-то файл с инфой о рейтингах сохранял.
Пару раз я так уже с трудом и любовью выставленные рейтинги терял. То RAID рассыплется, то Windows надо обновлять.

Решение

  1. С помощью AutoIt экспортировать медиа-библиотеку winamp в файл iTunes-xml файл. Рейтинги я расставляю в winamp, поэтому именно он первоисточник.
  2. Питоном разбираю этот XML (он очень тривиальный) и сохраняю в LastFM информацию о рейтинге композиции.
  3. Еще раз прохожу по XML и обновляю рейтинг исходя из LastFM рейтинга. Этот шаг позволит расставить рейтинг в "дубликатах песен" в случае когда рейтинг проставлен для FLAC песни и не проставлен для её mp3 версии.
  4. Тем же AutoIt импортирую получившийся iTunes XML файл в winamp.
  5. Импортировать тот же файл непосредственно в iTunes
Если периодически запускать скрипт который описан выше, в LastFm всегда будет вся информация о рейтингах, которую можно будет легко восстановить при различных катаклизмах с музыкальными файлами или системой.

Детали

Рейтинг будет сохраняться в LastFm в виде тега "N-stars" к каждой песне. N - количество звезд на песне в библиотеке winamp. Благо, LastFm позволяет любое количество тэгов для пользователей. Причем тэги можно как добавлять, так и удалять через LastFm API.  Эта особенность нам нужна, если рейтинг песни будет меняться (разонравилась). Натуральным ограничением для тэга является использование только латиницы и всего пары знаков пунктуации в названии.

Еще сохраняем в виде тэга к песне название альбома, на котором эта песня получила такой рейтинг. Нужно это для того, чтобы отличать разные исполнения одной и той же песни и помнить этот выбор. Если таких альбомов несколько - не проблема, мы сохраним их все. LastFm за нас эту проблему не решает. Для него если только пара "исполнитель - песня". Альбом - это вторичное свойство песни и по-умолчанию LastFm считает песню с самого популярного альбома.

В процессе работы пришлось дописать lastfmapi (на который я уже ссылался). Дело в том, что pyLast (другой пакет для работы с lastfm) для авторизации методов записи просит md5 пользовательского пароля. Что меня сильно смутило. Кроме того, этот метод признан в LastFm устаревшим и, видимо, скоро его совсем выключат.
Пришлось реализовывать поддержку методов записи в lastfmapi. Это не заняло много времени и не потребовало много изменений. Мою версию lastfmapi можно найти на гитхабе (надеюсь автор замержится обратно). Ну и чтобы два раза не вставать, сделал еще пару write вызовов: композиции с рейтингом 3+ будут скроблиться в LastFm, а композиции с рейтингом "5" - добавляться в категорию "любимых".

Код ниже работает в 2х режимах:
  • обновить LastFm рейтингами из iTunes xml (при этом LaftFm рейтинг не может понизится) - т.н. "бэкап"
  • обновить iTunes xml рейтингами из LastFm (рейтинги из winamp xml перезапишутся) - т.н. "восстановление"
А вот собственно "вкусо-БД".
Код для AutoIt смотрите в следующих сериях.

smogmusicdb.py

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