Как стать автором
Обновить

Python и Last.FM: скачиваем и прикрепляем обложки альбомов к mp3-файлам

Не знаю, как вам, но мне всегда было приятно, когда на экране плеера красуется обложка текущего альбома вместо унылой дефолтной картинки. Значительная часть релизов на всяких торрентах имеет оформленные теги вместе с картинками, однако, для остальной части прикреплять картинки вручную уж слишком накладно. Загуглить название группы и альбома, выбрать картинку приличного качества, скачать, а потом ещё назначать эту картинку через какой-нибудь проигрыватель. Бррр, сложно. Я не говорю уже о том, что все проигрыватели, через которые я это проделывал, уж как-то слишком медленно допиливают теги.

Взникла идея написать для этого скрипт: небольшое окошко где-нибудь в углу экрана. Перетаскиваешь туда файлы или папку с альбомом, а дальше программа сама ищет в интернете картинки и записывает их в mp3-файлы.
Ну и за одно хотелось занять себя чем-нибудь на унылых лекциях попрактиковаться в Python. И использовать это предполагается под Виндой, ибо недавно приобретённое 'апле' обязывает.

Под катом реализация данной идеи.

Откуда брать картинки, долго думать не пришлось: как следует из заголовка, я решил использовать last.fm. Мало того, что у них есть огромная база данных о музыке и исполнителях, там ещё, как оказалось, есть открытый API. Так что даже выдумывать ничего не пришлось.

Итак, взяв карандаш и бумагу, набросал алгоритм программы:
  1. Берём mp3-файл;
  2. Считываем из него название альбома и исполнителя;
  3. Формируем запрос для API last.fm;
  4. Обрабатываем XML-ответ last.fm и получаем адрес интересующей нас картинки;
  5. Скачиваем картинку;
  6. Записываем картинку в качестве соответствующего id3-фрейма в файл. Профит!
Далее опишу всё по порядку.
Да, прежде пара замечаний. Python начал изучать недавно, так что если если в коде что-то окажется не 'pythonic' — беспощадно забрасывайте помидорами комментариями. Буду благодарен. Проверки и защиту от дураков делал по минимуму. Писал для себя, да и не люблю я оверинжиниринг.

2. Чтение id3-тегов.


Начинаю со второго пункта, поскольку графический интерфейс писал в последнюю очередь.
Итак, беглый поиск в интернете показал, что библиотек для работы с id3-тегами для Python не более десятка. Причём большинство из них либо созданы только для чтения тегов, либо не работают с картинками. По требованиям подошла eyeD3 и даже оказалась довольно удобной и интуитивно понятной, но вот картинки в файл записывать у меня упорно не получалось. Помучившись и так, и эдак, решил отправить её в утиль и найти замену. К счастью, замена нашлась, хоть и с очень странным и неочевидным названием — mutagen. Это оказался довольно жирный пакет для работы с разными музыкальными форматами. Он содержит библиотеку easyid3 с простым и понятнымпространством имём, но проблему картинок он не решил. Поэтому я использовал более функциональный модуль id3. Однако, в отличие от easyid3 метки тегов там соответствуют не совсем очевидным именам из стандарта id3. Что ж, пришлось ознакомиться с этим стандартом, хотя, честно говоря, очень не хотелось.

Итак, подключаем библиотеку:
from mutagen.mp3 import MP3<br/>
from mutagen import id3 as mu
и реализуем пункт 2.
def fGetSongInfo(sSongLocation):<br/>
    muTrack=MP3(sSongLocation)<br/>
    sAlbum = muTrack.tags['TALB'].text<br/>
    sArtist = muTrack.tags['TPE1'].text<br/>
    return sArtist, sAlbum

Тут всё просто: передаём функции адрес mp3-файла и получаем в ответ кортеж из двух строк: названия исполнителя и альбома.
Да, предполагается, что соответствующие теги на месте (а они, как правило всегда присутствуют, а без них всё равно затея теряет смысл), поэтому все проверки я тут опустил.

3. Запрос для last.fm.


Как я уже упомянул, у last.fm есть открытый API. Я даже честно зарегистрировался и получил свой ключ.
Интересующий нас метод — album.getInfo.

Запрос будет выглядеть так:
http:⁄⁄ws.audioscrobbler.com⁄2.0&⁄?
method=album.getinfo&api_key=API_ключ&artist=Имя_исполнителя
&album=Название_альбома

Всё довольно просто. Стоит только помнить, что пробелы в названиях следует заменять плюсами. Функция тоже элементарная: используя urllib, приводим названия в порядок, создаём словарик с названиями атрибутов и кодируем его в запрос.
Хотя тут возник странный момент: этой функции предыдущая передаёт кортеж из двух строк, а приходит уже кортеж из двух списков по одному элементу в каждом:
print (lArtist, lAlbum)
([u'Chevelle'], [u'Wonder What's Next'])

Если кто-нибудь объяснит это, буду очень признателен. А пока пришлось выкручиваться:
def fCreateLfmRequest((lArtist, lAlbum)):<br/>
    '''<br/>
    Returns Last.FM API request<br/>
    '
''<br/>
    print (lArtist, lAlbum)<br/>
    sArtist=''.join(lArtist).encode('cp1251')<br/>
    sAlbum=''.join(lAlbum).encode('cp1251')<br/>
 <br/>
    sArtistTmp=ul.quote_plus(sArtist)<br/>
    sAlbumTmp=ul.quote_plus(sAlbum)<br/>
 <br/>
    dParams={"api_key" : sApiKey, 'artist' : sArtistTmp, 'album' : sAlbumTmp, 'method' : 'album.getinfo'}<br/>
    return ul.unquote(ul.urlencode(dParams))<br/>
 

4. Парсинг ответа.


Ответ от ластфм выглядит примерно так: ссылка.
Он содержит набор тегов с адресами сгенерированных автоматически картинок размерами от 34*34 до 300*300, а также исходник, который, не смотря на свё название, может быть и меньше 300*300. Выбираем размер по вкусу. Меня лично интересовал 300*300, ибо разрешение экрана на плеере 240*240.

Незадолго до реализации моей задумки на Хабре пиарили BeautifulSoup
, его я и использовал для парсинга.
from BeautifulSoup import BeautifulStoneSoup as bs <br/>
 
Это тоже реализуется в пару строчек, не считая проверки. Передаём исходник и название метки, получаем URL картинки.
def fUnparseImgUrl(sSrc, sSizeId):<br/>
    '''<br/>
    Gets an image URL from Last.FM querry result; returns URL of image ("large", 300x300).<br/>
    '
''<br/>
    bsSoup=bs(sSrc)<br/>
    if bsSoup.find('image', size=sSizeId)==Nonereturn ERROR_MSG<br/>
    else:<br/>
        try:<br/>
            return bsSoup.find('image', size=sSizeId).text<br/>
        except:<br/>
            return ERROR_MSG<br/>
 

5. Скачиваем картинку.


Берём и скачиваем. Я сохраняю как "Folder.jpg" в папке с альбомом и потом не удаляю. Авось пригодится ещё каком-нибудь приложению.
def fDownload(sSource, sTarget):<br/>
    '''<br/>
    Downloads file from Internet.<br/>
    sSource - source file URL<br/>
    sTarget - target file name<br/>
    '
''<br/>
    #Если альбом не нашёлся, качаем что-нибудь другое.<br/>
    if sSource == ERROR_MSG: sSource='http://cdn.last.fm/depth/global/download_aslogo_bluebg.jpg'<br/>
 <br/>
    fSourceImg = ul.urlopen(sSource)<br/>
    fTargetImg = open(sTarget, "wb")<br/>
    sData = fSourceImg.read()<br/>
    fTargetImg.write(sData)<br/>
    fTargetImg.close()<br/>
    fSourceImg.close()
В случае, если на last.fm по каким-то причинам ничего не нашлось, скачиваем другую картинку. Вместо этого я планировал написать функцию, которая генерит какую-нибудь картинку с названием исполнителя и альбома, но руки пока не дошли, да и не в тему статьи это. Поэтому заменил на такую затычку.

6. Прикрепляем картинку.


Тег для картинки выглядит так: 'APIC:метка_картинки'. Таким образом, картинок можно добавлять сколько угодно, лишь бы ключ начинался с "APIC" и двоеточия.
Значением по этому ключу является экземпляр класса APIC, имеющего аттрибуты encoding, mime, type, desc, data. Их назначение описано здесь.
Делаем простенькую проверку типа по расширению, создаём тег, добавляем к исходному файлу и не забываем сохранить. Аргумент v1 метода save() имеет следующий смысл:
if 0, ID3v1 tags will be removed
if 1, ID3v1 tags will be updated but not added
if 2, ID3v1 tags will be created and/or updated,

Так что выбираем двойку.
def fBindArtwork(sTrackAddr, sImgAddr, sArtAppend=u'frontcover'):<br/>
    '''<br/>
    Writes image data to mp3 file as APIC id3 frame.<br/>
    sTrackAddr - audio file address;<br/>
    sImgAddr - PNG/JPEG image file address;<br/>
    '
''<br/>
    #Open an Mp3 file<br/>
    muTrack=MP3(sTrackAddr)<br/>
    #Open picture file<br/>
    fPicture=file(sImgAddr, 'rb')<br/>
    sPic=fPicture.read()<br/>
 <br/>
    #Cheching image format<br/>
    if op.splitext(sImgAddr)[-1].lower()=='png': sMimeType=MIME_PNG<br/>
    elif (op.splitext(sImgAddr)[-1].lower()=='jpg'<br/>
        or op.splitext(sImgAddr)[-1].lower()=='jpeg'<br/>
        or op.splitext(sImgAddr)[-1].lower()=='jpe'): sMimeType=MIME_JPEG<br/>
    else: sMimeType=''<br/>
 <br/>
    #Creating a temporary APIC instance<br/>
    sArtworkTag=ARTWORK_TAG+sArtAppend<br/>
    muPic=mu.APIC(encoding=3, mime=MIME_JPEG, type=FRONT_COVER,<br/>
        desc=u'Album front cover', data=0)<br/>
    muTrack.tags[sArtworkTag]=muPic<br/>
 <br/>
    #Write image data to mp3 file<br/>
    muTrack.tags[sArtworkTag].data=sPic<br/>
    muTrack.save(v1=2)<br/>
 <br/>
    fPicture.close()

1. Графический интерфейс.


Подробно описывать не буду, ибо это не самая важная часть скрипта. Тут я использовал wxPython.
cDropFrame - производный от wx.Frame класс, собственно само окошко.
CDropTarget - класс для реальзиции драг-н-дропа. В методе OnDropFiles описывается его поведение.
Я не хотел лазить по деревьям папок (ибо себе не доверяю), потому сделал обработку только файлов внутри папки, если скинута папка, ну либо просто файлов, если перетащили файлы. А точнее, и то, и другое.
fProcessTracks - функция реализует написанный в начале статьи алгоритм. Проверок там городить не стал, ибо не вижу необходимости.
Предполагается, что внутри папки или среди скинутых файлов содержатся треки из одного и того же альбома (лично я бардака в своей музыкальной коллекции не допускаю), так что обложка скачивается один раз и назначается всем файлам.

Как это выглядит


А выглядит это примерно так:



Процесс назначения картинок для среднего альбома занимает не более трёх-пяти секунд. Я даже сперва не поверил и полез проверять файлы в айтюнсах. Ещё меня удивило, что при всей небрежности моих тестов треки не повреждались и после пыток исправно воспроизводились и на компе, и даже в капризном плеере.

Единственная проблема, которую я не смог решить — это работа с русскоязычными тегами. Похоже, что API last.fm просто с ними не работает. Печально, однако.

Код целиком.


GUI.pyw

import wx<br/>
import os.path as op<br/>
import os<br/>
import pyBinder as pb<br/>
 <br/>
FRAME_OPACITY=200<br/>
DEFAULT_IMAGE_NAME='Folder.jpg'<br/>
 <br/>
def fProcessTracks(lFileNames, wFrame):<br/>
    '''Обработка списка файлов'''<br/>
 <br/>
    #Ищем в списке первый mp3-файл...<br/>
    for sSongLocation in lFileNames:<br/>
        if op.splitext(sSongLocation)[-1].lower()=='.mp3'break<br/>
 <br/>
    #...и получаем для него обложку альбома<br/>
    wFrame.fSetGauge('Getting song info...'20)<br/>
    tSongInfo= pb.fGetSongInfo(sSongLocation)<br/>
 <br/>
    sImageLocation=op.join(op.dirname(sSongLocation), DEFAULT_IMAGE_NAME)<br/>
 <br/>
    wFrame.fSetGauge('Creating Last.FM request...'40)<br/>
    sLfmQuery=pb.fCreateLfmRequest(tSongInfo)<br/>
 <br/>
    wFrame.fSetGauge('Getting data from Last.FM...'60)<br/>
    sLfmResult=pb.fQueryLfm(sLfmQuery)<br/>
 <br/>
    wFrame.fSetGauge('Analysing Last.FM request...'80)<br/>
    sImgUrl=pb.fUnparseImgUrl(sLfmResult, pb.SIZE_300X300)<br/>
 <br/>
    wFrame.fSetGauge('Downloading image...'90)<br/>
    pb.fDownload(sImgUrl, sImageLocation)<br/>
 <br/>
    wFrame.fSetGauge('Almost ready.'100)<br/>
    wFrame.fSetImage(sImageLocation)<br/>
 <br/>
    #Назначаем всем mp3-файлам в списке полученную картинку<br/>
    #(Предполагается, что все треки из одного альбома)<br/>
    nGaugeStep=int(100/len(lFileNames))<br/>
    nGaugeValue=0<br/>
    for sFileName in lFileNames:<br/>
        if op.isfile(sFileName) and (op.splitext(sFileName)[-1].lower() == '.mp3'):<br/>
            nGaugeValue+=nGaugeStep<br/>
            wFrame.fSetGauge(op.basename(sFileName), nGaugeValue)<br/>
            pb.fBindArtwork(sFileName, sImageLocation)<br/>
    #Готово.<br/>
    wFrame.fSetGauge('Ready'100)<br/>
 <br/>
class CDropTarget(wx.FileDropTarget):<br/>
    '''Класс для Drag and Drop'''<br/>
 <br/>
    def __init__(self, wTargetWidget):<br/>
        wx.FileDropTarget.__init__(self)<br/>
        self.window=wTargetWidget<br/>
        self.wFrame=self.window.GetParent()<br/>
 <br/>
    def OnDropFiles(self, x, y, lDroppedContent):<br/>
        #Немного индусской магии!<br/>
        lFileNames=[]<br/>
        for sPath in lDroppedContent:<br/>
            if op.isdir(sPath):<br/>
                lInnerFileNames=[op.join(sPath, sFileName) for sFileName in os.listdir(sPath)]<br/>
                fProcessTracks(lInnerFileNames, self.wFrame)<br/>
            if op.isfile(sPath):<br/>
                lFileNames.append(sPath)<br/>
        if lFileNames!=[]:<br/>
            fProcessTracks(lFileNames, self.wFrame)<br/>
 <br/>
class CDropFrame(wx.Frame):<br/>
    '''Окошко приложения'''<br/>
 <br/>
    def __init__(self, nSize):<br/>
        wx.Frame.__init__(selfNone, title='pyBinder',<br/>
            style=wx.STAY_ON_TOP|wx.FRAME_TOOL_WINDOW|wx.CLOSE_BOX|wx.CAPTION|wx.SYSTEM_MENU)<br/>
 <br/>
        #Под Виндой size - это размер окна вместе с заголовком,<br/>
        #поэтому для создания квадратного окошко задаём clientSize вместо size<br/>
        self.SetClientSize((nSize,)*2)<br/>
        self.nSize=nSize<br/>
 <br/>
        #wPanel принимает Drag'нутые объекты<br/>
        self.wPanel=wx.Panel(self,pos=(0,0), size=(nSize,)*2)<br/>
        self.wPanel.SetBackgroundColour('#FFFFFF')<br/>
        wDropTarget=CDropTarget(self.wPanel)<br/>
        self.wPanel.SetDropTarget(wDropTarget)<br/>
 <br/>
        #Картинка для красоты.<br/>
        #Позже заменяется на обложку альбома функцией fProcessTracks()<br/>
        self.wBgFrame=wx.StaticBitmap(self.wPanel)<br/>
        self.fSetImage('Backgrounds\\Background2.jpg')<br/>
 <br/>
        #Текстовое поле ProgressBar<br/>
        self.wText=wx.StaticText(self.wBgFrame, -1'Drop some mp3 files here...'(0,(nSize/2)-15)(nSize,35), wx.ALIGN_CENTER)<br/>
        self.wText.SetBackgroundColour('#000000')<br/>
        self.wText.SetForegroundColour('#FFFFFF')<br/>
 <br/>
        self.wGauge=wx.Gauge(self.wText, -1100(5,20)(nSize-10,10))<br/>
        self.wGauge.SetValue(0)<br/>
 <br/>
        #Разполагаем окошко в правом нижнем углу экрана<br/>
        tScreenSize=wx.ScreenDC().GetSize()<br/>
        tFrameSize=self.GetSize()<br/>
        self.SetPosition(tuple(tScreenSize-tFrameSize))<br/>
 <br/>
        #Делаем прозрачность для понта<br/>
        self.SetTransparent(FRAME_OPACITY)<br/>
        #Готово!<br/>
        self.Show()<br/>
 <br/>
    #Установка фонового изображения<br/>
    def fSetImage(self, sImgSrc):<br/>
        wImage=wx.Image(sImgSrc)<br/>
        wImage.Rescale(self.nSizeself.nSize)<br/>
        wImage=wImage.ConvertToBitmap()<br/>
        self.wBgFrame.SetBitmap(wImage)<br/>
    #Обновление текста и статус-бара<br/>
    def fSetGauge(self, sMessage, nValue):<br/>
        self.wText.SetLabel(sMessage)<br/>
        #Размер текстового поля приходится каждый раз обновлять =\<br/>
        self.wText.SetSize((self.nSize,35))<br/>
        self.wGauge.SetValue(nValue)<br/>
 <br/>
if __name__=='__main__':<br/>
    aApp=wx.PySimpleApp()<br/>
    wDropFrame=CDropFrame(180)<br/>
    aApp.MainLoop()

pyBinder.py

import urllib as ul<br/>
import httplib as hl<br/>
from BeautifulSoup import BeautifulStoneSoup as bs<br/>
from mutagen.mp3 import MP3<br/>
from mutagen import id3 as mu<br/>
 <br/>
FRONT_COVER = 3<br/>
 <br/>
ARTWORK_TAG = u'APIC:'<br/>
MIME_PNG = 'image/png'<br/>
MIME_JPEG = 'image/jpeg'<br/>
 <br/>
SIZE_34X34=b'small'<br/>
SIZE_64X64=b'medium'<br/>
SIZE_174X174=b'large'<br/>
SIZE_300X300=b'extralarge'<br/>
SIZE_RAW=b'mega'<br/>
 <br/>
ERROR_MSG=b'FAYUUUL!'<br/>
 <br/>
sApiKey = b'c8e1cd060b177d97acd579faa5a4ef5e'<br/>
sLfmUrl='ws.audioscrobbler.com'<br/>
 <br/>
def fDownload(sSource, sTarget):<br/>
    '''<br/>
    Downloads file from Internet.<br/>
    sSource - source file URL<br/>
    sTarget - target file name<br/>
    '
''<br/>
    #Если альбом не нашёлся, качаем что-нибудь другое.<br/>
    if sSource == ERROR_MSG: sSource='http://cdn.last.fm/depth/global/download_aslogo_bluebg.jpg'<br/>
 <br/>
    fSourceImg = ul.urlopen(sSource)<br/>
    fTargetImg = open(sTarget, "wb")<br/>
    sData = fSourceImg.read()<br/>
    fTargetImg.write(sData)<br/>
    fTargetImg.close()<br/>
    fSourceImg.close()<br/>
 <br/>
def fBindArtwork(sTrackAddr, sImgAddr, sArtAppend=u'frontcover'):<br/>
    '''<br/>
    Writes image data to mp3 file as APIC id3 frame.<br/>
    sTrackAddr - audio file address;<br/>
    sImgAddr - PNG/JPEG image file address;<br/>
    '
''<br/>
    #Open an Mp3 file<br/>
    muTrack=MP3(sTrackAddr)<br/>
    #Open picture file<br/>
    fPicture=file(sImgAddr, 'rb')<br/>
    sPic=fPicture.read()<br/>
 <br/>
    #Cheching image format<br/>
    if op.splitext(sImgAddr)[-1].lower()=='png': sMimeType=MIME_PNG<br/>
    elif (op.splitext(sImgAddr)[-1].lower()=='jpg'<br/>
        or op.splitext(sImgAddr)[-1].lower()=='jpeg'<br/>
        or op.splitext(sImgAddr)[-1].lower()=='jpe'): sMimeType=MIME_JPEG<br/>
    else: sMimeType=''<br/>
 <br/>
    #Creating a temporary APIC instance<br/>
    sArtworkTag=ARTWORK_TAG+sArtAppend<br/>
    muPic=mu.APIC(encoding=3, mime=MIME_JPEG, type=FRONT_COVER,<br/>
        desc=u'Album front cover', data=0)<br/>
    muTrack.tags[sArtworkTag]=muPic<br/>
 <br/>
    #Write image data to mp3 file<br/>
    muTrack.tags[sArtworkTag].data=sPic<br/>
    muTrack.save(v1=2)<br/>
 <br/>
    fPicture.close()<br/>
 <br/>
def fGetSongInfo(sSongLocation):<br/>
    '''Gets Artist and Album data from an mp3 file.<br/>
    Returns a string (Artist, Album) tuple<br/>
    sSongLocation - location of the file'
''<br/>
    muTrack=MP3(sSongLocation)<br/>
    sAlbum = muTrack.tags['TALB'].text<br/>
    sArtist = muTrack.tags['TPE1'].text<br/>
    return sArtist, sAlbum<br/>
 <br/>
def fCreateLfmRequest((lArtist, lAlbum)):<br/>
    '''<br/>
    Returns Last.FM API request<br/>
    '
''<br/>
    print (lArtist, lAlbum)<br/>
    sArtist=''.join(lArtist).encode('cp1251')<br/>
    sAlbum=''.join(lAlbum).encode('cp1251')<br/>
 <br/>
    sArtistTmp=ul.quote_plus(sArtist)<br/>
    sAlbumTmp=ul.quote_plus(sAlbum)<br/>
 <br/>
    dParams={"api_key" : sApiKey, 'artist' : sArtistTmp, 'album' : sAlbumTmp, 'method' : 'album.getinfo'}<br/>
    return ul.unquote(ul.urlencode(dParams))<br/>
 <br/>
def fQueryLfm(sLfmRequest):<br/>
    '''<br/>
    Returns an XML result from Last.FM<br/>
    '
''<br/>
    cCon=hl.HTTPConnection(sLfmUrl)<br/>
 <br/>
    cCon.request('GET''/2.0/?'+sLfmRequest)   #Здесь я слегка схалтурил =)<br/>
    cResult = cCon.getresponse()<br/>
    return cResult.read()<br/>
    cCon.close()<br/>
 <br/>
def fUnparseImgUrl(sSrc, sSizeId):<br/>
    '''<br/>
    Gets an image URL from Last.FM querry result; returns URL of image ("large", 300x300).<br/>
    '
''<br/>
    bsSoup=bs(sSrc)<br/>
    if bsSoup.find('image', size=sSizeId)==Nonereturn ERROR_MSG<br/>
    else:<br/>
        try:<br/>
            return bsSoup.find('image', size=sSizeId).text<br/>
        except:<br/>
            return ERROR_MSG
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.