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

Взникла идея написать для этого скрипт: небольшое окошко где-нибудь в углу экрана. Перетаскиваешь туда файлы или папку с альбомом, а дальше программа сама ищет в интернете картинки и записывает их в 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