From 0817085adae1a9d58a786b116aadd8ba9df725f9 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Mon, 11 Jan 2016 16:53:41 +0100 Subject: [PATCH 01/12] Add contextmenu for emby settings (used to update ratings) Add ratings sync for music files (get rating details from music files and sync back to emby) --- addon.xml | 7 + contextmenu.py | 125 ++ resources/language/English/strings.xml | 11 +- resources/lib/api.py | 44 +- resources/lib/embydb_functions.py | 12 +- resources/lib/itemtypes.py | 98 +- resources/lib/musicutils.py | 131 ++ resources/lib/mutagen/__init__.py | 43 + .../__pycache__/__init__.cpython-35.pyc | Bin 0 -> 914 bytes .../__pycache__/_compat.cpython-35.pyc | Bin 0 -> 2754 bytes .../__pycache__/_constants.cpython-35.pyc | Bin 0 -> 3099 bytes .../mutagen/__pycache__/_file.cpython-35.pyc | Bin 0 -> 7763 bytes .../__pycache__/_mp3util.cpython-35.pyc | Bin 0 -> 8626 bytes .../mutagen/__pycache__/_tags.cpython-35.pyc | Bin 0 -> 2970 bytes .../__pycache__/_toolsutil.cpython-35.pyc | Bin 0 -> 6102 bytes .../mutagen/__pycache__/_util.cpython-35.pyc | Bin 0 -> 17420 bytes .../__pycache__/_vorbis.cpython-35.pyc | Bin 0 -> 10304 bytes .../mutagen/__pycache__/aac.cpython-35.pyc | Bin 0 -> 10050 bytes .../mutagen/__pycache__/aiff.cpython-35.pyc | Bin 0 -> 10216 bytes .../mutagen/__pycache__/apev2.cpython-35.pyc | Bin 0 -> 20902 bytes .../__pycache__/easyid3.cpython-35.pyc | Bin 0 -> 17989 bytes .../__pycache__/easymp4.cpython-35.pyc | Bin 0 -> 10727 bytes .../mutagen/__pycache__/flac.cpython-35.pyc | Bin 0 -> 28214 bytes .../mutagen/__pycache__/m4a.cpython-35.pyc | Bin 0 -> 3561 bytes .../__pycache__/monkeysaudio.cpython-35.pyc | Bin 0 -> 2904 bytes .../mutagen/__pycache__/mp3.cpython-35.pyc | Bin 0 -> 9514 bytes .../__pycache__/musepack.cpython-35.pyc | Bin 0 -> 8017 bytes .../mutagen/__pycache__/ogg.cpython-35.pyc | Bin 0 -> 16965 bytes .../__pycache__/oggflac.cpython-35.pyc | Bin 0 -> 4862 bytes .../__pycache__/oggopus.cpython-35.pyc | Bin 0 -> 4763 bytes .../__pycache__/oggspeex.cpython-35.pyc | Bin 0 -> 4600 bytes .../__pycache__/oggtheora.cpython-35.pyc | Bin 0 -> 4781 bytes .../__pycache__/oggvorbis.cpython-35.pyc | Bin 0 -> 4602 bytes .../__pycache__/optimfrog.cpython-35.pyc | Bin 0 -> 2545 bytes .../__pycache__/trueaudio.cpython-35.pyc | Bin 0 -> 2924 bytes .../__pycache__/wavpack.cpython-35.pyc | Bin 0 -> 3738 bytes resources/lib/mutagen/_compat.py | 86 + resources/lib/mutagen/_constants.py | 199 ++ resources/lib/mutagen/_file.py | 253 +++ resources/lib/mutagen/_mp3util.py | 420 ++++ resources/lib/mutagen/_tags.py | 101 + resources/lib/mutagen/_toolsutil.py | 231 ++ resources/lib/mutagen/_util.py | 550 +++++ resources/lib/mutagen/_vorbis.py | 330 +++ resources/lib/mutagen/aac.py | 410 ++++ resources/lib/mutagen/aiff.py | 357 +++ resources/lib/mutagen/apev2.py | 710 ++++++ resources/lib/mutagen/asf/__init__.py | 319 +++ .../asf/__pycache__/__init__.cpython-35.pyc | Bin 0 -> 8567 bytes .../asf/__pycache__/_attrs.cpython-35.pyc | Bin 0 -> 15131 bytes .../asf/__pycache__/_objects.cpython-35.pyc | Bin 0 -> 15903 bytes .../asf/__pycache__/_util.cpython-35.pyc | Bin 0 -> 10603 bytes resources/lib/mutagen/asf/_attrs.py | 438 ++++ resources/lib/mutagen/asf/_objects.py | 437 ++++ resources/lib/mutagen/asf/_util.py | 315 +++ resources/lib/mutagen/easyid3.py | 534 +++++ resources/lib/mutagen/easymp4.py | 285 +++ resources/lib/mutagen/flac.py | 876 ++++++++ resources/lib/mutagen/id3/__init__.py | 1093 ++++++++++ .../id3/__pycache__/__init__.cpython-35.pyc | Bin 0 -> 27785 bytes .../id3/__pycache__/_frames.cpython-35.pyc | Bin 0 -> 64560 bytes .../id3/__pycache__/_specs.cpython-35.pyc | Bin 0 -> 22816 bytes .../id3/__pycache__/_util.cpython-35.pyc | Bin 0 -> 4933 bytes resources/lib/mutagen/id3/_frames.py | 1925 +++++++++++++++++ resources/lib/mutagen/id3/_specs.py | 635 ++++++ resources/lib/mutagen/id3/_util.py | 167 ++ resources/lib/mutagen/m4a.py | 101 + resources/lib/mutagen/monkeysaudio.py | 86 + resources/lib/mutagen/mp3.py | 362 ++++ resources/lib/mutagen/mp4/__init__.py | 1010 +++++++++ .../mp4/__pycache__/__init__.cpython-35.pyc | Bin 0 -> 31145 bytes .../mp4/__pycache__/_as_entry.cpython-35.pyc | Bin 0 -> 14269 bytes .../mp4/__pycache__/_atom.cpython-35.pyc | Bin 0 -> 6248 bytes .../mp4/__pycache__/_util.cpython-35.pyc | Bin 0 -> 590 bytes resources/lib/mutagen/mp4/_as_entry.py | 542 +++++ resources/lib/mutagen/mp4/_atom.py | 194 ++ resources/lib/mutagen/mp4/_util.py | 21 + resources/lib/mutagen/musepack.py | 270 +++ resources/lib/mutagen/ogg.py | 548 +++++ resources/lib/mutagen/oggflac.py | 161 ++ resources/lib/mutagen/oggopus.py | 158 ++ resources/lib/mutagen/oggspeex.py | 154 ++ resources/lib/mutagen/oggtheora.py | 148 ++ resources/lib/mutagen/oggvorbis.py | 159 ++ resources/lib/mutagen/optimfrog.py | 74 + resources/lib/mutagen/trueaudio.py | 84 + resources/lib/mutagen/wavpack.py | 125 ++ resources/lib/utils.py | 2 +- 88 files changed, 15314 insertions(+), 27 deletions(-) create mode 100644 contextmenu.py create mode 100644 resources/lib/musicutils.py create mode 100644 resources/lib/mutagen/__init__.py create mode 100644 resources/lib/mutagen/__pycache__/__init__.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_compat.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_constants.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_file.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_mp3util.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_tags.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_toolsutil.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_util.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/_vorbis.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/aac.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/aiff.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/apev2.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/easyid3.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/easymp4.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/flac.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/m4a.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/monkeysaudio.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/mp3.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/musepack.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/ogg.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/oggflac.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/oggopus.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/oggspeex.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/oggtheora.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/oggvorbis.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/optimfrog.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/trueaudio.cpython-35.pyc create mode 100644 resources/lib/mutagen/__pycache__/wavpack.cpython-35.pyc create mode 100644 resources/lib/mutagen/_compat.py create mode 100644 resources/lib/mutagen/_constants.py create mode 100644 resources/lib/mutagen/_file.py create mode 100644 resources/lib/mutagen/_mp3util.py create mode 100644 resources/lib/mutagen/_tags.py create mode 100644 resources/lib/mutagen/_toolsutil.py create mode 100644 resources/lib/mutagen/_util.py create mode 100644 resources/lib/mutagen/_vorbis.py create mode 100644 resources/lib/mutagen/aac.py create mode 100644 resources/lib/mutagen/aiff.py create mode 100644 resources/lib/mutagen/apev2.py create mode 100644 resources/lib/mutagen/asf/__init__.py create mode 100644 resources/lib/mutagen/asf/__pycache__/__init__.cpython-35.pyc create mode 100644 resources/lib/mutagen/asf/__pycache__/_attrs.cpython-35.pyc create mode 100644 resources/lib/mutagen/asf/__pycache__/_objects.cpython-35.pyc create mode 100644 resources/lib/mutagen/asf/__pycache__/_util.cpython-35.pyc create mode 100644 resources/lib/mutagen/asf/_attrs.py create mode 100644 resources/lib/mutagen/asf/_objects.py create mode 100644 resources/lib/mutagen/asf/_util.py create mode 100644 resources/lib/mutagen/easyid3.py create mode 100644 resources/lib/mutagen/easymp4.py create mode 100644 resources/lib/mutagen/flac.py create mode 100644 resources/lib/mutagen/id3/__init__.py create mode 100644 resources/lib/mutagen/id3/__pycache__/__init__.cpython-35.pyc create mode 100644 resources/lib/mutagen/id3/__pycache__/_frames.cpython-35.pyc create mode 100644 resources/lib/mutagen/id3/__pycache__/_specs.cpython-35.pyc create mode 100644 resources/lib/mutagen/id3/__pycache__/_util.cpython-35.pyc create mode 100644 resources/lib/mutagen/id3/_frames.py create mode 100644 resources/lib/mutagen/id3/_specs.py create mode 100644 resources/lib/mutagen/id3/_util.py create mode 100644 resources/lib/mutagen/m4a.py create mode 100644 resources/lib/mutagen/monkeysaudio.py create mode 100644 resources/lib/mutagen/mp3.py create mode 100644 resources/lib/mutagen/mp4/__init__.py create mode 100644 resources/lib/mutagen/mp4/__pycache__/__init__.cpython-35.pyc create mode 100644 resources/lib/mutagen/mp4/__pycache__/_as_entry.cpython-35.pyc create mode 100644 resources/lib/mutagen/mp4/__pycache__/_atom.cpython-35.pyc create mode 100644 resources/lib/mutagen/mp4/__pycache__/_util.cpython-35.pyc create mode 100644 resources/lib/mutagen/mp4/_as_entry.py create mode 100644 resources/lib/mutagen/mp4/_atom.py create mode 100644 resources/lib/mutagen/mp4/_util.py create mode 100644 resources/lib/mutagen/musepack.py create mode 100644 resources/lib/mutagen/ogg.py create mode 100644 resources/lib/mutagen/oggflac.py create mode 100644 resources/lib/mutagen/oggopus.py create mode 100644 resources/lib/mutagen/oggspeex.py create mode 100644 resources/lib/mutagen/oggtheora.py create mode 100644 resources/lib/mutagen/oggvorbis.py create mode 100644 resources/lib/mutagen/optimfrog.py create mode 100644 resources/lib/mutagen/trueaudio.py create mode 100644 resources/lib/mutagen/wavpack.py diff --git a/addon.xml b/addon.xml index 69e48b98..45aa64a7 100644 --- a/addon.xml +++ b/addon.xml @@ -16,6 +16,13 @@ + + + + Settings for the Emby Server + !IsEmpty(ListItem.DBID) + + all en diff --git a/contextmenu.py b/contextmenu.py new file mode 100644 index 00000000..48fc2c87 --- /dev/null +++ b/contextmenu.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import os +import sys +import urlparse + +import xbmc +import xbmcaddon +import xbmcgui + +addon_ = xbmcaddon.Addon(id='plugin.video.emby') +addon_path = addon_.getAddonInfo('path').decode('utf-8') +base_resource = xbmc.translatePath(os.path.join(addon_path, 'resources', 'lib')).decode('utf-8') +sys.path.append(base_resource) + +import artwork +import utils +import clientinfo +import downloadutils +import librarysync +import read_embyserver as embyserver +import embydb_functions as embydb +import kodidb_functions as kodidb +import musicutils as musicutils +import api + +def logMsg(msg, lvl=1): + utils.logMsg("%s %s" % ("Emby", "Contextmenu"), msg, lvl) + + +#Kodi contextmenu item to configure the emby settings +#for now used to set ratings but can later be used to sync individual items etc. +if __name__ == '__main__': + + + itemid = xbmc.getInfoLabel("ListItem.DBID").decode("utf-8") + itemtype = xbmc.getInfoLabel("ListItem.DBTYPE").decode("utf-8") + if not itemtype and xbmc.getCondVisibility("Container.Content(albums)"): itemtype = "album" + if not itemtype and xbmc.getCondVisibility("Container.Content(artists)"): itemtype = "artist" + if not itemtype and xbmc.getCondVisibility("Container.Content(songs)"): itemtype = "song" + + logMsg("Contextmenu opened for itemid: %s - itemtype: %s" %(itemid,itemtype),0) + + userid = utils.window('emby_currUser') + server = utils.window('emby_server%s' % userid) + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + kodiconn = utils.kodiSQL('music') + kodicursor = kodiconn.cursor() + + emby = embyserver.Read_EmbyServer() + emby_db = embydb.Embydb_Functions(embycursor) + kodi_db = kodidb.Kodidb_Functions(kodicursor) + + item = emby_db.getItem_byKodiId(itemid, itemtype) + if item: + embyid = item[0] + + item = emby.getItem(embyid) + + print item + + API = api.API(item) + userdata = API.getUserData() + likes = userdata['Likes'] + favourite = userdata['Favorite'] + + options=[] + if likes == True: + #clear like for the item + options.append(utils.language(30402)) + if likes == False or likes == None: + #Like the item + options.append(utils.language(30403)) + if likes == True or likes == None: + #Dislike the item + options.append(utils.language(30404)) + if favourite == False: + #Add to emby favourites + options.append(utils.language(30405)) + if favourite == True: + #Remove from emby favourites + options.append(utils.language(30406)) + if itemtype == "song": + #Set custom song rating + options.append(utils.language(30407)) + + #addon settings + options.append(utils.language(30408)) + + #display select dialog and process results + header = utils.language(30401) + ret = xbmcgui.Dialog().select(header, options) + if ret != -1: + if options[ret] == utils.language(30402): + API.updateUserRating(embyid, deletelike=True) + if options[ret] == utils.language(30403): + API.updateUserRating(embyid, like=True) + if options[ret] == utils.language(30404): + API.updateUserRating(embyid, like=False) + if options[ret] == utils.language(30405): + API.updateUserRating(embyid, favourite=True) + if options[ret] == utils.language(30406): + API.updateUserRating(embyid, favourite=False) + if options[ret] == utils.language(30407): + query = ' '.join(("SELECT rating", "FROM song", "WHERE idSong = ?" )) + kodicursor.execute(query, (itemid,)) + currentvalue = int(round(float(kodicursor.fetchone()[0]),0)) + newvalue = xbmcgui.Dialog().numeric(0, "Set custom song rating (0-5)", str(currentvalue)) + if newvalue: + newvalue = int(newvalue) + if newvalue > 5: newvalue = "5" + musicutils.updateRatingToFile(newvalue, API.getFilePath()) + like, favourite, deletelike = musicutils.getEmbyRatingFromKodiRating(newvalue) + API.updateUserRating(embyid, like, favourite, deletelike) + query = ' '.join(( "UPDATE song","SET rating = ?", "WHERE idSong = ?" )) + kodicursor.execute(query, (newvalue,itemid,)) + kodiconn.commit() + + if options[ret] == utils.language(30408): + #Open addon settings + xbmc.executebuiltin("Addon.OpenSettings(plugin.video.emby)") + diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index 629d7d5b..303c36fc 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -258,8 +258,15 @@ Music Tracks Channels - - + + Emby options + Clear like for this item + Like this item + Dislike this item + Add to Emby favorites + Remove from Emby favorites + Set custom song rating + Emby addon settings diff --git a/resources/lib/api.py b/resources/lib/api.py index 4dbdcbfc..ea651a6e 100644 --- a/resources/lib/api.py +++ b/resources/lib/api.py @@ -29,7 +29,7 @@ class API(): played = False lastPlayedDate = None resume = 0 - rating = 0 + userrating = 0 try: userdata = self.item['UserData'] @@ -40,15 +40,15 @@ class API(): else: favorite = userdata['IsFavorite'] likes = userdata.get('Likes') - # Rating for album and songs + # Userrating is based on likes and favourite if favorite: - rating = 5 + userrating = 5 elif likes: - rating = 3 + userrating = 3 elif likes == False: - rating = 1 + userrating = 0 else: - rating = 0 + userrating = 1 lastPlayedDate = userdata.get('LastPlayedDate') if lastPlayedDate: @@ -71,11 +71,12 @@ class API(): return { 'Favorite': favorite, + 'Likes': likes, 'PlayCount': playcount, 'Played': played, 'LastPlayedDate': lastPlayedDate, 'Resume': resume, - 'Rating': rating + 'UserRating': userrating } def getPeople(self): @@ -259,11 +260,12 @@ class API(): item = self.item userdata = item['UserData'] - checksum = "%s%s%s%s%s%s" % ( + checksum = "%s%s%s%s%s%s%s" % ( item['Etag'], userdata['Played'], userdata['IsFavorite'], + userdata.get('Likes',''), userdata['PlaybackPositionTicks'], userdata.get('UnplayedItemCount', ""), userdata.get('LastPlayedDate', "") @@ -377,4 +379,28 @@ class API(): # Local path scenario, with special videotype filepath = filepath.replace("/", "\\") - return filepath \ No newline at end of file + return filepath + + def updateUserRating(self, itemid, like=None, favourite=None, deletelike=False): + #updates the userrating to Emby + import downloadutils + doUtils = downloadutils.DownloadUtils() + + if favourite != None and favourite==True: + url = "{server}/emby/Users/{UserId}/FavoriteItems/%s?format=json" % itemid + doUtils.downloadUrl(url, type="POST") + elif favourite != None and favourite==False: + url = "{server}/emby/Users/{UserId}/FavoriteItems/%s?format=json" % itemid + doUtils.downloadUrl(url, type="DELETE") + + if not deletelike and like != None and like==True: + url = "{server}/emby/Users/{UserId}/Items/%s/Rating?Likes=true&format=json" % itemid + doUtils.downloadUrl(url, type="POST") + if not deletelike and like != None and like==False: + url = "{server}/emby/Users/{UserId}/Items/%s/Rating?Likes=false&format=json" % itemid + doUtils.downloadUrl(url, type="POST") + if deletelike: + url = "{server}/emby/Users/{UserId}/Items/%s/Rating?format=json" % itemid + doUtils.downloadUrl(url, type="DELETE") + + self.logMsg( "updateUserRating on embyserver for embyId: %s - like: %s - favourite: %s - deletelike: %s" %(itemid, like, favourite, deletelike), 0) diff --git a/resources/lib/embydb_functions.py b/resources/lib/embydb_functions.py index 1afa0d84..df6cc54c 100644 --- a/resources/lib/embydb_functions.py +++ b/resources/lib/embydb_functions.py @@ -128,10 +128,11 @@ class Embydb_Functions(): "FROM emby", "WHERE emby_id = ?" )) - embycursor.execute(query, (embyid,)) - item = embycursor.fetchone() - - return item + try: + embycursor.execute(query, (embyid,)) + item = embycursor.fetchone() + return item + except: return None def getItem_byView(self, mediafolderid): @@ -291,4 +292,5 @@ class Embydb_Functions(): def removeItem(self, embyid): query = "DELETE FROM emby WHERE emby_id = ?" - self.embycursor.execute(query, (embyid,)) \ No newline at end of file + self.embycursor.execute(query, (embyid,)) + \ No newline at end of file diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index e69c5b8c..3a6f9060 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -18,7 +18,7 @@ import utils import embydb_functions as embydb import kodidb_functions as kodidb import read_embyserver as embyserver - +import musicutils as musicutils ################################################################################################## @@ -1931,7 +1931,6 @@ class Music(Items): if not pdialog and self.contentmsg: self.contentPop(title) - def add_updateArtist(self, item, artisttype="MusicArtist"): # Process a single artist kodiversion = self.kodiversion @@ -2045,7 +2044,7 @@ class Music(Items): genres = item.get('Genres') genre = " / ".join(genres) bio = API.getOverview() - rating = userdata['Rating'] + rating = userdata['UserRating'] artists = item['AlbumArtists'] if not artists: artists = item['ArtistItems'] @@ -2213,11 +2212,18 @@ class Music(Items): else: track = disc*2**16 + tracknumber year = item.get('ProductionYear') - bio = API.getOverview() duration = API.getRuntime() - rating = userdata['Rating'] - - + + #the server only returns the rating based on like/love and not the actual rating from the song + rating = userdata['UserRating'] + + #the server doesn't support comment on songs so this will always be empty + comment = API.getOverview() + + #if enabled, try to get the rating and comment value from the file itself + if not self.directstream: + rating, comment = self.getSongRatingAndComment(itemid, rating, API) + ##### GET THE FILE AND PATH ##### if self.directstream: path = "%s/emby/Audio/%s/" % (self.server, itemid) @@ -2264,11 +2270,11 @@ class Music(Items): "UPDATE song", "SET idAlbum = ?, strArtists = ?, strGenres = ?, strTitle = ?, iTrack = ?,", "iDuration = ?, iYear = ?, strFilename = ?, iTimesPlayed = ?, lastplayed = ?,", - "rating = ?", + "rating = ?, comment = ?", "WHERE idSong = ?" )) kodicursor.execute(query, (albumid, artists, genre, title, track, duration, year, - filename, playcount, dateplayed, rating, songid)) + filename, playcount, dateplayed, rating, comment, songid)) # Update the checksum in emby table emby_db.updateReference(itemid, checksum) @@ -2435,7 +2441,7 @@ class Music(Items): checksum = API.getChecksum() userdata = API.getUserData() runtime = API.getRuntime() - rating = userdata['Rating'] + rating = userdata['UserRating'] # Get Kodi information emby_dbitem = emby_db.getItem_byId(itemid) @@ -2450,6 +2456,7 @@ class Music(Items): # Process playstates playcount = userdata['PlayCount'] dateplayed = userdata['LastPlayedDate'] + rating, comment = self.getSongRatingAndComment(itemid, rating, API) query = "UPDATE song SET iTimesPlayed = ?, lastplayed = ?, rating = ? WHERE idSong = ?" kodicursor.execute(query, (playcount, dateplayed, rating, kodiid)) @@ -2461,6 +2468,77 @@ class Music(Items): emby_db.updateReference(itemid, checksum) + def getSongRatingAndComment(self, embyid, emby_rating, API): + previous_values = None + filename = API.getFilePath() + rating = 0 + emby_rating = int(round(emby_rating,0)) + file_rating, comment = musicutils.getSongTags(filename) + + currentvalue = None + kodiid = self.emby_db.getItem_byId(embyid)[0] + query = ' '.join(("SELECT rating", "FROM song", "WHERE idSong = ?" )) + self.kodicursor.execute(query, (kodiid,)) + currentvalue = int(round(float(self.kodicursor.fetchone()[0]),0)) + + #only proceed if we actually have a rating from the file + if file_rating == None and currentvalue: + return (currentvalue, comment) + elif file_rating == None and not currentvalue: + return (emby_rating, comment) + + file_rating = int(round(file_rating,0)) + self.logMsg("getSongRatingAndComment --> embyid: %s - emby_rating: %s - file_rating: %s - current rating in kodidb: %s" %(embyid, emby_rating, file_rating, currentvalue)) + + updateFileRating = False + updateEmbyRating = False + + if currentvalue != None: + # we need to translate the emby values... + if emby_rating == 1 and currentvalue == 2: + emby_rating = 2 + if emby_rating == 3 and currentvalue == 4: + emby_rating = 4 + + if (emby_rating == file_rating) and (file_rating != currentvalue): + #the rating has been updated from kodi itself, update change to both emby ands file + rating = currentvalue + updateFileRating = True + updateEmbyRating = True + elif (emby_rating != currentvalue) and (file_rating == currentvalue): + #emby rating changed - update the file + rating = emby_rating + updateFileRating = True + elif (file_rating != currentvalue) and (emby_rating == currentvalue): + #file rating was updated, sync change to emby + rating = file_rating + updateEmbyRating = True + elif (emby_rating != currentvalue) and (file_rating != currentvalue): + #both ratings have changed (corner case) - the highest rating wins... + if emby_rating > file_rating: + rating = emby_rating + updateFileRating = True + else: + rating = file_rating + updateEmbyRating = True + else: + #nothing has changed, just return the current value + rating = currentvalue + else: + # no rating yet in DB, always prefer file details... + rating = file_rating + updateEmbyRating = True + + if updateFileRating: + musicutils.updateRatingToFile(rating, filename) + + if updateEmbyRating: + # sync details to emby server. Translation needed between ID3 rating and emby likes/favourites: + like, favourite, deletelike = musicutils.getEmbyRatingFromKodiRating(rating) + API.updateUserRating(embyid, like, favourite, deletelike) + + return (rating, comment) + def remove(self, itemid): # Remove kodiid, fileid, pathid, emby reference emby_db = self.emby_db diff --git a/resources/lib/musicutils.py b/resources/lib/musicutils.py new file mode 100644 index 00000000..a9add080 --- /dev/null +++ b/resources/lib/musicutils.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +################################################################################################# + +import os +import xbmc, xbmcaddon, xbmcvfs +import utils +from mutagen.flac import FLAC +from mutagen.id3 import ID3 +from mutagen import id3 + +################################################################################################# + +# Helper for the music library, intended to fix missing song ID3 tags on Emby + +def logMsg(msg, lvl=1): + utils.logMsg("%s %s" % ("Emby", "musictools"), msg, lvl) + +def getRealFileName(filename): + #get the filename path accessible by python if possible... + isTemp = False + + if not xbmcvfs.exists(filename): + logMsg( "File does not exist! %s" %(filename), 0) + return (False, "") + + # determine if our python module is able to access the file directly... + if os.path.exists(filename): + filename = filename + elif os.path.exists(filename.replace("smb://","\\\\").replace("/","\\")): + filename = filename.replace("smb://","\\\\").replace("/","\\") + else: + #file can not be accessed by python directly, we copy it for processing... + isTemp = True + if "/" in filename: filepart = filename.split("/")[-1] + else: filepart = filename.split("\\")[-1] + tempfile = "special://temp/"+filepart + xbmcvfs.copy(filename, tempfile) + filename = xbmc.translatePath(tempfile).decode("utf-8") + + return (isTemp,filename) + +def getEmbyRatingFromKodiRating(rating): + # Translation needed between Kodi/ID3 rating and emby likes/favourites: + # 3+ rating in ID3 = emby like + # 5+ rating in ID3 = emby favourite + # rating 0 = emby dislike + # rating 1-2 = emby no likes or dislikes (returns 1 in results) + favourite = False + deletelike = False + like = False + if (rating >= 3): like = True + if (rating == 0): like = False + if (rating == 1 or rating == 2): deletelike = True + if (rating >= 5): favourite = True + return(like, favourite, deletelike) + +def getSongTags(file): + # Get the actual ID3 tags for music songs as the server is lacking that info + rating = None + comment = "" + + isTemp,filename = getRealFileName(file) + logMsg( "getting song ID3 tags for " + filename, 0) + + try: + if filename.lower().endswith(".flac"): + audio = FLAC(filename) + if audio.get("comment"): + comment = audio.get("comment")[0] + if audio.get("rating"): + rating = float(audio.get("rating")[0]) + #flac rating is 0-100 and needs to be converted to 0-5 range + if rating > 5: rating = (rating / 100) * 5 + elif filename.lower().endswith(".mp3"): + audio = ID3(filename) + if audio.get("comment"): + comment = audio.get("comment")[0] + if audio.get("POPM:Windows Media Player 9 Series"): + if audio.get("POPM:Windows Media Player 9 Series").rating: + rating = float(audio.get("POPM:Windows Media Player 9 Series").rating) + #POPM rating is 0-255 and needs to be converted to 0-5 range + if rating > 5: rating = (rating / 255) * 5 + else: + logMsg( "Not supported fileformat or unable to access file: %s" %(filename), 0) + rating = int(round(rating,0)) + + except Exception as e: + #file in use ? + logMsg("Exception in getSongTags %s" %e,0) + + #remove tempfile if needed.... + if isTemp: xbmcvfs.delete(filename) + + return (rating, comment) + +def updateRatingToFile(rating, file): + #update the rating from Emby to the file + + isTemp,filename = getRealFileName(file) + logMsg( "setting song rating: %s for filename: %s" %(rating,filename), 0) + + if not filename: + return + + try: + if filename.lower().endswith(".flac"): + audio = FLAC(filename) + calcrating = int(round((float(rating) / 5) * 100, 0)) + audio["rating"] = str(calcrating) + audio.save() + elif filename.lower().endswith(".mp3"): + audio = ID3(filename) + calcrating = int(round((float(rating) / 5) * 255, 0)) + audio.add(id3.POPM(email="Windows Media Player 9 Series", rating=calcrating, count=1)) + audio.save() + else: + logMsg( "Not supported fileformat: %s" %(filename), 0) + + #remove tempfile if needed.... + if isTemp: + xbmcvfs.delete(file) + xbmcvfs.copy(filename,file) + xbmcvfs.delete(filename) + + except Exception as e: + #file in use ? + logMsg("Exception in updateRatingToFile %s" %e,0) + + + \ No newline at end of file diff --git a/resources/lib/mutagen/__init__.py b/resources/lib/mutagen/__init__.py new file mode 100644 index 00000000..03ad7aee --- /dev/null +++ b/resources/lib/mutagen/__init__.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + + +"""Mutagen aims to be an all purpose multimedia tagging library. + +:: + + import mutagen.[format] + metadata = mutagen.[format].Open(filename) + +`metadata` acts like a dictionary of tags in the file. Tags are generally a +list of string-like values, but may have additional methods available +depending on tag or format. They may also be entirely different objects +for certain keys, again depending on format. +""" + +from mutagen._util import MutagenError +from mutagen._file import FileType, StreamInfo, File +from mutagen._tags import Metadata, PaddingInfo + +version = (1, 31) +"""Version tuple.""" + +version_string = ".".join(map(str, version)) +"""Version string.""" + +MutagenError + +FileType + +StreamInfo + +File + +Metadata + +PaddingInfo diff --git a/resources/lib/mutagen/__pycache__/__init__.cpython-35.pyc b/resources/lib/mutagen/__pycache__/__init__.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d767fdced0b459f7462e04b1308d99da0fe85f6 GIT binary patch literal 914 zcmZWn&2AGh5T5->vUQt+1XLW5@R3L~5<*-mKu8EABnp*UE=$nX$xaft_Il;@M%pWV z2i}3_;pQu+9=UR2oTh?`UCr2@8GrN5jQ55^c4zYI(~kt;C;aoJw0}j?d?i%^x#J=N#DcQIX z-Z)F7_t#J>p;~Mn^Jz#P_Jd;(9D^0whNk7y zGb1aXXi@uwhTwNMj^b(}-@)DumxB8J&0XGbCrJJxg+q+IJ`daG8FA zUh@=HD5@IzJ&I}ty+BdDpcg5cDCi}U%M`hEE}%<;qDk}RT}u8WX_NZhC#Bll>>pKs)hSJP3J%A6|KE zVWbUAKO)YU1-od+6D|H)CQw!HW^HSIcl$Iu=qC?9-+dZ) zcGEbMpALfXFxZ#rZtp0=n(X>vzc&c7)xoJM;j?>uo>4-W_Ymo39$tAEh3X)3h0dwa zrUrP0(MzG4h>Q~XRD-mxZghiQCki(2<1NJ?o27`zX` zGU)FPTxH-xd3qDu8vj7u(}<1j3{qH~>|9~XdUYmwYrQBnnQDnH2v4_1tg z4_2~{w_aZ{?3F9*E0&E^SO*|dERV`znbfkO4ac_#&EaC}3q#vH`$k~Lvs=32`)Q_p zU)OxUbDGK2_dQ2feLsrB%=gtaPH0c~{!tQ#{YZi{0}d>-!)`ieqn=D(1p`0pGbA$H zV{_R{B$~ptc&I!1*OkY7){|#fQs2g&$+S5TP!oB7=oAFe0#|@J5~(i&H3g|L4NNwC z3I2bxVdOKet2}J`m+Puztmn|3TnGAS1j<1C@H*1?JG|te89>p4*UYP+{55T{F)tAj zbL3Bgl6!@K&GJJ5G`B*416LZAzSYvDAPwW#E9-ik#z~q5Nhq}qPwR3dIW}qmO6amo z%nR4GXQ?t4RpDu@Zg~GOM#=?A2u@SXnEColV`t|E;^H}{QA4s+6Fa&z>;Z2r(Wj8Q zAt<-Nmv3zWU;i(B=ax>;x*45|1c5`j;TlBXzA_ELZjD@7Bp z&?OT)kmD^;SB4Gn8^-5@NGR?ZBez|-ejMyTfr};})Ero2jxi19Iru|B?>=heG}Z30 zRB7B#{5aX`YkT|K&v<&M*hV#vsa*?;ds$b*VSCc%e#`oO^g2FYJKcV=uS-1p+K!V< zH!#V4sr>A8AX9ahp(?{}solI0=6kxv*R`Q_`9uZDKI%8@4KN)j?O^iF?W?$DDsEG1 zlFu|cnC_$qkdjxyf%S``Wc(D}I)1^P6kAR^4jW3Mt;L;g?I9{dyti+W!Ksf!+}S literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_constants.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_constants.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..368544dcafdaf72965a51adee92899dd834cab69 GIT binary patch literal 3099 zcmeHJXPX;W89laEeb=!GF%a6&3?{4-a6$tF+Hf{Wl1cmliuJPDoxp9J0rJ{i0T zJPketd@A@f@MiGo;4{Eyg3kh<4F=$Iz~_R`1D_AR06YV}5PT7M7JM;y3-}Un89WEx z3f>036nq(Y9=sjA1AIC73hyz0_P|~6KJb3Nrh;EHMoF(_kx0 z_0YuURJk2xCdn5&kxr6u5bW5&fvR?F7N>5i$}2h^82Jj5fmI7@S$v=>>n@9jCa-RU zljRLNQ41G!o~!bfNp+N0x6M(|8X8r(6frc3Dql)R#;H=*PV&;unYJqJ=!vTAnBhd2QFt3oYMo2U23)*L%yXm$fVjZLBK05=|Pd&tZ`Kix&hl zmyIQa+%2>QAys+93V>>1U5`yZ%N%r~P}*6y$;3peylun8wj? z)yND|XXS;wCIOe%Y;>UN9w5#~>Wm)eOH!DhPdux((y?R1saoh{QJOc`bvzW}1TwL_ zvTG;7?YHGioskQZG}Q8nJodqFond)VXc3wGhV7!Mpfxrw9Ozisr2E<#nS|%YWvN&- zyUBDgGBlAivasVH)q5j*lxDF|rYh42BjfV&rd?hO{dB^B00e7!c-QBYu1?j42kFmJBuBwe1< zque%qA$oaJc1k}x<}f@IQdBx7N~M9i*U-@UK{r~5qO+l7GXh^Rs3L8Cvg_>78PV9G zx%T7<3}#PG^rqv<$j@wcU++csXtu1i!(q_YF(v89x-7|0tm}VHM>?wvhJ*vSyg%hgfn@qDDa{l3t^(HugbeZ7%L2y_!7|L8CfU$+d7Ig^Tn<` zlDzY~rJd0<9gU^iLLK!rXH1e#BN5n2CUmL$Nzaj35hcpD$mWe!(9shSTcq*uvEH7J5}hyZj)Y&=%&**v!?D10QHu3*i__7>uUdVvEIkmV zwOl%jRBdZ#ACN%Rwy;1*n$M^tod|Em0}lFrS?i3#C=xGF^%Z+Gt5KC!WTvIn_Li!x z4!oZTTdH$oQH%4%Kn{+P>>68n^q>$-Dl06rvaX$+G2&N|74@ppg%t^OA&O?Uyfk+! zyX<9>*Mvngg4geLIuj|&IaJ>?SJEq?iG=o>;`4Iq)VKT@PzZO?+sTd{i{QlN>f70J z(N8YKv7+6XPZUePUDd^N1RZblHSroV)VVitZ^pBD^<5_xSA=d}wl3maAd<)WE(>2# z^;X8v_N^7?D3-zQNDve?+_-Hr6RemlO~l)R%R*6sy=yrTE6M%Y7S1zO*2P)mD5wg+ zqTh*kT8~wcT-Bt|0;BD-#~-Wi@6!(U_XWn)?VZ)$qj2&gad!Fl`OcktUD1$@&)>1P z8TR**Fg454(SZa@_QtbAes6zp?fgGCnLbv#YiyZC=I)aJ$@%axpi(Lw`~T;Ehd}-K Fe*pD4cI^NF literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_file.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_file.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e8bbe118279fb735d7106993b1282ab526e22c2 GIT binary patch literal 7763 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 literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_mp3util.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_mp3util.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e242cafa64776176be78a6c7ef2f18a25b8a3e70 GIT binary patch literal 8626 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 literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_tags.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_tags.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b49b1dfcbd2a2fce928e9ac92ebfbdbf514f2518 GIT binary patch literal 2970 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 UpK+#}b6(?2qc(M<(P&)xALiS7%>V!Z literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_util.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_util.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..199c081ba21657517d7297d2389319f50067db88 GIT binary patch literal 17420 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 z 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 literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/_vorbis.cpython-35.pyc b/resources/lib/mutagen/__pycache__/_vorbis.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a667067893e573ea9145da73a1180c5996a0eb6 GIT binary patch literal 10304 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 PTb(Rqr*rh*#lQSNYB63I literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/aac.cpython-35.pyc b/resources/lib/mutagen/__pycache__/aac.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c976f6f4650780c5745425975ff709a31937ca5d GIT binary patch literal 10050 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 SUi`)48;ka0Z}9@N^#28zz&&mN literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/aiff.cpython-35.pyc b/resources/lib/mutagen/__pycache__/aiff.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f9a42314d9662e22c2aa38fff07809f81db17f50 GIT binary patch literal 10216 zcmb_iOLH98b-w+WCmL@MAjDV8p=6CDK$d8g5=IZuAStp$jby?UqzO~4!E}QdVjj@# z21!hbvdg5)t5mA8%hC&{D!Wu=?`-oMy0VJ1;UX2UwNbwB-0q$sLdljZ1N5ES_kGSi z=R4<~JImA4^?%s;!>!-Eq}1=##HWP(2A=p=mQtZoEu@wTZPl|>3mKEQRm+yVqk6XL zJF4Xep4PZb~bvH>LWXYI)|p zs(RC^KciYRsz0k*bEdSWdh@Ekpjr#6zo=S^s=uUKOQ@@>a7u-qx`Q>GQ16G+Dx6V| zvFI5UEvaZ(J#tlR78SE9oRf+<70s$>MJi6Ja9*`mRk)zSMb@^SQ{j>dPe}e0x-F}4 zMY^r1=(Ku-SleXtjdRm2Nr1Xr6JSp{6c$RDCe9xoB zIR!Gf=DLdN(&7gy`hjZw1^K?!@JBzqbp7g8zkL{Xhkl&ssNMIwgWaL-x0CL0;I{{% zpS179>rc3m&4!h_2kp-O6YQXBIB7K+gz^1uvX|D9=s^-BM+Z?_(UER<h_pn48p&Ed7E9Y*?!)C}WYl;S@i%SYp_oIVokd{rm4i}q$xYP|i@f?x}e1W_m6(QSd*m?@ZP0|tsgLXd( zg0vn4{b6|6W8MpbFAm#1Q}fSCpFnF#zFynBc>Cj{WN$dQ@aF9s-JRQUH;LBB9jq1V;;`_MDC)kY zhSyZ$<|Ve;FG)!$FL4ywY&G2G6Bh%1ZTVkYUp2~Uy&HE2anc@iB27F!lJ>~1(g8mUU>yKx|_2s-3N&<$Tiaa=~CtP}QmOFxfXK_1IFmyq|b z$bh`NLI&xv)on*TwA4deJ#?rQ4?z`bK>!%|4ly7ran%aiq3%mtxz4_=hJpIhDYEQg ztAVX-OZN#^4@+wBu8dV?js7JW$-;&dR@6xj2RW>&C5*EI28T7Ns;iS&*Am)(iN%!E z(pJN3j@)U3w?JypLUS!*lU##!$|_qPp1SoztAlf*Ydo z6iqank{)I%h`Sp{z1_5utxNxigO>Ni<{TGcn1Jq}n*@PuD1lQ!XlvH2yzX6#wKEn1`bobL6!XJ-;r3H34*@risD;9*8K%gTxbSRoeJj;)3@TDpnF`N>{n zWG?nePotz?!yu$4+Sh-DtiHhHO(YFVUu1r40R;S6{~4axMWP%mce+?EH=x7i@x$+8 zsXxIB@gLxCWYSXmuF|J6E5Ic?Psy-Qfg;GU^r1rryErU&R zw0nn<*+5dK-(^DL($anQhItms;7a}<@-|QgO;a&-QaLBAGIwOAAW;T*YCFgW*^aS| zgB>v2Vd#PrCgcEQKz2>(D^l%JGuZ+ekSY6j670!IJ$Eze5AQ|s1hKn%*q2wz9jTti zs21e{kciH2UFqQ07Qg8+F4hkqBYd2C6iP6SfJX^d-LjmyU zeaPG+hX6JqGp4>Vbr zDciHlUfHX8RnOC`?cgas+|~^|@l7PpwEmEu$JkYfDE>>5|C1ent;Td(!M5@MGCKV% zJC91Aidt+lm4~d~!@;2|aq-)T08Zfz=ybLmwD^ zGelWE_=P%J5yx7T2EA5c6Z}SBn0n*HD zL-(Ni8B)PDa@F!%b|4yw6{8MST=W;8s_l>#z0?$sGIg(hbn`>e!6)32hL_IMvXgIP z!#+1I5}znGsLee!((*cT>4|Zx0Z)d;92*Povq1@7X!pS2;H2{YwN%Q;6OtuDMS=P? zp182g?zFY&ER|*<){eCTB`#ab?xMA9jpnj-j}0>^;)mbdUfEjWg|T+eIPJ)W#%ltM zl7~ZP9&9ejdestvb597q& ziTvH+q4qCrZC<~QCT-pBB$1BS8)evBu#{k{#mhCXsKT(ePq z7JKy#kgCa9{XUY^<+(q1LOx`%KwN=y=~NKhi4u4!{UG=k)Mqy3gz(rAyW&`*#WAmo z)=#mzkVnq?y;yDdTsU7~=OZ|+U@!dik#GEx{|6raQGW60cp#SHkFX0~iecC9$ga$H zGa@;`rlM8BCbFn7yy>6MCdegtl=(eyVX7*DZz6sVP!y7fIshp^6B$?#MjC>bYnyKa(9}QlEY~W*gTtA03-NwlzKt#`Meg*1Dste*tzOx?fQUW5$qi7 zd!_;HL>vT!+;X4k`>TQ++yvqEI(R6s4=0xqu!V0|2uh_vd3w%zg9#BLE%ojK#L{v! z;CW2!x^WD|SrJE9l!*>p?wyGmeZV1(>5>P$1kL8e`0r>CFCl?vFVeASty@I)bM`cj zNV!5yc4;14T)zytW(rICsZUIL6D(k3Re`lc3DimO<(6nEtooX!o$^w-XrTokN6MFPid~ zeZ=DKBzykaS{5KS@2?_4?8i8JBlar`c-i0SCK^YWam%bgnj()?MLO$~b%CiH45D7l zK0m`*cW~I>K^ul$Bp997XU*ew{{UXd)3caDpM^5hwb9e9<$RHipM|((7;&$qsVG2! zF|>G#cqlgU?~xcNhW^7RI}$y{sc98ao9)3{uxU6WBEqtzEJO&c5j;6toH>CGbb>kC z{wxFvD25Qfa@D7dz95prvp&j7DggkATp-BDhK~AG<^uv~%IdbO9#+)Ds(M&c592FI^7uI<3c*Lmnz0tF7i`ZyZ7tY{=-_V&QJh@m#?B&CPd@xEtd5Tt>^~W6 zPSYlD5ZzI`@Us#4XpYui2%&Hy-oEJChL4h+gZNedh1kC~!q@s6ySWNATuqwv-!c(0 z7}a=oOeZZL9BAm*HXK&-NBH>pFOf5990`c;pJMOXV`xOMAiV2=Llf%w*XoRqSjLi zFo->0g_C6gDrLE7_*Kn7B|WSvX9xHJ_VAC!%Ws)CW+6nlS&9EZaO2?&>i|j%Kw9XD zTrtE+_+m?MVO^7j?7U`;JLB9U^I6+i%TI59igc?{8Tni7d$ zh)WGEU|R*)O2A%=KSq?*pCd`Dj4Bbmj|;3DgA>yfQZ`l&E_B=l949?%+O8uyUIxC- z;aNk;47{~z>m>e0UM|G!{*WJ+Ep>WPTjwe&bQKczJm=KLKvGdS`pE0Npcq z_7?F>j@8>xU<$Q4UH>`E8;dzFpzmNUV=nrI9@WecYf13fh11H*5k+BsL2!v?RR^Z=U28un&E(76W*P+5wdKN>DKD{(B2E;gn zTxjM)U+ms%Yx(8FMgL-_*N)?hpXXNfbMX{S3;q^j+yZiDDo1r0@ikudk35rqu*SCz ziQQqbi7k4jhC%PxhZad#OX$_C-Tb;%^tZdz11rxF=hN={IhB~^&4P<*| zd(Nm%2CYj)A*+A z3sre-hs5whcG9jBDC2)b0;~dUAmeB`(>-kB-+F48{;T>MJKRgc7{XZ1yrw6F3Xd(wb3WY%zXrubk1VH{em^Kj+DQ z!t>P6dL)?EmXP;)Gqv>&j?Myp9Z;q<6Y8nnV?wvW=sI0K-A00oiHpp$Pg>r;-_~IH z=PY@am*djG`GkBXj48VYLqXFp4MTAbSF59%@SLr_$HvOP{Gf@)<{4fWR~^AXj`sdiq~^2&0H$}Oo@ zLA6IzZA7(;s#a9(lB$(dyR2$u)gD!~QPr-fT1ByNYth)FVJYtGqGg?obac zbrT@&QMKn(ZLe~VsoEEmdtB9?SMIB-_JVrXomB1=pF!#EQ{Ievh`HLhu}itT<;HI1 z*~;55H@=7))5@KZ8#AdJFXG0ma`(uMJ*gW9aN{}U?v)#Rm3L4*1Vk^P7LYtIwa+VW zN@@?G_5~uI@L0+_q`Yw%_OS8}t6EjL`?wmM;mgXkmAhZgUs3KCmHVQcA3+xflzWgD zD5K$yDzBovY215BxrgK)`aa4*fg=3Z=5X(@a;tK$s=PVn@}m3tUzPUi9|YUfBn1SF%p38_6n3R`ORZcN(|tm1fKHkJ!r_ zY+iO+e%B7rPt-$q#cr&1n+>V3uRC`y^BE4?MJpXO^XKfPZrigjT|OCgvF6;3VF{eo zsQ+LM05zNr4vcQE>+W*PYx@SEw$s^U2W8i5bX`yTG727E#W^NtCT2QoHE(~yx0IcY4+dh8(#Bn?5;`@z@N6O1yaL@BP_6dRR#OtpEJxTjk7t3JVU*i}Ars4SB zQQzzMSfIO};JD0{lgZ(}#!Lyki$%c*0Eti7%}(kWqPi(7iD@l2T^EA~-Ew=Q6*Sjd z9_I-Jt~DELw%c4;!IH>AH;H85OGC5aZ#XT`fFE?V=az$J(DIJ5k!v6J<^cGivpQ({ z!692avbMn*mgiuV;hI0`GnR6g4}J-aKM=H4tp0%F`XE1x#UPUI%1&IGRS;v9DcOAXMjM3f&cS z(WhH~-D@;gnhhstb~~Wb4WQ8p%4 zoehHi*ustKNLXx~+YCH^;r%ds`NNZ8i4(5}o9iC0PlO}d(@xX(!X5rSXT2VD>xMeR z!hP*@R=sLAEMD>g$8`cHEOPv-e73w4fVA2Rot17iAC@jO8^NXK{bnc3H>737Kv!Sp z*x`<#8#t|cSA!No`C)~y0v8vx?&<~s<%i#6IL_nne+fmP)F&vx{W8kGq=Jmn`&9s8 zCj9W5mfD3$i$*7Fg{Y4dwM7P2JZs!$iigC>d8=?l!LxkNP0fQV{?CmyO0-M5aZ-DV(O96Xf z-timF<~xrer`6-nQG90EpIP6)!mX|@h1siT-wm@17fybby>$7NYEFLvE%fs&USP2g zMYR-`n?81C;9x0rne`gMImV6EFZ`TEWGn#(uAFubGW&R7%wX4|aIO=sP^dtz>VGn{Ptb?l|4 zTbHUjx;lb>{9P!NHEtEGN+xSfSz}fy`6cOe7{Wt6=3E|gR*yNq29D&z)t<-U zPoVH1E}^`jgxX>OxtTEUkvJAffAxCDX?yj0SgzOGU3a6!^Gdyb2OP%Sk%^^>D*DvN zP`t|#xDFLduAd2n2`uko_p8D}1+RccA7|IEvN*wl7)=#2@JZBt4M#jMub*k)=P>ZK zj=!-80gqIx^#@`W{__^`0j?Lr`i z#rTeX2}lTM2cK-U>g}cvUbPxNH+biMeOY&Jd!3Hw`(aMRLeBTR+hLAcNtpASJqRHP z0_=9&Xsv`J)DFALw}v(pIY6`13{Y|@o% zE?HBq=N=rgrvC#xXmgif4s0EyB1>Ui@4N&VVu=hnO&LN|q5)RMG$0m5rtk(BR*nd! zEP;UfB<>E>R^ZCY)6n6S%5e>3an~S6({no9RLH0&S6 z!@?vQ8rF=;@uGQv4EjybPeleB8yswR+-P%;$W1d-G1oBJ2N_7(Szrlr5t8;QkYoX4 z5XlK%fUs%xNk+}W#+w}+FpaNdb~EvSgi`R;2mE?xGFg#-&au*?U|G;SLi7+?{;TDV z3+aQ0WaR{GgnM9i@v#ju(6cPK?H?CW_$uVnR7 zdmp=QfT1LtZ-AlR!{##t2~fyMEj1JKEvzE8L(DM2Ee#9gUX%LhR)LIk`&%PVzGaxC z&;dE;9}17eWme6Q;>vPu%7UpHO#zC{lLbA4g!>dH{5abG*F-RwR_v~#I&J9@(#o*v z1!;lHXh8H11emg*X8SM{RWPRJ^(ko&$%S!wv!w2rK?Fuzb}A}>{KKA>9mw92!F)cP zy9IfeRc~hM_$Kv_ENU+O;~WZokp;KF0QL7K7P~ZJZSZGf&{W5TT7@VLrHE2!V<0lr;l#oDxAmg>))2o~dNVtzJsp33>MWN~n|j zdlUzEk^fsLh)8fTvub;zOK`r95v#%l4ZjAPBkA7nlHq}nZ|9W$hp}iuji8?dbG{NbI~)D89m^(D<0e_3b_&qBm!6JmqEN0IsBq02a~V>psTw5&y=!8{hG zM(!o6Jg}_IKa6=%b)>&4W?|ps6J$a95{fV{3?|K6G+d6u%5H6fOOqD$zl%ox7f>iH zNhMRFqy|SJk11q?qh#_{Z|{JV9_*nyHLPXJz+`noM20zD=OsC$KFfkyr;wh$#9H`>m6I5}ADVsy~M#=58VM;J21Lfd*jrUKN(&z6ariq?U?N8ShO~GHHBP3G(I4Yhm~VCOc{*kxhfp=N zyy0lQzUslz;Sa8mZ?Yh)u=vmMoIr+oaZ;HUeWE1F2r%Th0rBASD;&kAD!ws!++td({I=B~NmRKrtEh*TFd9+7`+H zsc=QYEY=(LM`rH0EJ)Qdiz9R7GJIA(-GY}Ap40WWKIbqTv!r?l2grtQ(Ye#DZUYYO zw{Q|afl-7!b8L|AJ3g1q0jgI2H|`rGxpPb3)#5Nmynyz)!2&!-=@l02r%(16!eNCB z!Cm{GaQI~umCWR5C0of?^0J%ROY-45@Z52#6G@R#9pRoAp-~c@Cwqvht zAOvxgmN31xQDkE-w8{x}bNC#G=Rp$$oAu-U>Sl(6LkVzYOzd5(`$O!SjGF$yx`U+= z2U;(G&_8p~r)#*dz7Dr*0EWGWUP9}3Xx;78i4e$VrPJ`odiU4$TRVVWq4I+2?Gvod zndegk9=C;euHcK0N~CU>yVY%WVlFIlST<6!n{*2RlsNz=rm~Y(uaX`jVPCvWQWf-* zuDOXty1~rEIp&BB{oN_z0b&kF;F@IBbx_RLuiOCtg$)UPh5QX_6&O9w;7E}hER6|F z-PveZqC4a1rE0GC+?>k|)p_O;P^_9=-EDXu!f?QV+rh^C!|B zRQFNo4lUvN*HT){y1iCABNE~s$k=>66h!Q?OiSxhyIefrKGkSMuWWl>XM z)btaJr$L+yz2`+Auo<68LJG%^*sn^6>V%IKMDrOI)KVWqng3kh=Ov5k4qfCqiNh%J z6pZ>R3p!#YgrL8Ov#=5c4UFG`WD;imO+PGp_Z#rFt#re2YC`n;Z-7)8^2yaZ-o0wY z$j7sMBHuuuD==;ct63}2p;xD$&*ZtK%G!sk9q>|2Krd(1VbVH^R=YF3oraO5`bxM7Wp*|?{C9DZ zYy%i7*aIMV?22hD76_DKrdN4QSFGVHs6_M#Bweu?~u9ZUr~X$iVWr9EwLh!im@V^^7qv9i%=NPHaW51!8ygw+j%1`sGm zMga=~izH{_VhVmochUSbm zA3;Jx(gUN`0iMEU#lVsUV8+{~S8DNm*iIfwx{-%@pYWPtj>V%2_{ad+3Z`k8bb_n} z$+RHrNX&A zrh_B4ch~DAa5N>AVN-HQ3>+K5%F#Dd%NHTM$95gdo)DK8@;x}TagjkmtlNhk<46#{BFoFi1=~%X~#2@5bXuH zHzcJ1<<9J0+?zyl!A>|4cV*L(ZmY+GO3Va~^EmuBAoswi)}f7w0|6Ejw7<0N0IqGA zVMdtYMS5eS`w;rVKPAdU4_a(pP}hTN_a{Ub5HAnR2v&~3H7{RO5p8oGOT_%1A6vEdbHpQ7e*fTJ4fUMn!1_dlRB4%OGS$!3*gxU4?m0}YGu{ZwM5dP^SYmh;90af~k#vZPW~b5GaQi$Bz61%_ z%S}KkQG4UhAaCkr^()K0?7df(O?W^ohlp+TvZqfhi1`tj7Succ8xG~uk^qyCP$-(c zw$S{&5$ybnsP2aq*peSi<^3k99##u>khXY{h0Sb~HM@NI+>7`L$X`(WQC7wH4JDYGiNb+BpW7d*B!2UjE@iTlTM~{RsEq#O6rG+@o z6y~LC8suHyi)|SJmR!R`sB*OX`xZ;BEP_PrnpzR}qO;CMnk1B=ASY`iY5r3z~=NoLn55JS;I*JQq zpUwCPed;>)Y!X$^CZpyqRefOIOYDoC$L4dQBizu z0KhEj&*Shfpa5t9kO?jml%h;af}PB3oBklVHr}4&6pl+UlxS3|*r<-k;#K1nrRtq1 zB(}e?ECC~`?vjh@+oEfUvD_Qq=-lpf?{$oMa1a@RBl@QSQAo$(RV1@YTm5ZT?LgHz zN$Hi!uox?A;VAB0jN87Cwm{3L`06jE@sk5$^#??Yaj;da90q^ul|bPMBGlO!29kGQF1!IvWB`~WZ-7q(H;?MSPQ#KkpHdLcrf zuhaaAwQ-|`Z}xTGLl9ZwKXN*ff(=u|AVri-N)YA%+oF*(IojC>x=7zg_A>LeT_hDF zAKit$U>q5Ui6zRGkt)5H#8it@Ua3GHv5k5teySMji!kenG%X0v8rASqRMheWFp3W* z3{xWnp#F%+7x%^pvrxvYof)Zb;9l|}^5=2*Z4^)_lb99+!FL zfg&aB`bdz@+(HRh;F1}J_%G3m&pg8Y9{+{lYgz{iHi{KQ|5?ps2ypi;orO$QML=iDl*tNfKL7*dcbBSR+r+7Kh={ zG!k_~`}qQH5D*h3%px5Dz|MyT=A&4AbWqT7T@WeSez#E?o z;8@JzL~%j%1&JWSZO-9qc+l^lZs!vQ6;0*Oh?UplL1UV6x1i`MHk=^gn6O5if|EwT zMTs{;TSF`NOMq}X1)(HYN)K}hfev6MhX-WoY@A@x(qb*5d=Oeztz^|S(!3XY<%9m= zgZhYl(5+T;NPYvHMo1^54luVt9+N_~x325;y$O6nByVm*&7-NA%Gv&um(u+OoQ0zz zeURk3)^#;WRg)Mr7e{mcFyK)15hG_J@yQDQHyIY~&I)YRLT0=$Szu%pu~pt};7C4z zQ_bV>*HH|yEwHLb;67jm_hc4GfdhGV4ZYr)E! zvVaL~krgn%LYD0&PBvl0K@XAj12-U(vZ7Fl(MPT;3nETJCE3{V3G)-SC!Rq7M>NAY zRFYBO$e2)xOFjpmv2j_59RgwgMT}@x<3*lPgEP^J9?yOPMa=xTZ$ryVErqNCSL;R6 zKoKVjO7=9=zuvBa$<7&emCTt-!FkJ^$+nH`28VtKYx<-F0S*kjYtlg?DUc$HF@|aUj4wk!8Z$aU?du4jE%-&w;z#$1<})b{k7DnyN4!iS`18?L(7zjFEeFKOUATh7Ec%sNlLvejH;%N?JIc_sRHUl#?LxM1%3P+>LBn?Xq^YYr_5Wzxr*1bFR`l-~&!w8lJf0kjB z$)<+YzXoJ%GiVDL^k<$nC>v~#UYPz7degJqmUy_7Fwg7t`P5KbmQq?QR!4_m?>2BGAGB2SIJRTQV8k9? zGKNdcsE}ZzDgE)!qddYlH!f@%8s!9NWvBiHjNSYG*(7y^O1ZF-Na3e(V=7=TX)g|I z95ZnaAP~U~#2K!x;q|iyGGOTQZ!{25f%48*Rz=;9n%RftZBxhYY#=(ocX9F7C7$nF z4L}g~eW)=-B5zG8d!WHjrd4DrSh{C{0VOJ6;kO2pgn}(tvq1~2yZUYD? zaE5Y501;nZ%)q}vXtv#hn)~Kj3XEMtUk zhh!9oIb1}*;AvmJ{GI+sxe1#2sMeZyFGf?)n61n1}VUZL>eq23GrkJ>Jmv%3{(*sl@dOQEpBl)>hIw(@m?8A|F?Kajv@Qicp13>#_1^D z$o}%knXC~7#{~|~)UFD0)8S~$i<}#+jKc8XefFAsxGwWJd?0<;@c92N>0o<2g7k!C zGKq~prT-2<=uIb5&xDC1xt9iLEX;!M~~12>ZoTB~^+^4{2x^W{lAa0yoYFXEZr zm4shGCpW(lBAYSN)GrQH8f`uP{S*9b2=(d(e8npJR*jm#2}58=0c!(Z2Z>}BX6&Q5 z`;YB|J{CoU`Sxlb5%v#=7j_7{eG>GKr$3;BBFf@s{Ny-pru<>DFn^bIpR#x&)`^N= zVDIE1Ccq`=8NhQ6_vUfF3AywPaX|mYk88T zYu+XR1Eh1S1o=_coY}yDSyas9@K2#g4~X&TPcy-Q#Eg4FBmjwQJ^tpw-4pL1&^}k^ zFPq3)kpnU*5fGfEYebRAotX7<5r+lQ1=Rf-(MHg*C}c|MVfxN_-j6?nLq!gp7p_IY zF#hNeas5RBxeasybIpu1>oYNQpTfoPcfT6($ku$)YXA?R7 zIo>(RR*_#^nhxQULE_)$v?@4JRtayQnx8=FF$Ni}uB75~9r@umsfHBde+306Cq%@b zh}pW_-B=+kk-(GmqX3@{XMr-yhjTHXTBK$VE71AJotaWCk2h ze)42{HXaro*Odf@f5vIVS^-k5$TOk|#7zb)p@F69|9k@6BgUG!-r1K#K40fLV39ac zF>m^B?xV4!$hufXy+3d?K2G)I`G2|CGf)X7Mjr^v9Aj(Z?{7`c)2B#7V`P9)+)}(8}V1pA6sTRP!wC z>Payh=vG637C{g3i~lv;Lgb>`Y9U&SABXWZmk^EkrkWF&7XeDBG9G9ynsKdDi<375 z!jUM`QIE4~f(7Rhj>Q>}^^Ks}(lj#U4R4c~v749EEM8{8P`m`+wFr+ih(K5`}X-(-{bSR7-)@TTl9{Tk2sVgi1&VEGp2VG@NtVzrPj(P1(funUyLfhDRU zzC1B=M9zy462Y(?s>9-G^P@p;J&#WOb0`X#N!b2OJ$xev8PzjVnBt7cnAb zq!}?;sb{2|6_XQ_mwHYbc`4_mQIK*$8bv7=CBt?Fsb{4zAmssfUzGZwG=`)+B#mJy z54+L<_SP7Y@`yA_QZ7kjRLY~$*e2y|XfY^eNX)P-=cGI)@0t-YCHVvsDM_$hf*lf+ zRiMmlibJh(?FeJf%Ji^3wNw7=GyTxo1GsfET9x>a+?9lvPF+0WV()>O#Xtqc5 z`^BJYpXSHK;C5W|&xygm1DZb|=AfA8HIK{-VqVnz^I|+Phcu7E!(v|2{EK2<7IQ@N zXz+@dqnbY?=9riX&7<+FVvcM6B{3(&oYXuXoD}n#=8uRuCFXU_qm$EO&S?Iqm^Z|H zN%QFN%VK^*^Alp;6!V*!N9Sk7{3FdD7jsU`Tbjo}=f!+Q^C!i;EoMsdlk)I?NRStE zLCmz688H{dyd&maF<%vPNz8j<-WPLO%oQ=SVy=pr6Z17O*Tl?=SrBtw%m-p_h`A}| zmYA}buZy`Y=8l-(5>pZL4Kco$MKM(|Mob`PNzAgCyJBi$J`}SerY@!-rYWW+W>w5R zF;+|{rY&Yo%zZHr#5@$UF13U5C?hj>4$AGUlwXtbDYE~=*CjyaHF=bk%)P(L-e4K% z){$V31bZdeClHlPxAdD5?3Z9%g6GnuA4zaPf`bw~pDz7af)^xsQ36kbLlnfgli!x$ zummqj@N&BOwIJQDMX znD2`DRLu9pd|%8D#Qd(9-xKruVty#*55)YTm_HKpBQZY~^Aj=uSj;~W^HVYZRLmbM zQU95kpNaYBVty{>Ux@jaV*W(TzY_DW#rzvF|5nVu6Z5BH{!D7S(nLKI6ZH)x>KRAW zZ2rc(=Fi3a1;qwwuuUF8WxqtYn4kDX=i3+kuohU)uU3OF^x7@&(##pJ?JtLu!^5as z=WQ+YeD8tvS6A86w$_@)YpkJB(44H9Gn4vA%WL>6LGrlA*1jEXv~1wjYpD64Wf##? z%X(9D7w@0$4)Pg+ZT7Pa(urI&RBHzoS0jvu+QGwirM*0yX^G(kOR2hj-1-l^4t1$H*jT~(C7 zx*9a?2nMcl4)KpOc^g-_7fAqI0%Ri15@X9bN4H!w*6ro}VErP;>iq1QUkd}{J-8b* zy|!JiHJ3@v<)Hmq7_>dV>D8L|{d&!KE5SNw>)|)QwpgwEVHkwoVyk`Ei#tPW<9*=Q z*8=U^TT+@`@~W-Is^6|H*6OwP`U&l#%4Kt`m?0sF(-T`{;FKvXCStLt_Q&pt;2W|5Q%df!Lk0hGVZ244fMH&eI&rQ+ienkE?kDP{DHbY}RgVhG(7e+mUJp1Vaz_=c@2j(gTnU<-6mmJ*g@fyS=f2 z+n+LUFm{Q7Lk4!u88{d_*fM9}P)Mcr>?nxqM>&xxZwX@WJ*YvrAc3l<{&H9PE~k_d zlGO;B?eK&b>pY|sS`Xc=`eAUg)(nFtIA6OTcvJJ!moBMbg>4Jb3n#s+&H6eZB3xUf zthbhQq$LZ1bA5zstE(;BhI%ZndnQ=&*Xr#_?|K+$H{Bs8uLaAs5c&^MRt>1N!x(iG znYz7rgp}?YZnf4h!KUZe!X!PK2tqOuFaq9KJXeYHLuZcOV2G!aUI=YLo>VQkA zqwTH>%cmy$iU8h}F`#FwiJ5kF`7%}?&r4Ue7tovjWwu$j22o1bJ4VniarL_r$6o z&k9td0;Tg4S^rbOeXPNd@n-Nqm8k*pf#O|J;@YOt<@x>`CSb>LE(i=o&aRf zj$3E)*asDv;vZNQ_im5t6!-vOM>oZD=tO@59oAcm9&9Hbh^3m{EI?YEw#Q?Dr@g+R zxz&J>2hk^X#=62L2_Gp~Hn<0VQv``06kssu_?&l!!5!z{c0OP1aPZ4^-WXy z1bmqmkSawgSP&WpxJ|8U%_q$r2c54&uFg&rq5>2>uu<;apdIDrkrZc~FPOg!v6(1F zxoSO(a+pa}bk9dcN5^QOOT%)}Ew|?k**#FNXe5^M_k;DTRJxr=WLGIu%8aJ2&aN%& zS~bVE_)%*MxOgAVA^D!j58c!XDIu?W*CNlty z9PIp`c%qObI@1)+6hkjLJVl|xSTk^zCbm}2>kn0UfULpEcIzbm0E<}W*o=KZ2LTt z`?%DZl}zX8W2QBEuHLG`3pqdOq$y!^9uJpr>0~O%tZ$1a10LUKzA1T3Z)I5QS0$6l z5LPqN&JuNjubi`!2}E@O;EyDWC1h?jcgal%3bm%K64b16|W?52I1J_>Q`3_GR4ISCCBPwL)nV6%?SNf)Z#VV_#w` z-6SZLUcl`-u8=F0OfItxs&G6z^epspTGz)sGEnKo&6(reTe`N)tPVPn?M-d-WM0HW z-@z5L?ATLT)>U|jh4t{@MA06=A6>ayDW$-2k(QmiIdqAjkT4c&T31NxXcZ13k>Q=W zokKfwoqdm)L?S0eG)@5F4sO79aDU$qGV;BQLIX^Bt_KY)LXD}Y@*&v`_yHfT+MaJUmGxn7MOA4MRcfacRTZgGo@MvXW;BL`@d)xrYDH9%F+ECW-T4*|TQ%zaRJl4V;U%}-Gt5`1E$DP~7A z`!mH%A$u~r1wwBm_OAtT{*;pENKE*wwf><}J_Mk`%>t5jf5 zVO(3jfpN`;L47G2jCXa;VkBmRL8)4^)~;0kl4Dco1rAgx)7c)QW!#`japRAFdFgDq zR7$bhOttG-dj@6p4J5GVnQ+sH=jwiA(fH^8Jz9jgq&HSv)km2;n&?&8bcS;`=6^u&*=G6(`HKzlpN{fJ?_uR+d8vFLf!YK2SNVjMg6v zL4e#1?Y3#C11vZ$WM|96_fJ>u2Q~znB8Zw>ZLR7gK!5*!03Lk_-9>rdE{9QZZ_kE?BbLGo4b*1NM>3^4aK5yA5Ni6xd?%zvMt1=L?fsJI-3X5tC;k^M6W4 z`eFj~WjyzU1^nlvTh(~n@$a+9igvtt0a13TxsfFllmC(o^o8aR#@9N*6Bh7alYXCV z0g%A1u)mEbA>5ld@D-GAwSxb)X(S4HLaGJaK$Rfqk2GLGixuMxGdT|YF&tbNB?m-r ztG|mXjXppz=Gz*XKrefNOy5y`p*}4;$CaYkSrt-96B?;(jxz^6 zgcRW)+=6?1yV}X-$V0Prf7-26pHug#bSZwC>JAdCT|o@HUEx`3aU}32RZa8Q6NL6T zOgJiHe}b5-Gjc^2pp8n5?8L;3wf8+VcjDDARF)P03zv)DK$P}Em|g-2ci`{Al=YpLoxKaXqHum9*LiLZ?^*C}!(Zlh>@D4kBbr&W;bf<9Bz)^g7-fd>lCK!* zOBP+5y^PsK`43yQ=0riYtUS;SBRWVcYB!ZRxYc%y?!PoB9_s zoo%sW;2KRlZ?~hJ-NA&RFiTg-QtOp(ma?vFgPTGSt|%X;SbCAkyG*ViiAMCjLL@cp7=B)HW5zEy7a!D?m%N{te^T5uR#tNm(ShnO+=6DU3w&5B{Acn@2mx>1`2B8L* z$3J9X2C|%An7+#36GDzn8-(dyWNLou+Yf0I#%uo{`swa0><#4mOb7!mI@;Hfal1qg zsJp~Bl3n7=7NI2wnbFuHwKpABQfsj(tW=n_*D-|K>K$S36(rrA-iA$Hs^tf4l!_!d zaXZfEdkjf07`Xj8YWpHdM-fS~H&OaDLc1stTymW%2Y3>ROOC{DFP4Xc6;t`v#-Va3 zP%2ZZL0{1B6UW?~GIvH5H9uR8y)+5|ADYJ%QUQPykcB&A`cg*yogZc8UQyisw2cor5?K5l~Ee!GvBz2;9uI>7@76etQG) z2jgA_k$OC?T9kY)_5!jg2Gyy@`Y^bKX?%4Rf`(QMMDm~v0XO9{t&s+8>RCyf)AgQ+1Q`n@3sa6@A-jVt_PBy<{J_!S16-aG;rXBD=)7!F*4 zpKzo30x%qJUGaM0Jz_YwM;d^^Hs@9|BR3zOq5}bEf^omwS%&^ThA#!DD<>=18(gP8 z(wQha|Li*AeklGvi|5n5b(Qt=%vnqD0&-lLz6_tD5U#?}bn(36YS6-~M0G8I{5^%e z%O^%2_)WZytJqc@N46T(dO6Q*8Bf`;wQ+kJjVABdiC!LwM|2f+#oQZ&9dOF%kQFoe ztX)A}B5*_^nm!6%_)8>P5c3C#K_oHxuH|e(%x@?$ijJTs7z0zt{Sv@UX-KzB z35_s2-?kLvmvDM^=u-@g`00Sq05Al5h;*H~Gp-?6@MVy3RB!_x9(%g?Ly#ZtA3DPG zgvX&-8({iMbGyXc%S_b3lB#_cC659+S0VAYYf%n%Eed)Y$5<;lvAX-~niuFfD4R$$ zu_Vf@qO=y5>Pt<1GCQ&LI|#3IU&c02lPEa}MU7MO_&G996_0G6 zuBpG2)(#bc@3~jB@hcZ97MM-WtIOo>sdV4czy_l{8ki&!pJ?a*yo})VAb&F2#8=u+PEku~q%AuB0hPw{PtI{AQ-^ z07J*{UlBWd+eGIHQ_0N4-F+W7DR-Cb+ncz%uTZ-oob$JuhvX*sh)9ZacRztBsMeEh z?%zB#N8J=^-zLh3C=3~l2O`|rMO{O4NE2XteBTF|dvJVs<}TM6PS$o-H72Wnd~!3sOR z!ZC-@Mf?(FC~NQGE+IFq057##+<=1uTs8VegaE4s`4Er7+GjfluD^3>=Iq^ed-d#V zui-pYt-5IaTC+3RvddAy5398r-v+=f@p&khZ{(ijnD0wI!y^FcKfcbb|k} z#<@}?_>1*j=L+1{-MG#WGbSXyK#g>BhUJ6^z)>CN7F-XA!WegDsgSZ1FrYBI-&9Kh zC@(Oq1{;C5s|K+%-86MA1=JZqI<*wNS7kV=+68!05zZOUNb&%ME(qs|aFRj~EP;a_ zc;mrFE<3QceBjA6Z9Tww#m?TXNp2~%6GKrBhv}l>50Zn#h@0?WK(GO`%XzZTdFA@# zGws*5WD?eb^U`rpWhbont{zxtZ3pw(TEK|bPQc3uUj zAxaZ3l_EwIAUu$nPjqGwd&mr!tbni4FYMVM23vVieK5i<=W#vLJ}}Cy**bJXQR6tC z+JG90*!VKXtpJAl#RtJ6k7Pf73wYz|)HP@yk2&F(l^(}VgGD)y5jVaMf-~3ac7?+Y zVlDz~R$uH}$oDB<0R>97^T>dYq?Q6~=HaU((#)O1Y9$prlG809E)_>I)SY{~5%{9U zYs#R+hPJbp<9nx2I%@udQeISgAWw*zB16Qsdd0NY+dvrf4<+bLZ8VCwYQ&p6CyB72jibt!u&!Bqi7As z9B55CqIpT11G>>8zgJcN z^UK4g4{>_2i|ZtcJEMC-j}{-XlM3fu@>?uM>?ch04gaUeMR|N9vl0zdaaz0?)a`d! z%Vc8MK4AVK8x@xDI=!9pxe_gi^5m>j4fcmfuPVnuKC24B(kJi`D)A|KNMAwVPUnEw z&mko*U&p=ueUv8(lEV!{rpismUY;ncI}?BG6j^esd0dZ?zXD`M%8t`)?fGgT0^S3Xui#ubk!xfwo={C)OCAKt7Daa>%4bLk4c~NPUH29LTis{J>6DLSvsRBNge8`wf$XI zDfA}#Y=4hsYUz~Eo77oFd@G4|9EUpp1v%~lDNdcY>r26uO7mrNpJ4w3R%wN=XA=1l z6?v!9zkG~J|0uokJZ^X?m|aE&$O0St@GE_0f9B4lIxg5WsyT*))4I}OfyrW-?)~F3 zy#Hi;P4|=JWoM@iR_p${;?0q;!oM5KIB{t7Z=<qgUT$}e;wH4P9BJo$WZ*J-S zx~J@Xow*x~w?D?C<^00bfMTiyszUy;2@z zQ41$R^?oiIsa65PL^>Wd-ZP(%4#na`XWc#Ys zXUdpU)8M`^4)=AG@oiF3N(6HKEYb?5w>lex;Y)AcOtrIWx+VuP7 z5#5d@&8}X)6&3w8yn$r5^H)!o$CPYtE_}bL#Z%=WElvui%cYoRN!eU^c*A20-lr-S3so zzFOY3#nz`h&~-;UJ5mDqs*}h5B8pUJ&lPUsFX$%Tn^Hb_mrQwgx@)(#n(6HBYfK?y z+rDWivC#Cu=BB;g$NPF`Kgkd~)6XqjyTSo>q?@;G1KpRj*`+ye?)s&fa!DK1Rx7J( zsN1V`SN!E#)q4kL;{&VjxV!U2D;#}}3p@L@alC$A(mW3Lt%c>jZLgC7t?2gO_Xxi? zF7MGPxP~uOaDc{}#s}8;g1EfRx`L{^@9auHG6PW|6?Ue(r~K5Q zTT}CwX3Kee+1)IULG`a+nYy-cX&&NyZJ|7H!JVNl4|LB)mq(|k01#K-oxOhL!o_Rl zQG8SE*IUa@CprhyqsCKQU{Hp`_wBR0^X*1#wtn(md_KPVxF?rc310QB^aeiB+q6=C zw}*zir$C&rjgwtzmhSeurF}ddN!nuGBT17Q{s#xCA23wj#ud_s4gj8jc%;~f;j7>r zKlI0E$=H`+pPGCG9)DZjIicUwroJ{m;o(a~o~;k#?^*N{Q2oAs7AMiRIypgetfy*I z+b)geX~gztsND^>B&6-)5h%-&i7)G^i)bYN9Ft$Q(5;9DD#RshALzWhQ(4@U;Uxho zN8{#|wRWv;2Us!4WQfTylMyBiyP_wEZ@p4+J_~my8cWcxjV&E$J)NNM=`G)&>-%HN zZG!ybc?QAX5CrphD3dHOF!1qnL%hB z2`!yFEv~zt6TJ19Ofosmgp}HfsL|j#_euPD(mwnfB*kngdoZ&dX7q4wEO&5l{BMi7 zQob~dEB)Vg8~_o>sc7B-u84g8?l1NdKZLGYqH8w?Q*Yd8HmS|}uN2S&_~$FnDi{+)8acaXk>5FIHHDg0a1CF)t^SY+{gfw~3i z70D@5uS8DCUC15NwrA5LH-ndaflufUA=pP?i9#5OwJMVX6TDq@{l-8j|ybno3U269a3+S zS|D|X)FP>~q|TB05vd=OdW+O~Qg4&`38|lwdWY1{NG*|H+tc}6*7;HCe9m+pqH}ch zE~yLTThD4TpKtOzG{IOGNiCClkJKgd3(snJEZ^|otkrAkGOr6Wx=LP!9%047wV%_i z)|IbWURqXCyWz#Ia$~nuNa|PpPJF}P_JgFMkZkwdxU<DSKLn zI;kkH>&4#J7I#!kiZ?&|aZ-=H?YJH9^gVumE17uTjdrqj)$~Qz&_Y)cbr42B?&lIh zG>yn37FNirQ(*DEh1pG|%|GyV-scIdV9;8-9k@NO-A)?qb}v+eE~lIA_Pv4IH6>q> zK7`sUn~r8_rw;>|F4>+L%q`fGP0KlaY_-OiS~;YD|PgBZ`?j)z(I`sWw>J9-Lj zXLwdnC2CsozH8b{$0e>%0Rela4gK;#LP_GX#1)C5ETF5%O9Kt_>ZC-wj7mVwkbpEg z4e};Lvrymsi;6ui(IY5xQ%W;EDl&DtL>HhK=8c{+4Hd66!TSvO8g~=LK=JVCUq=-tu-L+tr@kaU<`H zA4Fb&<@4`*_T^hwu3wj4qFDRE1{%77vhTay0S4kGuHE&c*bdk2j|0CGD$h=9+NZf@ zyy-=rREeY;Y0`b*Vn8(Mdcg*!f6flI{i9Yo-|Or4PB^dw&r`PB>3C6O$02uQuMfIi z(0YDEp}p?8@c^@kym&!cMVsNEtL#DK$#AH=U(F60T(kS&vZsUGRJdt2mKj4C-8X&P+Ut0X5N^O4$>z%jaKvuVF`Ac6h4of@ z_W&0$^qD8a(qyJ)Xt3RG6lVQF*Eh!DHosJ+@Pf(jK5Iu-(zhWkAYR!3H-r0NaUSa=SZVuYe%qU&iSLyRXChzVg(llQmR~ z0nJeqc6^t4ZdTDCAFs+vtDKa(;R8=6#cN)i6mKD_Tos=qDSZghX;qS9ryC_j%p|Fp z_DRL?F{!3JbSj42J-V1oWRm`Yw{s3U7qOA2Lrtq`P3FRIE{{&RHk~5Z$B(tQjKJ^F zO+-(K*5SI~zd(Nh9WG`$P}q{>l(HNsWJPj}HX2livO+D(1fSRvYg!xpC%VnXhCs2C z#WI>(S6Cw!VPuZ25`;!)>Wsy9?MP(E4g>E@9G<}=)9ul+z}WG^xpCMN1z^F;F@XDD z;DG`er30;k2qD8aW|P+~gMpNms{M(31=H$=`o<`J>w> zTca86!equ}hU17mNZ{8miRRf1$l?NsOK{&J{W=O^2P#a3ugt=;8PcZ_ts;moN7nGo z=gevGLO1L{<(C(YV2#$B*O`ON;{Xx{l`)kIsUbD9De46j%4CwVAVbU2Y=CkTYT?yE z_-+$268?0?OrSTzn2Wn+Qy9-?|1{n}qr16jEW8k3QUzNm$>94+(JSNs-oN8)gvgS0i?2Y%?T45is|w*h8=cvR}cDWqDIp3XOFAdX%BL&KTQ=oelx@G3VJj84Zi{}#SKIUHTzz^+@ z=lMUUr2_&7eoqp9?bP;A@yEK-U~>zy@15zY9X98WiK@CWsKRn9LV(B zjJAe`2BXR|#iJ!))UjtT*8eFSv948DI>InalJ#t)YoFo;;`*>a+aHK>0%oq>eUFd9 z;GR5$yHO&2ku4b(AD#>ECrYgIvk+E zC@uX@pj6;MzYQknpCAJJf69-7Q3z2DW|P4I2GBo4h!ALa=|$Q9ED-(1I?LXC)W?QI z=8-f$;jAYv~rT;B`I{Uy&YMY*#t%I6g6u!iD!{jUJsOb+N$z2en3 zU{{=}A3XrDd-?e@98SZQEdm3&-l93>>xdyiKUD)P|yL)|kw zsMBZgYsqe$PZOTGnHp_&_&_ngsrpWvsXt1`<_*=T$ISga*&9TDXHC0)Fl?LMmallf zb%XPNF8TIl*@fBHRc`+^EHe>H-&ji{)56j^+5z$Lo7w9=&UPO2>;m3u6~}1BEqL*7 zAw(w;0dm=QIw~hza@wV&8~A>y2h}PJ%Y%6R%sWX{d;PB4;iElVIq=ynKx+w|$T{2C z(=y0)5e0vXAUXwK>dMn3Hpi;5$Cn;JjP`wF-%Q5%cGvrFvSHz{JXMvSGwf)4$!ubs zVHAr(L~NakwEd~EPB+<9!EJ`H!w`X8(KX&W0K{Q#Sfxs0v;SODFey+kECyTnq8#|b z&fu<2GR4SD!pO+sAv{=PWQr@p#wjhQ(KxV57ok;68h(_W=ZJaNY^C-49Q`pz4Bbh^ z3uJ+Ere7f3shR>M0KVV!GgAK!LGFIo^iSe!xMEEsKK5Oh`Mp5&-|@s)gt4yYURa#= zdZX2`H=2|f4xCcYa|1b)*z1bscgcq`{K`qsZ+S)yp3%fi0Xt>vmxr%>%gjdV%c{%a z3PQAk2-pd??-Aq-+fWD&098(sAzkno0nX4(K4)IyG?&V8GD>g7a@36W>?cCzP*MEB zBM=~*IPr~!%cr8g9I3X-`UeyeP}+hGo3g`U6BxV-1&I<|Nsup>Xq6 zE@vn<+$@c`X*?*tIeH35p)Tfx_#aUo@eYuzW}yPDYZj)gdVzU+XdiD!tvaF>rq<)B5eh|iU^+(4w1QeE(1tIMg*I!d$at4T&|9^xvm?Pts*16vtva{N;qqZa6=>z zydCovOr$~l>68&{e9h?`+ZH1Yvgzl7e?^E6AY$;Hg#f&Yr9Ws5r}L=7gN&$W%K7YD zs9!^Z4Hc;WaC}(?>VXSa@4h1pfW{SQo^9DFR*b86UlO|n%49pn12z^O<#kx`Z1%tR2qWPX7sp57f?6lkZRn8JD6zSy^)W^9-4Ob?8PsclQ$(T&w=qQSCWxg3I zar*UHuDZ<8HI7*8G+#gHUvR{?#D@6KI5j3pQnKv4F`h+;77;b;6%ZzWg=V#BO%;lT z$Zdu$lMcu9s%G-8H9-XwXYDaF*{>jJxTS zTz;F7Vw06tQtpoI?y_Dwy*R0+p8%l1i#o{!$J+1n{bJf`L-&)~h7JdOIho~ofm6l@ z92&;8)7NoQ?}YerCb5Gs@OZ!^|+t33^Ul2+cA&q0{7Z zyD;)J=M9fM)sc1%Kh5tR9?n<3&4(gzx}8J$y0XRf)$Xd7nYH_>3I(WoJm0ISI`Zh9 z`F!U&n$fN)?XKUnZ+?9Js?(H;e!txxpzP@WK5yZ!dk{Hu`#Qw6Dhh_P`g7d)6P}dJp!^z7h_CSnoIow8`kC@1+PkTR^UtspMpVe&C^av`O%hx zPnV*O$>~5$M?Z$`O9=7>g$EE2f5m)Xgb#H*e1cZy7!ESNfa8RG1jh;a4vrJ@DI6yn z@^KCl@-ZAx3i}<+_&NLs`H*Ev^mXZTzD>K3epa-|pA;cu`Ezq^yEcgV`Yf`u$~iRo z9CzxPVJ=E9`Ih|qa<#A?E{*D=uj|2jxNiW?T>f14fTkJuGj5h2Mk=kentK3B^R1M8 z#3SE?7=#k68pD(T5T8Fh63V2(p4^;;)t;? zDYNa5%yIbZoGTRF;FO^07+jf{r%{>IE|`zbmg}e?f;A26dD1$8+p80$`F|-INPR|p QqHwsdw}#2fO7JZH7m7(;rT_o{ literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/flac.cpython-35.pyc b/resources/lib/mutagen/__pycache__/flac.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..703d588251c17e036a3bfde0b1b41be9202d3afd GIT binary patch literal 28214 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 W0eH@;<2yakLuvKgwZ__Dt@A&tI-YX? literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/mp3.cpython-35.pyc b/resources/lib/mutagen/__pycache__/mp3.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a0be64316ee96d34ca96f91a8414a6ead76495b GIT binary patch literal 9514 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 literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/ogg.cpython-35.pyc b/resources/lib/mutagen/__pycache__/ogg.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0eb57b0c6c9310509e2fb8e83ed55f48342796ce GIT binary patch literal 16965 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 Q(WBPs^jvP{f%@_P0W}0OjQ{`u literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/oggflac.cpython-35.pyc b/resources/lib/mutagen/__pycache__/oggflac.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab7dadf0f0e45087906650fa6558056a002dc4ab GIT binary patch literal 4862 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 F>%R{d_Jsfd literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/oggopus.cpython-35.pyc b/resources/lib/mutagen/__pycache__/oggopus.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa6e46fa18b4658ac155f038b62d93a06acf2aee GIT binary patch literal 4763 zcmb7HTaO$^6+S(ep1Wsnp51jEXcU|<#2z~#LBSYs*2aznT3g;Fh}Mc$?{v*<@AhR} zRbyvIyAom(c!x)R0TM#WGXjYR{zSj>&tPZc|jGxI}J= zEN)jQDpPEeYv=7MMHPyxNJg1FBE#vOXcw9o{poNf%FIZh89wW zyC@ECq{CqfO>f+Kubx$)H>;hM9*;zl*@1|J7B3kIn_VlbZy9wSC4&^L%EqA<>d|9V zt3#nZ6;1@|jVFE(gvoIFRrAw(xe4xvecdgZC7$?0ku|W^1MGbJa3r!aR7qu@7n=Qm zv>bB_s0bJvAVUdwSulA)HX+``gjLT={8)HiR`yepMzZpsf>Lcrb$nhux_8V;9m`o zjprU7^>-AS=*XfIi;fHQ+1qqfpnbT}qV;|FvA`YbgL5A}Ytzs9t0El{c_>jYk@}Rh z*#HLo+9GY!K9nwag~m+ zXNwMsB>!rbFp^Hb4B)oat!ZJR+%c~R%c(j3#kl;p7P^SBs}SB;WpB-9*wm(^uxq$OqcRv zf0UJDe?OIHkjx5m+C^QQ-t7z4V9~ElFI%JcTGurlM!{+cR zVkAEXUf)Fp@B*HIFt`yS!^3qG0`cU$)`Tzg;9qLG?=9rzX4|%^1=cB9Ce#} zx4PA=JvHwv-b~)*<2nz(j)-?f5d4n-+n;GuhB92ghev&h;*`OP^x5~Oj064v?!X%A zBjyq&Gv+kIx8RPZe0Xl0AvI)5purM-_6z1Akk>l!IeNjY!|k%+L=48R55RbTIyXbU zAb-c}VCe$MZ63F8vjP}qJ=+FzmS76|0%L6hi7y#kFaLv|yn)&j`3CNC6#~hp(yby} z!70}wD+279%?U{objzoR&q{{;8TVP48AF|c0VBX&_B>7org@KNzS%^FT0udDCQibt zwNR{CU7YuhwOE+Eb-{5s_pi(__YX%GK2hu=^*0px31JGz&wr7$#SY1>!sg;Mm54(F ztgjIlkomzA2qS31_{6-yb~Lt!V@@o~(k5EqJD$HvYj| z$nTn&59D<;|BZ*bt?aECh0l=v z8vEq&qOMaI+=L9thRVtaDafqeMLj>g%)nXMq-t&z-~C`(ze0sgYp4+hx8Q2D*%>C>v)duTc|agAzQz%ox*MEVfD>Ax%j8~1HCmZ114Ql{1hJyfyUrNhZf*p~t^5ET1{U%|)UvX&ao7Ufp!~-crn+d zmN#Cd8HA@W*%y7CV0v{G1z9biYpDQiA(>byT(PcLZTy!COV(ud6`bXx!Gjl)@NZ+L z+;ogy`7sysK!+H6hXqV@=1~0v6_XiqsB11PEf{GZ()o{x>MkDj6veAy4%bkGGl7e7 zg#@m{;0MqZVg*umFb#SerfiaEoo4NT8ZN@<1%XKR{bcdu{LW}%9_MtuNzESzVR|zi z4kw*?_4^2jf#|CrqzI0i^JB(Z@^cT5VwZ6eH9KZ}a0+x_)5=!2xT*L!!k$~_Qev2f zLkQo;cy+#anG`9r^Yr-Ch{k$grhY2L?|C_Mc_>U;Qy=wAPq+PA&u|t2{^HcdnA$CS z9NtV8?Ao5WT4lBuhcTeXq?MI85zCrLpmo@f1@3);ypN7E3Y{_*oAL`hibW=Uv26UC zCXMNvLxv81&l5E!Zf(L4oPx-Ff_|_EGPcRuQxT`ng~J*+e5f4XF|OIf^_3@)Y;ss` zgX?pYg;yl|fv~eF;ux9_O#ZC$Tw>!5x%Xaa_#`K{0GL;IZtNl{MnP;hX#H=Nx)=R4ooUF~#Q zU+w6$0ag~x9xwW!h zr`V#TPHvr&2DuGNn&h_1z6Ql@N^Ej%N;>3r%GM^u=P2ot+a=@Euti~;zQojR5-oax ziJJ6jXj9nHtsN3g-MWOPGd;n3L`3322ES(1n}SN&G&@m{2y zBn!u}aP}}Z7HT?Z5OMiX`lFEuohXGStztLIRW5~}^qt3hqBV%5%BP={?am+@V^ZiH zD-mK2y^bW4!tr;ralWt+KX>->d~|Db^YHMnuXu6&Ob$0$_364I)!Ox;%E?kE-xChT z1<@c1{5(R-x9LdybnM61H`6Q>AN2R~ByO=Sg4mbykCo#Oav`0OkSa_4SX*YC&Qu>p z`JVGY`02(kd>O2_rfZEO1=}4(qdmRYQH0MEKZ-Z?1~x+RZ1eio*3FFvYe!r5emcldvwwBBbkxUDkoU}@j*lPt zLs7I*xCi$V@MH2xH?MHqXM(L0h20X{?0XoV^SwcYo zI0y|$M2Z3URAdv)+nBQ9d8wZW&nsGNwN|x{{8OVTTdQ6&-b$Q zz4xDd7VSP!K;Fj44-T+y^&}bR_)ni?!(r*{(Q`f38&IGs5VoP~|Ib>Bm6=U{4!jHb z37Z3JmmOZ!ITl?O^F@}W^V|jkq3`3J&CT^QbMu;R^MKDFUb7SU1JvSzK_F{dS{#RA zRfl4onFi#-VcDP%)rKxNDQv>Vt-_wIo4qr61GTeZ6rmZoVvzS`74rwY8h%W?7T&vf z)!!jMeZy>W3`0SlnRo zAw;hu>-a0`j7gEJqNP7n9O^|)lbJ?^Z0S+sD9ybX5vTlQ6bn!KxhNU|m@*ZyD$K+` zDy-?4I+#)STS~~tj}bmqCE`I*XDZEh_lqiTVgZPZK@PDfjdIU>A2oFWf(+YOGnR~| zVdA}p-?}lmxQLei?3*)?U|48KTt@+r00w{xI1MO(xq*$+gN?(2IQ-^G)a~8lwz>3?RZpapvce1GcbYkq+}cgxWP!C7WEn&EhoF^bT7d zKF>XbzPf{MRfQnqqA|I2c2DzB0n-yd{Li4*yK18VRPoY@R>4x+T0t2!>(1N#>jOuL zAWK8lGh`2=@uyez+Ip0n%($~GGFdO9X_oyRk?e{v{FdY%%+|o+!XD$g`WwWM=mabW zvZ>P14jorX`3vMyqhoLg{G>_fpu+TfY%&JX4#XMUZPMts%sX7xtzfBxDr;nBV(fy1 zG$(u9TV~EP4Ax_@YKBIOE;FRtC8TTH_g*oGG@~F8rJo=j z_G}IF65AFZaMBP_opFdGzD?j-uHmbqm1o|JC~9hqJTEs`8~eMc=`8CuFhVC;1kW|@ zF*iLA2N9(JrBjHoR?ah160i&l;cpwuM#p&5STU{| zleO1IOyqBBnphu zzFF)z`kwYGOkbq~ljPCa&s_c45Fn}1(Qnw#$a-t=_2@5@!?&onbb5l@;9~e*{o{;= za#9}0tHT@s(o2n(`V<+))0cn>b_ZNwn2rFzfx!ZR=w0$mKngTqn#(8nQX>Re8AGy0v*f z`{y(VBPeV>vKu%A?DX1`>tE<2IVjO#MBsRxXZmb=CPbYQdz*}+@(6GyDat;4Tv#A! zoDc8GP4+m0owi^77mvJ!Vz0|dNF0=aXHd&4EST0yTynCITP)sV!7N+U^o>ElgE9$%7Wk|uxx8W2~eFs9Lcxws)rB)TU|Jt} z%i^DSI!^vnnVg&Xp|87o-EXu$r%|vnr*Lgmx8^;@nM0rFT+7oJgTnHXC;?6wL`9X) zB!!J5U>*gDz`Z4uKSRgDicaa74fi{|ikox>2SJ0PbtVsV;Gu*6dDYrle40ZtYw}=}k-4Fv{&tYkluoCU+wx8Gdei)|w56B|Y$oTi?H2zNeP<>dZ!Y<2;J> zvAE=j(+=K(=8E#<%x);tq}~P_L+@o_6LX^$jgQJ(Gvu^a>-n+ N;`i3-hpQ`=`9J>^?#=)J literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/oggtheora.cpython-35.pyc b/resources/lib/mutagen/__pycache__/oggtheora.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7a4e55736d3e214718eae3c2d039db2a76f609c GIT binary patch literal 4781 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 wI(-V(g+9v48J0S&ns>}i_NVMIFqnm;e9( literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/__pycache__/optimfrog.cpython-35.pyc b/resources/lib/mutagen/__pycache__/optimfrog.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0bcbbb85864a5bceedba00e8f3b89b42776baef1 GIT binary patch literal 2545 zcmZuyTW=dh6h6DQ*Y?J)LurFbp$vs0T&1o^sVbGKN?VaOQk7JV5Rw&XwegPaP4{AE z#tpHO7gGKJZ~TP*3H}6c`^p2#8_zuPo$)1U#Y%H_&UMb5+pIL3{%?E#{P1Uu=r20= zm9byP(r*DGqAs9AkxNmD1|{lZU#6%+iA!A&J&LN7lqfAzw@eQ6YLrwc^{DGj@j4|{ zN^8`uQCg>Nol>8=J~>Y#pQ0stg9DaG*69S+m*{EKpr~o7O_H9aHbA|=Zm_IFvSz7Z zH>nGHg?8Gl@OOu3dt~Btb$C+G(hL7y$N`wi{$VOws2lHt< zPU9q0)4T8~-OU&NIY(E;-WY;bb{G$a$)V6A*^9#j-b8R#%!OxuMWG2f5*Z|4OU0Mf z;@i$$5oVEK3ld%^+-5u)<;uti!W-}`%|VPaIGI{^SA~{R42>DxzkU1Q;Gi=Prj2DM zSA*MrQC|eSC~uEsR(O$2q>-nnGTJ&t?x^h)HJL@4lZCy%W0SXugW9E{ZnL+65Ed1w zRIXGBDm~Voe{5qNOD_XRR4z&t5Jl>sK-}We!i8rtjHLRW&1}JTEeNtOl|fMWL6GLr zIN^OG2!0xe$&~Y!)CE|z;NP_;_jg|$nqi*Zxx4#3-rLo&k+()+?^QUEdN&=T#$>je zb20W&J)O~^@}Yf=opS*ij>X^cp6Tl>&;fYQ`)+C0fT(|T88=yld!bw52Ha^^i{|2P z9IweA3v|{3=ktX$3Ve@@Q87wJ>ibi|P0<^MStb)LZi#H1?jiq+BA&CX>P?ZzY=D-! zCAhff7+OQiUXB#5ijcdipX8yLY3MK=A+D%!3AI^!GL2ioQlb8AVx@Z=4E{W<3f2=W z{WrjoL;H3HoH%ssl5yzBCG3&nS0&OvGP;k9TVeyaDbYT{Rbs|8Zoj&8<-|^|D^ZJH zRY~>8Adnb94*lZHHGHc9`V!4Fb}MvTqT@0hSLlc+@GPv-5sc%A20gq7J~-ulOvNVM zbEhKvIHDj^V?i7N0q_}C>C#m;RHv*?7;w`)YIdi+ymk7V?_37h-gqe{?t{%!MouxW zN!eY4(H6kua+Vu(tvJg%r)A!UGU)xp8QI`SQeo@A#jEWL6 zTlm_9%GgFMJj~E>&lKJ`;|jNh+pW#Zp;$VX*P>z*UU<{$XnRFj%cNga*_(WCzwm~( z@w!cun=lEcs=9me9W>z4)1BKjhnSd))>C|4?$I~4KzDp~Z99xrX3qz~oMrW;mR_8Y& z6fGk88MM`>0BuJJ-oNLwHyWupGv9$_lS=S$ayF5ye^ZgIwx^cZ0_RsX0g7r6M0qa= z)E7+plEImIyW_Yho_p7^G`CHo=DCfQrS*U=KECfdmi`*x{G5?^e#<0L@%FiYLtLzQ z!_hlC?Xu!n^;nrd{&NjJ!P0jCrcpZ~I>sI?Xgd=6#$^__qe~yn5Oe_$*xb@=2jy)e z(#iX3zR{Sj=FIFPA;a+hjtwsJi zmcP}mz87N~sG<^ZB8mp;AFpyMG2sZ$7hU2;Imbw9)^M&Ud`MNsA z_~8wWy+u)-JrAnNyygt+gd_QSP1)wIgJEAc+iTV_8-3+6<9`xg)E-P% U+!k)W>?}KC%GU-+qKT7)9WwN9kgqSO|V)bVa6BVG$^ zl&qLIl|p8NJc_ej-l`eNa?ul;d)Oq+qfsISX4|30n>f(yNfG6PQ7SW2nP8fG#tc`l zUOhNC=ouKjV$JOh^7QKF=B?mSWk_Is32x^&Wh+<-?pZH_J5mo+JY;uHIk6MNdNI;c zJiK$ga{mD>fg%`<08d-GihWcl8CGi}XQk_KvV-Msvf)@F7Cj zb&D2uzK3Hr4~J6KSi>zEgD5m%(SAHaTxE7wsay^C?a9MiuVLtU5Q$<$>4Bmc9i$E$ zNPcC#TPtRQAPZ9&1Vu9l%2;CF4ubDSVNy!|AjR8Q^3S!~t6NVG&0d~e|8nb5yuGDk zBUgsu;5jVOTWM+07KgZu@bFNz@%DRcjSJFt?D{{MyEqNn0~tnAS%+c0>abmBn9MPm znpw`yGVdJ72N;vXd41||HfoI(dLVDa3K%UB3P#sYZ%yhi#C zFZU4)%WSKsWNb8`@uNegLHizSlo~j=ZsDJ>T9hynJUzODL44 z^!;)deWLcb>=;*Z7MmdW3@gnRlC$8 zibI9OfjYF~(h{J&J{HF%cST(4UE7(I3|5hNFz$6-^&#F@7eKm>5%SmAFq z{ytRpidq$`gCx{?_1P(3pIHEvYJ77Yt8tsZQ8l?<99{2$akEq(w`{|^`ooGb;S_xA z5J%Le@pDW7JLIs0T{U;v`17XBFBT93XK2=ST0FneZLC`m;G_u{Y>s+3=7^gY`XF~# z#c?efNq!&|H+FWI8@T$2g HPK(WYLl1H#tz|apF3NIl8i<~iu6JkO$hZ2Gx8lqd zjesko@PZV#v@Y7fn~4Xh#PtxV%coB~E(A`_*BBa~P}FXCuIJ3U1I(und$oq4uYkN= z1!YUFzM4F8q{qd8XdzaLJsJP=p&30PYC0@v))~_ z*q5AcOE3{6=aeszN4$QP~2c`#Ycf@41~iq3=`j7*%^o~c@_-~MKl@#mPm zeeES)_z$0yD{j@cCM@(6C(vf%U6vO!H_8u-z0yA{8{qn z$e$;Ff&4}Cm&m_C{xbPD$zP%4S=z7Dd;Ti-&N1Z_Q(;#)WcORc`I?6jv+s=nDWZU#ZU^y!XQYKQG`LuG!_mtP+MdL@Hv4uCCg9rcs+&ClvAmAz^TPVK!J`Nbj z$82o}pQyTS>~#az&1~20CVn5MaI=Q%e%|+@eB}2eo3N?Hw{Jaqc(D6Koh0$w-#_?y zczBS8D!9-7@s5M^pxcKVgZNk=?2t_ zZa}T-2Gp8vAhrk_5Ie0}W*<8x5Lj-Iw69bww#{;#A z0Zpaj0efNq1S>90)ky(FI6Ux$Bja$`2YLV~o1WI`c@2PC7(Uo`;$YxRUxfo=l3vKn zH-&f22_pwrl9nI_=e@JxN}MRi8_#o2GZX}UUvm0uH30D_%_>aA$>C{c<=edu>sh4;oc{;!jU$*q3@|-G@81ngPzNWFJKGrbxa5j%nO6Bz=sGG9WC6}(XrR6Wc30VoQH=0 zC=@5vxoJ9|*$$p6?znY5XWkJXNg7@6eT=3a~7`pu-!42&o(a?5SwdV$j>o&f4x$(d9#&`4GYBD{o(+^(c8p+?&ot`}zBbndn{kFJn^*9VMShI|KW<&Pj* zB0t9!JLt@8^?DKvnP7(I20~UIjw?S7=5{{ezv(BdxUQdcTvzju{4sy%%J>!ad=CR3 zuz3rjQL~K3n&iF?-phy2x`h`xd%6rmZO?=w=vdcX_)p@Jh=45o=K=ihwaStmHk{P~ zy>W4WkQd+`Hp>tHWqx~vH{}|JK{~D+U~p-#W$s+&FbzTY>mDX@!OPg>!gc}!R|nNz zETuokL>cpVHYJ?11U^}or27_FymT}@>vz$ z+@nY#HDjkWFHr|0-?^?AMJUKCMPV(=+}$j+w$}^JHzi+%tgv9uT#dSY6-GL7<|*`t zJZd2`1|5}$8*f`XTk=7#+g?_EC;zYDU9R&|E(|SW)|f*m%$dvPZE?d~etr3k<=TG$ DmOdQc literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/_compat.py b/resources/lib/mutagen/_compat.py new file mode 100644 index 00000000..77c465f1 --- /dev/null +++ b/resources/lib/mutagen/_compat.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import sys + + +PY2 = sys.version_info[0] == 2 +PY3 = not PY2 + +if PY2: + from StringIO import StringIO + BytesIO = StringIO + from cStringIO import StringIO as cBytesIO + from itertools import izip + + long_ = long + integer_types = (int, long) + string_types = (str, unicode) + text_type = unicode + + xrange = xrange + cmp = cmp + chr_ = chr + + def endswith(text, end): + return text.endswith(end) + + iteritems = lambda d: d.iteritems() + itervalues = lambda d: d.itervalues() + iterkeys = lambda d: d.iterkeys() + + iterbytes = lambda b: iter(b) + + exec("def reraise(tp, value, tb):\n raise tp, value, tb") + + def swap_to_string(cls): + if "__str__" in cls.__dict__: + cls.__unicode__ = cls.__str__ + + if "__bytes__" in cls.__dict__: + cls.__str__ = cls.__bytes__ + + return cls + +elif PY3: + from io import StringIO + StringIO = StringIO + from io import BytesIO + cBytesIO = BytesIO + + long_ = int + integer_types = (int,) + string_types = (str,) + text_type = str + + izip = zip + xrange = range + cmp = lambda a, b: (a > b) - (a < b) + chr_ = lambda x: bytes([x]) + + def endswith(text, end): + # usefull for paths which can be both, str and bytes + if isinstance(text, str): + if not isinstance(end, str): + end = end.decode("ascii") + else: + if not isinstance(end, bytes): + end = end.encode("ascii") + return text.endswith(end) + + iteritems = lambda d: iter(d.items()) + itervalues = lambda d: iter(d.values()) + iterkeys = lambda d: iter(d.keys()) + + iterbytes = lambda b: (bytes([v]) for v in b) + + def reraise(tp, value, tb): + raise tp(value).with_traceback(tb) + + def swap_to_string(cls): + return cls diff --git a/resources/lib/mutagen/_constants.py b/resources/lib/mutagen/_constants.py new file mode 100644 index 00000000..62c1ce02 --- /dev/null +++ b/resources/lib/mutagen/_constants.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- + +"""Constants used by Mutagen.""" + +GENRES = [ + u"Blues", + u"Classic Rock", + u"Country", + u"Dance", + u"Disco", + u"Funk", + u"Grunge", + u"Hip-Hop", + u"Jazz", + u"Metal", + u"New Age", + u"Oldies", + u"Other", + u"Pop", + u"R&B", + u"Rap", + u"Reggae", + u"Rock", + u"Techno", + u"Industrial", + u"Alternative", + u"Ska", + u"Death Metal", + u"Pranks", + u"Soundtrack", + u"Euro-Techno", + u"Ambient", + u"Trip-Hop", + u"Vocal", + u"Jazz+Funk", + u"Fusion", + u"Trance", + u"Classical", + u"Instrumental", + u"Acid", + u"House", + u"Game", + u"Sound Clip", + u"Gospel", + u"Noise", + u"Alt. Rock", + u"Bass", + u"Soul", + u"Punk", + u"Space", + u"Meditative", + u"Instrumental Pop", + u"Instrumental Rock", + u"Ethnic", + u"Gothic", + u"Darkwave", + u"Techno-Industrial", + u"Electronic", + u"Pop-Folk", + u"Eurodance", + u"Dream", + u"Southern Rock", + u"Comedy", + u"Cult", + u"Gangsta Rap", + u"Top 40", + u"Christian Rap", + u"Pop/Funk", + u"Jungle", + u"Native American", + u"Cabaret", + u"New Wave", + u"Psychedelic", + u"Rave", + u"Showtunes", + u"Trailer", + u"Lo-Fi", + u"Tribal", + u"Acid Punk", + u"Acid Jazz", + u"Polka", + u"Retro", + u"Musical", + u"Rock & Roll", + u"Hard Rock", + u"Folk", + u"Folk-Rock", + u"National Folk", + u"Swing", + u"Fast-Fusion", + u"Bebop", + u"Latin", + u"Revival", + u"Celtic", + u"Bluegrass", + u"Avantgarde", + u"Gothic Rock", + u"Progressive Rock", + u"Psychedelic Rock", + u"Symphonic Rock", + u"Slow Rock", + u"Big Band", + u"Chorus", + u"Easy Listening", + u"Acoustic", + u"Humour", + u"Speech", + u"Chanson", + u"Opera", + u"Chamber Music", + u"Sonata", + u"Symphony", + u"Booty Bass", + u"Primus", + u"Porn Groove", + u"Satire", + u"Slow Jam", + u"Club", + u"Tango", + u"Samba", + u"Folklore", + u"Ballad", + u"Power Ballad", + u"Rhythmic Soul", + u"Freestyle", + u"Duet", + u"Punk Rock", + u"Drum Solo", + u"A Cappella", + u"Euro-House", + u"Dance Hall", + u"Goa", + u"Drum & Bass", + u"Club-House", + u"Hardcore", + u"Terror", + u"Indie", + u"BritPop", + u"Afro-Punk", + u"Polsk Punk", + u"Beat", + u"Christian Gangsta Rap", + u"Heavy Metal", + u"Black Metal", + u"Crossover", + u"Contemporary Christian", + u"Christian Rock", + u"Merengue", + u"Salsa", + u"Thrash Metal", + u"Anime", + u"JPop", + u"Synthpop", + u"Abstract", + u"Art Rock", + u"Baroque", + u"Bhangra", + u"Big Beat", + u"Breakbeat", + u"Chillout", + u"Downtempo", + u"Dub", + u"EBM", + u"Eclectic", + u"Electro", + u"Electroclash", + u"Emo", + u"Experimental", + u"Garage", + u"Global", + u"IDM", + u"Illbient", + u"Industro-Goth", + u"Jam Band", + u"Krautrock", + u"Leftfield", + u"Lounge", + u"Math Rock", + u"New Romantic", + u"Nu-Breakz", + u"Post-Punk", + u"Post-Rock", + u"Psytrance", + u"Shoegaze", + u"Space Rock", + u"Trop Rock", + u"World Music", + u"Neoclassical", + u"Audiobook", + u"Audio Theatre", + u"Neue Deutsche Welle", + u"Podcast", + u"Indie Rock", + u"G-Funk", + u"Dubstep", + u"Garage Rock", + u"Psybient", +] +"""The ID3v1 genre list.""" diff --git a/resources/lib/mutagen/_file.py b/resources/lib/mutagen/_file.py new file mode 100644 index 00000000..5daa2521 --- /dev/null +++ b/resources/lib/mutagen/_file.py @@ -0,0 +1,253 @@ +# Copyright (C) 2005 Michael Urman +# -*- coding: utf-8 -*- +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import warnings + +from mutagen._util import DictMixin +from mutagen._compat import izip + + +class FileType(DictMixin): + """An abstract object wrapping tags and audio stream information. + + Attributes: + + * info -- stream information (length, bitrate, sample rate) + * tags -- metadata tags, if any + + Each file format has different potential tags and stream + information. + + FileTypes implement an interface very similar to Metadata; the + dict interface, save, load, and delete calls on a FileType call + the appropriate methods on its tag data. + """ + + __module__ = "mutagen" + + info = None + tags = None + filename = None + _mimes = ["application/octet-stream"] + + def __init__(self, filename=None, *args, **kwargs): + if filename is None: + warnings.warn("FileType constructor requires a filename", + DeprecationWarning) + else: + self.load(filename, *args, **kwargs) + + def load(self, filename, *args, **kwargs): + raise NotImplementedError + + def __getitem__(self, key): + """Look up a metadata tag key. + + If the file has no tags at all, a KeyError is raised. + """ + + if self.tags is None: + raise KeyError(key) + else: + return self.tags[key] + + def __setitem__(self, key, value): + """Set a metadata tag. + + If the file has no tags, an appropriate format is added (but + not written until save is called). + """ + + if self.tags is None: + self.add_tags() + self.tags[key] = value + + def __delitem__(self, key): + """Delete a metadata tag key. + + If the file has no tags at all, a KeyError is raised. + """ + + if self.tags is None: + raise KeyError(key) + else: + del(self.tags[key]) + + def keys(self): + """Return a list of keys in the metadata tag. + + If the file has no tags at all, an empty list is returned. + """ + + if self.tags is None: + return [] + else: + return self.tags.keys() + + def delete(self, filename=None): + """Remove tags from a file. + + In cases where the tagging format is independent of the file type + (for example `mutagen.ID3`) all traces of the tagging format will + be removed. + In cases where the tag is part of the file type, all tags and + padding will be removed. + + The tags attribute will be cleared as well if there is one. + + Does nothing if the file has no tags. + + :raises mutagen.MutagenError: if deleting wasn't possible + """ + + if self.tags is not None: + if filename is None: + filename = self.filename + else: + warnings.warn( + "delete(filename=...) is deprecated, reload the file", + DeprecationWarning) + return self.tags.delete(filename) + + def save(self, filename=None, **kwargs): + """Save metadata tags. + + :raises mutagen.MutagenError: if saving wasn't possible + """ + + if filename is None: + filename = self.filename + else: + warnings.warn( + "save(filename=...) is deprecated, reload the file", + DeprecationWarning) + + if self.tags is not None: + return self.tags.save(filename, **kwargs) + + def pprint(self): + """Print stream information and comment key=value pairs.""" + + stream = "%s (%s)" % (self.info.pprint(), self.mime[0]) + try: + tags = self.tags.pprint() + except AttributeError: + return stream + else: + return stream + ((tags and "\n" + tags) or "") + + def add_tags(self): + """Adds new tags to the file. + + :raises mutagen.MutagenError: if tags already exist or adding is not + possible. + """ + + raise NotImplementedError + + @property + def mime(self): + """A list of mime types""" + + mimes = [] + for Kind in type(self).__mro__: + for mime in getattr(Kind, '_mimes', []): + if mime not in mimes: + mimes.append(mime) + return mimes + + @staticmethod + def score(filename, fileobj, header): + raise NotImplementedError + + +class StreamInfo(object): + """Abstract stream information object. + + Provides attributes for length, bitrate, sample rate etc. + + See the implementations for details. + """ + + __module__ = "mutagen" + + def pprint(self): + """Print stream information""" + + raise NotImplementedError + + +def File(filename, options=None, easy=False): + """Guess the type of the file and try to open it. + + The file type is decided by several things, such as the first 128 + bytes (which usually contains a file type identifier), the + filename extension, and the presence of existing tags. + + If no appropriate type could be found, None is returned. + + :param options: Sequence of :class:`FileType` implementations, defaults to + all included ones. + + :param easy: If the easy wrappers should be returnd if available. + For example :class:`EasyMP3 ` instead + of :class:`MP3 `. + """ + + if options is None: + from mutagen.asf import ASF + from mutagen.apev2 import APEv2File + from mutagen.flac import FLAC + if easy: + from mutagen.easyid3 import EasyID3FileType as ID3FileType + else: + from mutagen.id3 import ID3FileType + if easy: + from mutagen.mp3 import EasyMP3 as MP3 + else: + from mutagen.mp3 import MP3 + from mutagen.oggflac import OggFLAC + from mutagen.oggspeex import OggSpeex + from mutagen.oggtheora import OggTheora + from mutagen.oggvorbis import OggVorbis + from mutagen.oggopus import OggOpus + if easy: + from mutagen.trueaudio import EasyTrueAudio as TrueAudio + else: + from mutagen.trueaudio import TrueAudio + from mutagen.wavpack import WavPack + if easy: + from mutagen.easymp4 import EasyMP4 as MP4 + else: + from mutagen.mp4 import MP4 + from mutagen.musepack import Musepack + from mutagen.monkeysaudio import MonkeysAudio + from mutagen.optimfrog import OptimFROG + from mutagen.aiff import AIFF + from mutagen.aac import AAC + options = [MP3, TrueAudio, OggTheora, OggSpeex, OggVorbis, OggFLAC, + FLAC, AIFF, APEv2File, MP4, ID3FileType, WavPack, + Musepack, MonkeysAudio, OptimFROG, ASF, OggOpus, AAC] + + if not options: + return None + + with open(filename, "rb") as fileobj: + header = fileobj.read(128) + # Sort by name after score. Otherwise import order affects + # Kind sort order, which affects treatment of things with + # equals scores. + results = [(Kind.score(filename, fileobj, header), Kind.__name__) + for Kind in options] + + results = list(izip(results, options)) + results.sort() + (score, name), Kind = results[-1] + if score > 0: + return Kind(filename) + else: + return None diff --git a/resources/lib/mutagen/_mp3util.py b/resources/lib/mutagen/_mp3util.py new file mode 100644 index 00000000..409cadcb --- /dev/null +++ b/resources/lib/mutagen/_mp3util.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +""" +http://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header +http://wiki.hydrogenaud.io/index.php?title=MP3 +""" + +from functools import partial + +from ._util import cdata, BitReader +from ._compat import xrange, iterbytes, cBytesIO + + +class LAMEError(Exception): + pass + + +class LAMEHeader(object): + """http://gabriel.mp3-tech.org/mp3infotag.html""" + + vbr_method = 0 + """0: unknown, 1: CBR, 2: ABR, 3/4/5: VBR, others: see the docs""" + + lowpass_filter = 0 + """lowpass filter value in Hz. 0 means unknown""" + + quality = -1 + """Encoding quality: 0..9""" + + vbr_quality = -1 + """VBR quality: 0..9""" + + track_peak = None + """Peak signal amplitude as float. None if unknown.""" + + track_gain_origin = 0 + """see the docs""" + + track_gain_adjustment = None + """Track gain adjustment as float (for 89db replay gain) or None""" + + album_gain_origin = 0 + """see the docs""" + + album_gain_adjustment = None + """Album gain adjustment as float (for 89db replay gain) or None""" + + encoding_flags = 0 + """see docs""" + + ath_type = -1 + """see docs""" + + bitrate = -1 + """Bitrate in kbps. For VBR the minimum bitrate, for anything else + (CBR, ABR, ..) the target bitrate. + """ + + encoder_delay_start = 0 + """Encoder delay in samples""" + + encoder_padding_end = 0 + """Padding in samples added at the end""" + + source_sample_frequency_enum = -1 + """see docs""" + + unwise_setting_used = False + """see docs""" + + stereo_mode = 0 + """see docs""" + + noise_shaping = 0 + """see docs""" + + mp3_gain = 0 + """Applied MP3 gain -127..127. Factor is 2 ** (mp3_gain / 4)""" + + surround_info = 0 + """see docs""" + + preset_used = 0 + """lame preset""" + + music_length = 0 + """Length in bytes excluding any ID3 tags""" + + music_crc = -1 + """CRC16 of the data specified by music_length""" + + header_crc = -1 + """CRC16 of this header and everything before (not checked)""" + + def __init__(self, xing, fileobj): + """Raises LAMEError if parsing fails""" + + payload = fileobj.read(27) + if len(payload) != 27: + raise LAMEError("Not enough data") + + # extended lame header + r = BitReader(cBytesIO(payload)) + revision = r.bits(4) + if revision != 0: + raise LAMEError("unsupported header revision %d" % revision) + + self.vbr_method = r.bits(4) + self.lowpass_filter = r.bits(8) * 100 + + # these have a different meaning for lame; expose them again here + self.quality = (100 - xing.vbr_scale) % 10 + self.vbr_quality = (100 - xing.vbr_scale) // 10 + + track_peak_data = r.bytes(4) + if track_peak_data == b"\x00\x00\x00\x00": + self.track_peak = None + else: + # see PutLameVBR() in LAME's VbrTag.c + self.track_peak = ( + cdata.uint32_be(track_peak_data) - 0.5) / 2 ** 23 + track_gain_type = r.bits(3) + self.track_gain_origin = r.bits(3) + sign = r.bits(1) + gain_adj = r.bits(9) / 10.0 + if sign: + gain_adj *= -1 + if track_gain_type == 1: + self.track_gain_adjustment = gain_adj + else: + self.track_gain_adjustment = None + assert r.is_aligned() + + album_gain_type = r.bits(3) + self.album_gain_origin = r.bits(3) + sign = r.bits(1) + album_gain_adj = r.bits(9) / 10.0 + if album_gain_type == 2: + self.album_gain_adjustment = album_gain_adj + else: + self.album_gain_adjustment = None + + self.encoding_flags = r.bits(4) + self.ath_type = r.bits(4) + + self.bitrate = r.bits(8) + + self.encoder_delay_start = r.bits(12) + self.encoder_padding_end = r.bits(12) + + self.source_sample_frequency_enum = r.bits(2) + self.unwise_setting_used = r.bits(1) + self.stereo_mode = r.bits(3) + self.noise_shaping = r.bits(2) + + sign = r.bits(1) + mp3_gain = r.bits(7) + if sign: + mp3_gain *= -1 + self.mp3_gain = mp3_gain + + r.skip(2) + self.surround_info = r.bits(3) + self.preset_used = r.bits(11) + self.music_length = r.bits(32) + self.music_crc = r.bits(16) + + self.header_crc = r.bits(16) + assert r.is_aligned() + + @classmethod + def parse_version(cls, fileobj): + """Returns a version string and True if a LAMEHeader follows. + The passed file object will be positioned right before the + lame header if True. + + Raises LAMEError if there is no lame version info. + """ + + # http://wiki.hydrogenaud.io/index.php?title=LAME_version_string + + data = fileobj.read(20) + if len(data) != 20: + raise LAMEError("Not a lame header") + if not data.startswith((b"LAME", b"L3.99")): + raise LAMEError("Not a lame header") + + data = data.lstrip(b"EMAL") + major, data = data[0:1], data[1:].lstrip(b".") + minor = b"" + for c in iterbytes(data): + if not c.isdigit(): + break + minor += c + data = data[len(minor):] + + try: + major = int(major.decode("ascii")) + minor = int(minor.decode("ascii")) + except ValueError: + raise LAMEError + + # the extended header was added sometimes in the 3.90 cycle + # e.g. "LAME3.90 (alpha)" should still stop here. + # (I have seen such a file) + if (major, minor) < (3, 90) or ( + (major, minor) == (3, 90) and data[-11:-10] == b"("): + flag = data.strip(b"\x00").rstrip().decode("ascii") + return u"%d.%d%s" % (major, minor, flag), False + + if len(data) <= 11: + raise LAMEError("Invalid version: too long") + + flag = data[:-11].rstrip(b"\x00") + + flag_string = u"" + patch = u"" + if flag == b"a": + flag_string = u" (alpha)" + elif flag == b"b": + flag_string = u" (beta)" + elif flag == b"r": + patch = u".1+" + elif flag == b" ": + if (major, minor) > (3, 96): + patch = u".0" + else: + patch = u".0+" + elif flag == b"" or flag == b".": + patch = u".0+" + else: + flag_string = u" (?)" + + # extended header, seek back to 9 bytes for the caller + fileobj.seek(-11, 1) + + return u"%d.%d%s%s" % (major, minor, patch, flag_string), True + + +class XingHeaderError(Exception): + pass + + +class XingHeaderFlags(object): + FRAMES = 0x1 + BYTES = 0x2 + TOC = 0x4 + VBR_SCALE = 0x8 + + +class XingHeader(object): + + frames = -1 + """Number of frames, -1 if unknown""" + + bytes = -1 + """Number of bytes, -1 if unknown""" + + toc = [] + """List of 100 file offsets in percent encoded as 0-255. E.g. entry + 50 contains the file offset in percent at 50% play time. + Empty if unknown. + """ + + vbr_scale = -1 + """VBR quality indicator 0-100. -1 if unknown""" + + lame_header = None + """A LAMEHeader instance or None""" + + lame_version = u"" + """The version of the LAME encoder e.g. '3.99.0'. Empty if unknown""" + + is_info = False + """If the header started with 'Info' and not 'Xing'""" + + def __init__(self, fileobj): + """Parses the Xing header or raises XingHeaderError. + + The file position after this returns is undefined. + """ + + data = fileobj.read(8) + if len(data) != 8 or data[:4] not in (b"Xing", b"Info"): + raise XingHeaderError("Not a Xing header") + + self.is_info = (data[:4] == b"Info") + + flags = cdata.uint32_be_from(data, 4)[0] + + if flags & XingHeaderFlags.FRAMES: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.frames = cdata.uint32_be(data) + + if flags & XingHeaderFlags.BYTES: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.bytes = cdata.uint32_be(data) + + if flags & XingHeaderFlags.TOC: + data = fileobj.read(100) + if len(data) != 100: + raise XingHeaderError("Xing header truncated") + self.toc = list(bytearray(data)) + + if flags & XingHeaderFlags.VBR_SCALE: + data = fileobj.read(4) + if len(data) != 4: + raise XingHeaderError("Xing header truncated") + self.vbr_scale = cdata.uint32_be(data) + + try: + self.lame_version, has_header = LAMEHeader.parse_version(fileobj) + if has_header: + self.lame_header = LAMEHeader(self, fileobj) + except LAMEError: + pass + + @classmethod + def get_offset(cls, info): + """Calculate the offset to the Xing header from the start of the + MPEG header including sync based on the MPEG header's content. + """ + + assert info.layer == 3 + + if info.version == 1: + if info.mode != 3: + return 36 + else: + return 21 + else: + if info.mode != 3: + return 21 + else: + return 13 + + +class VBRIHeaderError(Exception): + pass + + +class VBRIHeader(object): + + version = 0 + """VBRI header version""" + + quality = 0 + """Quality indicator""" + + bytes = 0 + """Number of bytes""" + + frames = 0 + """Number of frames""" + + toc_scale_factor = 0 + """Scale factor of TOC entries""" + + toc_frames = 0 + """Number of frames per table entry""" + + toc = [] + """TOC""" + + def __init__(self, fileobj): + """Reads the VBRI header or raises VBRIHeaderError. + + The file position is undefined after this returns + """ + + data = fileobj.read(26) + if len(data) != 26 or not data.startswith(b"VBRI"): + raise VBRIHeaderError("Not a VBRI header") + + offset = 4 + self.version, offset = cdata.uint16_be_from(data, offset) + if self.version != 1: + raise VBRIHeaderError( + "Unsupported header version: %r" % self.version) + + offset += 2 # float16.. can't do + self.quality, offset = cdata.uint16_be_from(data, offset) + self.bytes, offset = cdata.uint32_be_from(data, offset) + self.frames, offset = cdata.uint32_be_from(data, offset) + + toc_num_entries, offset = cdata.uint16_be_from(data, offset) + self.toc_scale_factor, offset = cdata.uint16_be_from(data, offset) + toc_entry_size, offset = cdata.uint16_be_from(data, offset) + self.toc_frames, offset = cdata.uint16_be_from(data, offset) + toc_size = toc_entry_size * toc_num_entries + toc_data = fileobj.read(toc_size) + if len(toc_data) != toc_size: + raise VBRIHeaderError("VBRI header truncated") + + self.toc = [] + if toc_entry_size == 2: + unpack = partial(cdata.uint16_be_from, toc_data) + elif toc_entry_size == 4: + unpack = partial(cdata.uint32_be_from, toc_data) + else: + raise VBRIHeaderError("Invalid TOC entry size") + + self.toc = [unpack(i)[0] for i in xrange(0, toc_size, toc_entry_size)] + + @classmethod + def get_offset(cls, info): + """Offset in bytes from the start of the MPEG header including sync""" + + assert info.layer == 3 + + return 36 diff --git a/resources/lib/mutagen/_tags.py b/resources/lib/mutagen/_tags.py new file mode 100644 index 00000000..ce250adf --- /dev/null +++ b/resources/lib/mutagen/_tags.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + + +class PaddingInfo(object): + """Abstract padding information object. + + This will be passed to the callback function that can be used + for saving tags. + + :: + + def my_callback(info: PaddingInfo): + return info.get_default_padding() + + The callback should return the amount of padding to use (>= 0) based on + the content size and the padding of the file after saving. The actual used + amount of padding might vary depending on the file format (due to + alignment etc.) + + The default implementation can be accessed using the + :meth:`get_default_padding` method in the callback. + """ + + padding = 0 + """The amount of padding left after saving in bytes (can be negative if + more data needs to be added as padding is available) + """ + + size = 0 + """The amount of data following the padding""" + + def __init__(self, padding, size): + self.padding = padding + self.size = size + + def get_default_padding(self): + """The default implementation which tries to select a reasonable + amount of padding and which might change in future versions. + + :return: Amount of padding after saving + :rtype: int + """ + + high = 1024 * 10 + self.size // 100 # 10 KiB + 1% of trailing data + low = 1024 + self.size // 1000 # 1 KiB + 0.1% of trailing data + + if self.padding >= 0: + # enough padding left + if self.padding > high: + # padding too large, reduce + return low + # just use existing padding as is + return self.padding + else: + # not enough padding, add some + return low + + def _get_padding(self, user_func): + if user_func is None: + return self.get_default_padding() + else: + return user_func(self) + + def __repr__(self): + return "<%s size=%d padding=%d>" % ( + type(self).__name__, self.size, self.padding) + + +class Metadata(object): + """An abstract dict-like object. + + Metadata is the base class for many of the tag objects in Mutagen. + """ + + __module__ = "mutagen" + + def __init__(self, *args, **kwargs): + if args or kwargs: + self.load(*args, **kwargs) + + def load(self, *args, **kwargs): + raise NotImplementedError + + def save(self, filename=None): + """Save changes to a file.""" + + raise NotImplementedError + + def delete(self, filename=None): + """Remove tags from a file. + + In most cases this means any traces of the tag will be removed + from the file. + """ + + raise NotImplementedError diff --git a/resources/lib/mutagen/_toolsutil.py b/resources/lib/mutagen/_toolsutil.py new file mode 100644 index 00000000..e9074b71 --- /dev/null +++ b/resources/lib/mutagen/_toolsutil.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +import os +import sys +import signal +import locale +import contextlib +import optparse +import ctypes + +from ._compat import text_type, PY2, PY3, iterbytes + + +def split_escape(string, sep, maxsplit=None, escape_char="\\"): + """Like unicode/str/bytes.split but allows for the separator to be escaped + + If passed unicode/str/bytes will only return list of unicode/str/bytes. + """ + + assert len(sep) == 1 + assert len(escape_char) == 1 + + if isinstance(string, bytes): + if isinstance(escape_char, text_type): + escape_char = escape_char.encode("ascii") + iter_ = iterbytes + else: + iter_ = iter + + if maxsplit is None: + maxsplit = len(string) + + empty = string[:0] + result = [] + current = empty + escaped = False + for char in iter_(string): + if escaped: + if char != escape_char and char != sep: + current += escape_char + current += char + escaped = False + else: + if char == escape_char: + escaped = True + elif char == sep and len(result) < maxsplit: + result.append(current) + current = empty + else: + current += char + result.append(current) + return result + + +class SignalHandler(object): + + def __init__(self): + self._interrupted = False + self._nosig = False + self._init = False + + def init(self): + signal.signal(signal.SIGINT, self._handler) + signal.signal(signal.SIGTERM, self._handler) + if os.name != "nt": + signal.signal(signal.SIGHUP, self._handler) + + def _handler(self, signum, frame): + self._interrupted = True + if not self._nosig: + raise SystemExit("Aborted...") + + @contextlib.contextmanager + def block(self): + """While this context manager is active any signals for aborting + the process will be queued and exit the program once the context + is left. + """ + + self._nosig = True + yield + self._nosig = False + if self._interrupted: + raise SystemExit("Aborted...") + + +def get_win32_unicode_argv(): + """Returns a unicode argv under Windows and standard sys.argv otherwise""" + + if os.name != "nt" or not PY2: + return sys.argv + + import ctypes + from ctypes import cdll, windll, wintypes + + GetCommandLineW = cdll.kernel32.GetCommandLineW + GetCommandLineW.argtypes = [] + GetCommandLineW.restype = wintypes.LPCWSTR + + CommandLineToArgvW = windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + + LocalFree = windll.kernel32.LocalFree + LocalFree.argtypes = [wintypes.HLOCAL] + LocalFree.restype = wintypes.HLOCAL + + argc = ctypes.c_int() + argv = CommandLineToArgvW(GetCommandLineW(), ctypes.byref(argc)) + if not argv: + return + + res = argv[max(0, argc.value - len(sys.argv)):argc.value] + + LocalFree(argv) + + return res + + +def fsencoding(): + """The encoding used for paths, argv, environ, stdout and stdin""" + + if os.name == "nt": + return "" + + return locale.getpreferredencoding() or "utf-8" + + +def fsnative(text=u""): + """Returns the passed text converted to the preferred path type + for each platform. + """ + + assert isinstance(text, text_type) + + if os.name == "nt" or PY3: + return text + else: + return text.encode(fsencoding(), "replace") + return text + + +def is_fsnative(arg): + """If the passed value is of the preferred path type for each platform. + Note that on Python3+linux, paths can be bytes or str but this returns + False for bytes there. + """ + + if PY3 or os.name == "nt": + return isinstance(arg, text_type) + else: + return isinstance(arg, bytes) + + +def print_(*objects, **kwargs): + """A print which supports bytes and str+surrogates under python3. + + Needed so we can print anything passed to us through argv and environ. + Under Windows only text_type is allowed. + + Arguments: + objects: one or more bytes/text + linesep (bool): whether a line separator should be appended + sep (bool): whether objects should be printed separated by spaces + """ + + linesep = kwargs.pop("linesep", True) + sep = kwargs.pop("sep", True) + file_ = kwargs.pop("file", None) + if file_ is None: + file_ = sys.stdout + + old_cp = None + if os.name == "nt": + # Try to force the output to cp65001 aka utf-8. + # If that fails use the current one (most likely cp850, so + # most of unicode will be replaced with '?') + encoding = "utf-8" + old_cp = ctypes.windll.kernel32.GetConsoleOutputCP() + if ctypes.windll.kernel32.SetConsoleOutputCP(65001) == 0: + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + old_cp = None + else: + encoding = fsencoding() + + try: + if linesep: + objects = list(objects) + [os.linesep] + + parts = [] + for text in objects: + if isinstance(text, text_type): + if PY3: + try: + text = text.encode(encoding, 'surrogateescape') + except UnicodeEncodeError: + text = text.encode(encoding, 'replace') + else: + text = text.encode(encoding, 'replace') + parts.append(text) + + data = (b" " if sep else b"").join(parts) + try: + fileno = file_.fileno() + except (AttributeError, OSError, ValueError): + # for tests when stdout is replaced + try: + file_.write(data) + except TypeError: + file_.write(data.decode(encoding, "replace")) + else: + file_.flush() + os.write(fileno, data) + finally: + # reset the code page to what we had before + if old_cp is not None: + ctypes.windll.kernel32.SetConsoleOutputCP(old_cp) + + +class OptionParser(optparse.OptionParser): + """OptionParser subclass which supports printing Unicode under Windows""" + + def print_help(self, file=None): + print_(self.format_help(), file=file) diff --git a/resources/lib/mutagen/_util.py b/resources/lib/mutagen/_util.py new file mode 100644 index 00000000..f05ff454 --- /dev/null +++ b/resources/lib/mutagen/_util.py @@ -0,0 +1,550 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Utility classes for Mutagen. + +You should not rely on the interfaces here being stable. They are +intended for internal use in Mutagen only. +""" + +import struct +import codecs + +from fnmatch import fnmatchcase + +from ._compat import chr_, PY2, iteritems, iterbytes, integer_types, xrange, \ + izip + + +class MutagenError(Exception): + """Base class for all custom exceptions in mutagen + + .. versionadded:: 1.25 + """ + + __module__ = "mutagen" + + +def total_ordering(cls): + assert "__eq__" in cls.__dict__ + assert "__lt__" in cls.__dict__ + + cls.__le__ = lambda self, other: self == other or self < other + cls.__gt__ = lambda self, other: not (self == other or self < other) + cls.__ge__ = lambda self, other: not self < other + cls.__ne__ = lambda self, other: not self.__eq__(other) + + return cls + + +def hashable(cls): + """Makes sure the class is hashable. + + Needs a working __eq__ and __hash__ and will add a __ne__. + """ + + # py2 + assert "__hash__" in cls.__dict__ + # py3 + assert cls.__dict__["__hash__"] is not None + assert "__eq__" in cls.__dict__ + + cls.__ne__ = lambda self, other: not self.__eq__(other) + + return cls + + +def enum(cls): + assert cls.__bases__ == (object,) + + d = dict(cls.__dict__) + new_type = type(cls.__name__, (int,), d) + new_type.__module__ = cls.__module__ + + map_ = {} + for key, value in iteritems(d): + if key.upper() == key and isinstance(value, integer_types): + value_instance = new_type(value) + setattr(new_type, key, value_instance) + map_[value] = key + + def str_(self): + if self in map_: + return "%s.%s" % (type(self).__name__, map_[self]) + return "%d" % int(self) + + def repr_(self): + if self in map_: + return "<%s.%s: %d>" % (type(self).__name__, map_[self], int(self)) + return "%d" % int(self) + + setattr(new_type, "__repr__", repr_) + setattr(new_type, "__str__", str_) + + return new_type + + +@total_ordering +class DictMixin(object): + """Implement the dict API using keys() and __*item__ methods. + + Similar to UserDict.DictMixin, this takes a class that defines + __getitem__, __setitem__, __delitem__, and keys(), and turns it + into a full dict-like object. + + UserDict.DictMixin is not suitable for this purpose because it's + an old-style class. + + This class is not optimized for very large dictionaries; many + functions have linear memory requirements. I recommend you + override some of these functions if speed is required. + """ + + def __iter__(self): + return iter(self.keys()) + + def __has_key(self, key): + try: + self[key] + except KeyError: + return False + else: + return True + + if PY2: + has_key = __has_key + + __contains__ = __has_key + + if PY2: + iterkeys = lambda self: iter(self.keys()) + + def values(self): + return [self[k] for k in self.keys()] + + if PY2: + itervalues = lambda self: iter(self.values()) + + def items(self): + return list(izip(self.keys(), self.values())) + + if PY2: + iteritems = lambda s: iter(s.items()) + + def clear(self): + for key in list(self.keys()): + self.__delitem__(key) + + def pop(self, key, *args): + if len(args) > 1: + raise TypeError("pop takes at most two arguments") + try: + value = self[key] + except KeyError: + if args: + return args[0] + else: + raise + del(self[key]) + return value + + def popitem(self): + for key in self.keys(): + break + else: + raise KeyError("dictionary is empty") + return key, self.pop(key) + + def update(self, other=None, **kwargs): + if other is None: + self.update(kwargs) + other = {} + + try: + for key, value in other.items(): + self.__setitem__(key, value) + except AttributeError: + for key, value in other: + self[key] = value + + def setdefault(self, key, default=None): + try: + return self[key] + except KeyError: + self[key] = default + return default + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def __repr__(self): + return repr(dict(self.items())) + + def __eq__(self, other): + return dict(self.items()) == other + + def __lt__(self, other): + return dict(self.items()) < other + + __hash__ = object.__hash__ + + def __len__(self): + return len(self.keys()) + + +class DictProxy(DictMixin): + def __init__(self, *args, **kwargs): + self.__dict = {} + super(DictProxy, self).__init__(*args, **kwargs) + + def __getitem__(self, key): + return self.__dict[key] + + def __setitem__(self, key, value): + self.__dict[key] = value + + def __delitem__(self, key): + del(self.__dict[key]) + + def keys(self): + return self.__dict.keys() + + +def _fill_cdata(cls): + """Add struct pack/unpack functions""" + + funcs = {} + for key, name in [("b", "char"), ("h", "short"), + ("i", "int"), ("q", "longlong")]: + for echar, esuffix in [("<", "le"), (">", "be")]: + esuffix = "_" + esuffix + for unsigned in [True, False]: + s = struct.Struct(echar + (key.upper() if unsigned else key)) + get_wrapper = lambda f: lambda *a, **k: f(*a, **k)[0] + unpack = get_wrapper(s.unpack) + unpack_from = get_wrapper(s.unpack_from) + + def get_unpack_from(s): + def unpack_from(data, offset=0): + return s.unpack_from(data, offset)[0], offset + s.size + return unpack_from + + unpack_from = get_unpack_from(s) + pack = s.pack + + prefix = "u" if unsigned else "" + if s.size == 1: + esuffix = "" + bits = str(s.size * 8) + funcs["%s%s%s" % (prefix, name, esuffix)] = unpack + funcs["%sint%s%s" % (prefix, bits, esuffix)] = unpack + funcs["%s%s%s_from" % (prefix, name, esuffix)] = unpack_from + funcs["%sint%s%s_from" % (prefix, bits, esuffix)] = unpack_from + funcs["to_%s%s%s" % (prefix, name, esuffix)] = pack + funcs["to_%sint%s%s" % (prefix, bits, esuffix)] = pack + + for key, func in iteritems(funcs): + setattr(cls, key, staticmethod(func)) + + +class cdata(object): + """C character buffer to Python numeric type conversions. + + For each size/sign/endianness: + uint32_le(data)/to_uint32_le(num)/uint32_le_from(data, offset=0) + """ + + from struct import error + error = error + + bitswap = b''.join( + chr_(sum(((val >> i) & 1) << (7 - i) for i in xrange(8))) + for val in xrange(256)) + + test_bit = staticmethod(lambda value, n: bool((value >> n) & 1)) + + +_fill_cdata(cdata) + + +def get_size(fileobj): + """Returns the size of the file object. The position when passed in will + be preserved if no error occurs. + + In case of an error raises IOError. + """ + + old_pos = fileobj.tell() + try: + fileobj.seek(0, 2) + return fileobj.tell() + finally: + fileobj.seek(old_pos, 0) + + +def insert_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16): + """Insert size bytes of empty space starting at offset. + + fobj must be an open file object, open rb+ or + equivalent. Mutagen tries to use mmap to resize the file, but + falls back to a significantly slower method if mmap fails. + """ + + assert 0 < size + assert 0 <= offset + + fobj.seek(0, 2) + filesize = fobj.tell() + movesize = filesize - offset + fobj.write(b'\x00' * size) + fobj.flush() + + try: + import mmap + file_map = mmap.mmap(fobj.fileno(), filesize + size) + try: + file_map.move(offset + size, offset, movesize) + finally: + file_map.close() + except (ValueError, EnvironmentError, ImportError, AttributeError): + # handle broken mmap scenarios, BytesIO() + fobj.truncate(filesize) + + fobj.seek(0, 2) + padsize = size + # Don't generate an enormous string if we need to pad + # the file out several megs. + while padsize: + addsize = min(BUFFER_SIZE, padsize) + fobj.write(b"\x00" * addsize) + padsize -= addsize + + fobj.seek(filesize, 0) + while movesize: + # At the start of this loop, fobj is pointing at the end + # of the data we need to move, which is of movesize length. + thismove = min(BUFFER_SIZE, movesize) + # Seek back however much we're going to read this frame. + fobj.seek(-thismove, 1) + nextpos = fobj.tell() + # Read it, so we're back at the end. + data = fobj.read(thismove) + # Seek back to where we need to write it. + fobj.seek(-thismove + size, 1) + # Write it. + fobj.write(data) + # And seek back to the end of the unmoved data. + fobj.seek(nextpos) + movesize -= thismove + + fobj.flush() + + +def delete_bytes(fobj, size, offset, BUFFER_SIZE=2 ** 16): + """Delete size bytes of empty space starting at offset. + + fobj must be an open file object, open rb+ or + equivalent. Mutagen tries to use mmap to resize the file, but + falls back to a significantly slower method if mmap fails. + """ + + assert 0 < size + assert 0 <= offset + + fobj.seek(0, 2) + filesize = fobj.tell() + movesize = filesize - offset - size + assert 0 <= movesize + + if movesize > 0: + fobj.flush() + try: + import mmap + file_map = mmap.mmap(fobj.fileno(), filesize) + try: + file_map.move(offset, offset + size, movesize) + finally: + file_map.close() + except (ValueError, EnvironmentError, ImportError, AttributeError): + # handle broken mmap scenarios, BytesIO() + fobj.seek(offset + size) + buf = fobj.read(BUFFER_SIZE) + while buf: + fobj.seek(offset) + fobj.write(buf) + offset += len(buf) + fobj.seek(offset + size) + buf = fobj.read(BUFFER_SIZE) + fobj.truncate(filesize - size) + fobj.flush() + + +def resize_bytes(fobj, old_size, new_size, offset): + """Resize an area in a file adding and deleting at the end of it. + Does nothing if no resizing is needed. + """ + + if new_size < old_size: + delete_size = old_size - new_size + delete_at = offset + new_size + delete_bytes(fobj, delete_size, delete_at) + elif new_size > old_size: + insert_size = new_size - old_size + insert_at = offset + old_size + insert_bytes(fobj, insert_size, insert_at) + + +def dict_match(d, key, default=None): + """Like __getitem__ but works as if the keys() are all filename patterns. + Returns the value of any dict key that matches the passed key. + """ + + if key in d and "[" not in key: + return d[key] + else: + for pattern, value in iteritems(d): + if fnmatchcase(key, pattern): + return value + return default + + +def decode_terminated(data, encoding, strict=True): + """Returns the decoded data until the first NULL terminator + and all data after it. + + In case the data can't be decoded raises UnicodeError. + In case the encoding is not found raises LookupError. + In case the data isn't null terminated (even if it is encoded correctly) + raises ValueError except if strict is False, then the decoded string + will be returned anyway. + """ + + codec_info = codecs.lookup(encoding) + + # normalize encoding name so we can compare by name + encoding = codec_info.name + + # fast path + if encoding in ("utf-8", "iso8859-1"): + index = data.find(b"\x00") + if index == -1: + # make sure we raise UnicodeError first, like in the slow path + res = data.decode(encoding), b"" + if strict: + raise ValueError("not null terminated") + else: + return res + return data[:index].decode(encoding), data[index + 1:] + + # slow path + decoder = codec_info.incrementaldecoder() + r = [] + for i, b in enumerate(iterbytes(data)): + c = decoder.decode(b) + if c == u"\x00": + return u"".join(r), data[i + 1:] + r.append(c) + else: + # make sure the decoder is finished + r.append(decoder.decode(b"", True)) + if strict: + raise ValueError("not null terminated") + return u"".join(r), b"" + + +class BitReaderError(Exception): + pass + + +class BitReader(object): + + def __init__(self, fileobj): + self._fileobj = fileobj + self._buffer = 0 + self._bits = 0 + self._pos = fileobj.tell() + + def bits(self, count): + """Reads `count` bits and returns an uint, MSB read first. + + May raise BitReaderError if not enough data could be read or + IOError by the underlying file object. + """ + + if count < 0: + raise ValueError + + if count > self._bits: + n_bytes = (count - self._bits + 7) // 8 + data = self._fileobj.read(n_bytes) + if len(data) != n_bytes: + raise BitReaderError("not enough data") + for b in bytearray(data): + self._buffer = (self._buffer << 8) | b + self._bits += n_bytes * 8 + + self._bits -= count + value = self._buffer >> self._bits + self._buffer &= (1 << self._bits) - 1 + assert self._bits < 8 + return value + + def bytes(self, count): + """Returns a bytearray of length `count`. Works unaligned.""" + + if count < 0: + raise ValueError + + # fast path + if self._bits == 0: + data = self._fileobj.read(count) + if len(data) != count: + raise BitReaderError("not enough data") + return data + + return bytes(bytearray(self.bits(8) for _ in xrange(count))) + + def skip(self, count): + """Skip `count` bits. + + Might raise BitReaderError if there wasn't enough data to skip, + but might also fail on the next bits() instead. + """ + + if count < 0: + raise ValueError + + if count <= self._bits: + self.bits(count) + else: + count -= self.align() + n_bytes = count // 8 + self._fileobj.seek(n_bytes, 1) + count -= n_bytes * 8 + self.bits(count) + + def get_position(self): + """Returns the amount of bits read or skipped so far""" + + return (self._fileobj.tell() - self._pos) * 8 - self._bits + + def align(self): + """Align to the next byte, returns the amount of bits skipped""" + + bits = self._bits + self._buffer = 0 + self._bits = 0 + return bits + + def is_aligned(self): + """If we are currently aligned to bytes and nothing is buffered""" + + return self._bits == 0 diff --git a/resources/lib/mutagen/_vorbis.py b/resources/lib/mutagen/_vorbis.py new file mode 100644 index 00000000..da202400 --- /dev/null +++ b/resources/lib/mutagen/_vorbis.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005-2006 Joe Wreschnig +# 2013 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""Read and write Vorbis comment data. + +Vorbis comments are freeform key/value pairs; keys are +case-insensitive ASCII and values are Unicode strings. A key may have +multiple values. + +The specification is at http://www.xiph.org/vorbis/doc/v-comment.html. +""" + +import sys + +import mutagen +from ._compat import reraise, BytesIO, text_type, xrange, PY3, PY2 +from mutagen._util import DictMixin, cdata + + +def is_valid_key(key): + """Return true if a string is a valid Vorbis comment key. + + Valid Vorbis comment keys are printable ASCII between 0x20 (space) + and 0x7D ('}'), excluding '='. + + Takes str/unicode in Python 2, unicode in Python 3 + """ + + if PY3 and isinstance(key, bytes): + raise TypeError("needs to be str not bytes") + + for c in key: + if c < " " or c > "}" or c == "=": + return False + else: + return bool(key) + + +istag = is_valid_key + + +class error(IOError): + pass + + +class VorbisUnsetFrameError(error): + pass + + +class VorbisEncodingError(error): + pass + + +class VComment(mutagen.Metadata, list): + """A Vorbis comment parser, accessor, and renderer. + + All comment ordering is preserved. A VComment is a list of + key/value pairs, and so any Python list method can be used on it. + + Vorbis comments are always wrapped in something like an Ogg Vorbis + bitstream or a FLAC metadata block, so this loads string data or a + file-like object, not a filename. + + Attributes: + + * vendor -- the stream 'vendor' (i.e. writer); default 'Mutagen' + """ + + vendor = u"Mutagen " + mutagen.version_string + + def __init__(self, data=None, *args, **kwargs): + self._size = 0 + # Collect the args to pass to load, this lets child classes + # override just load and get equivalent magic for the + # constructor. + if data is not None: + if isinstance(data, bytes): + data = BytesIO(data) + elif not hasattr(data, 'read'): + raise TypeError("VComment requires bytes or a file-like") + start = data.tell() + self.load(data, *args, **kwargs) + self._size = data.tell() - start + + def load(self, fileobj, errors='replace', framing=True): + """Parse a Vorbis comment from a file-like object. + + Keyword arguments: + + * errors: + 'strict', 'replace', or 'ignore'. This affects Unicode decoding + and how other malformed content is interpreted. + * framing -- if true, fail if a framing bit is not present + + Framing bits are required by the Vorbis comment specification, + but are not used in FLAC Vorbis comment blocks. + """ + + try: + vendor_length = cdata.uint_le(fileobj.read(4)) + self.vendor = fileobj.read(vendor_length).decode('utf-8', errors) + count = cdata.uint_le(fileobj.read(4)) + for i in xrange(count): + length = cdata.uint_le(fileobj.read(4)) + try: + string = fileobj.read(length).decode('utf-8', errors) + except (OverflowError, MemoryError): + raise error("cannot read %d bytes, too large" % length) + try: + tag, value = string.split('=', 1) + except ValueError as err: + if errors == "ignore": + continue + elif errors == "replace": + tag, value = u"unknown%d" % i, string + else: + reraise(VorbisEncodingError, err, sys.exc_info()[2]) + try: + tag = tag.encode('ascii', errors) + except UnicodeEncodeError: + raise VorbisEncodingError("invalid tag name %r" % tag) + else: + # string keys in py3k + if PY3: + tag = tag.decode("ascii") + if is_valid_key(tag): + self.append((tag, value)) + + if framing and not bytearray(fileobj.read(1))[0] & 0x01: + raise VorbisUnsetFrameError("framing bit was unset") + except (cdata.error, TypeError): + raise error("file is not a valid Vorbis comment") + + def validate(self): + """Validate keys and values. + + Check to make sure every key used is a valid Vorbis key, and + that every value used is a valid Unicode or UTF-8 string. If + any invalid keys or values are found, a ValueError is raised. + + In Python 3 all keys and values have to be a string. + """ + + if not isinstance(self.vendor, text_type): + if PY3: + raise ValueError("vendor needs to be str") + + try: + self.vendor.decode('utf-8') + except UnicodeDecodeError: + raise ValueError + + for key, value in self: + try: + if not is_valid_key(key): + raise ValueError + except TypeError: + raise ValueError("%r is not a valid key" % key) + + if not isinstance(value, text_type): + if PY3: + raise ValueError("%r needs to be str" % key) + + try: + value.decode("utf-8") + except: + raise ValueError("%r is not a valid value" % value) + + return True + + def clear(self): + """Clear all keys from the comment.""" + + for i in list(self): + self.remove(i) + + def write(self, framing=True): + """Return a string representation of the data. + + Validation is always performed, so calling this function on + invalid data may raise a ValueError. + + Keyword arguments: + + * framing -- if true, append a framing bit (see load) + """ + + self.validate() + + def _encode(value): + if not isinstance(value, bytes): + return value.encode('utf-8') + return value + + f = BytesIO() + vendor = _encode(self.vendor) + f.write(cdata.to_uint_le(len(vendor))) + f.write(vendor) + f.write(cdata.to_uint_le(len(self))) + for tag, value in self: + tag = _encode(tag) + value = _encode(value) + comment = tag + b"=" + value + f.write(cdata.to_uint_le(len(comment))) + f.write(comment) + if framing: + f.write(b"\x01") + return f.getvalue() + + def pprint(self): + + def _decode(value): + if not isinstance(value, text_type): + return value.decode('utf-8', 'replace') + return value + + tags = [u"%s=%s" % (_decode(k), _decode(v)) for k, v in self] + return u"\n".join(tags) + + +class VCommentDict(VComment, DictMixin): + """A VComment that looks like a dictionary. + + This object differs from a dictionary in two ways. First, + len(comment) will still return the number of values, not the + number of keys. Secondly, iterating through the object will + iterate over (key, value) pairs, not keys. Since a key may have + multiple values, the same value may appear multiple times while + iterating. + + Since Vorbis comment keys are case-insensitive, all keys are + normalized to lowercase ASCII. + """ + + def __getitem__(self, key): + """A list of values for the key. + + This is a copy, so comment['title'].append('a title') will not + work. + """ + + # PY3 only + if isinstance(key, slice): + return VComment.__getitem__(self, key) + + if not is_valid_key(key): + raise ValueError + + key = key.lower() + + values = [value for (k, value) in self if k.lower() == key] + if not values: + raise KeyError(key) + else: + return values + + def __delitem__(self, key): + """Delete all values associated with the key.""" + + # PY3 only + if isinstance(key, slice): + return VComment.__delitem__(self, key) + + if not is_valid_key(key): + raise ValueError + + key = key.lower() + to_delete = [x for x in self if x[0].lower() == key] + if not to_delete: + raise KeyError(key) + else: + for item in to_delete: + self.remove(item) + + def __contains__(self, key): + """Return true if the key has any values.""" + + if not is_valid_key(key): + raise ValueError + + key = key.lower() + for k, value in self: + if k.lower() == key: + return True + else: + return False + + def __setitem__(self, key, values): + """Set a key's value or values. + + Setting a value overwrites all old ones. The value may be a + list of Unicode or UTF-8 strings, or a single Unicode or UTF-8 + string. + """ + + # PY3 only + if isinstance(key, slice): + return VComment.__setitem__(self, key, values) + + if not is_valid_key(key): + raise ValueError + + if not isinstance(values, list): + values = [values] + try: + del(self[key]) + except KeyError: + pass + + if PY2: + key = key.encode('ascii') + + for value in values: + self.append((key, value)) + + def keys(self): + """Return all keys in the comment.""" + + return list(set([k.lower() for k, v in self])) + + def as_dict(self): + """Return a copy of the comment data in a real dict.""" + + return dict([(key, self[key]) for key in self.keys()]) diff --git a/resources/lib/mutagen/aac.py b/resources/lib/mutagen/aac.py new file mode 100644 index 00000000..83968a05 --- /dev/null +++ b/resources/lib/mutagen/aac.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +""" +* ADTS - Audio Data Transport Stream +* ADIF - Audio Data Interchange Format +* See ISO/IEC 13818-7 / 14496-03 +""" + +from mutagen import StreamInfo +from mutagen._file import FileType +from mutagen._util import BitReader, BitReaderError, MutagenError +from mutagen._compat import endswith, xrange + + +_FREQS = [ + 96000, 88200, 64000, 48000, + 44100, 32000, 24000, 22050, + 16000, 12000, 11025, 8000, + 7350, +] + + +class _ADTSStream(object): + """Represents a series of frames belonging to the same stream""" + + parsed_frames = 0 + """Number of successfully parsed frames""" + + offset = 0 + """offset in bytes at which the stream starts (the first sync word)""" + + @classmethod + def find_stream(cls, fileobj, max_bytes): + """Returns a possibly valid _ADTSStream or None. + + Args: + max_bytes (int): maximum bytes to read + """ + + r = BitReader(fileobj) + stream = cls(r) + if stream.sync(max_bytes): + stream.offset = (r.get_position() - 12) // 8 + return stream + + def sync(self, max_bytes): + """Find the next sync. + Returns True if found.""" + + # at least 2 bytes for the sync + max_bytes = max(max_bytes, 2) + + r = self._r + r.align() + while max_bytes > 0: + try: + b = r.bytes(1) + if b == b"\xff": + if r.bits(4) == 0xf: + return True + r.align() + max_bytes -= 2 + else: + max_bytes -= 1 + except BitReaderError: + return False + return False + + def __init__(self, r): + """Use _ADTSStream.find_stream to create a stream""" + + self._fixed_header_key = None + self._r = r + self.offset = -1 + self.parsed_frames = 0 + + self._samples = 0 + self._payload = 0 + self._start = r.get_position() / 8 + self._last = self._start + + @property + def bitrate(self): + """Bitrate of the raw aac blocks, excluding framing/crc""" + + assert self.parsed_frames, "no frame parsed yet" + + if self._samples == 0: + return 0 + + return (8 * self._payload * self.frequency) // self._samples + + @property + def samples(self): + """samples so far""" + + assert self.parsed_frames, "no frame parsed yet" + + return self._samples + + @property + def size(self): + """bytes read in the stream so far (including framing)""" + + assert self.parsed_frames, "no frame parsed yet" + + return self._last - self._start + + @property + def channels(self): + """0 means unknown""" + + assert self.parsed_frames, "no frame parsed yet" + + b_index = self._fixed_header_key[6] + if b_index == 7: + return 8 + elif b_index > 7: + return 0 + else: + return b_index + + @property + def frequency(self): + """0 means unknown""" + + assert self.parsed_frames, "no frame parsed yet" + + f_index = self._fixed_header_key[4] + try: + return _FREQS[f_index] + except IndexError: + return 0 + + def parse_frame(self): + """True if parsing was successful. + Fails either because the frame wasn't valid or the stream ended. + """ + + try: + return self._parse_frame() + except BitReaderError: + return False + + def _parse_frame(self): + r = self._r + # start == position of sync word + start = r.get_position() - 12 + + # adts_fixed_header + id_ = r.bits(1) + layer = r.bits(2) + protection_absent = r.bits(1) + + profile = r.bits(2) + sampling_frequency_index = r.bits(4) + private_bit = r.bits(1) + # TODO: if 0 we could parse program_config_element() + channel_configuration = r.bits(3) + original_copy = r.bits(1) + home = r.bits(1) + + # the fixed header has to be the same for every frame in the stream + fixed_header_key = ( + id_, layer, protection_absent, profile, sampling_frequency_index, + private_bit, channel_configuration, original_copy, home, + ) + + if self._fixed_header_key is None: + self._fixed_header_key = fixed_header_key + else: + if self._fixed_header_key != fixed_header_key: + return False + + # adts_variable_header + r.skip(2) # copyright_identification_bit/start + frame_length = r.bits(13) + r.skip(11) # adts_buffer_fullness + nordbif = r.bits(2) + # adts_variable_header end + + crc_overhead = 0 + if not protection_absent: + crc_overhead += (nordbif + 1) * 16 + if nordbif != 0: + crc_overhead *= 2 + + left = (frame_length * 8) - (r.get_position() - start) + if left < 0: + return False + r.skip(left) + assert r.is_aligned() + + self._payload += (left - crc_overhead) / 8 + self._samples += (nordbif + 1) * 1024 + self._last = r.get_position() / 8 + + self.parsed_frames += 1 + return True + + +class ProgramConfigElement(object): + + element_instance_tag = None + object_type = None + sampling_frequency_index = None + channels = None + + def __init__(self, r): + """Reads the program_config_element() + + Raises BitReaderError + """ + + self.element_instance_tag = r.bits(4) + self.object_type = r.bits(2) + self.sampling_frequency_index = r.bits(4) + num_front_channel_elements = r.bits(4) + num_side_channel_elements = r.bits(4) + num_back_channel_elements = r.bits(4) + num_lfe_channel_elements = r.bits(2) + num_assoc_data_elements = r.bits(3) + num_valid_cc_elements = r.bits(4) + + mono_mixdown_present = r.bits(1) + if mono_mixdown_present == 1: + r.skip(4) + stereo_mixdown_present = r.bits(1) + if stereo_mixdown_present == 1: + r.skip(4) + matrix_mixdown_idx_present = r.bits(1) + if matrix_mixdown_idx_present == 1: + r.skip(3) + + elms = num_front_channel_elements + num_side_channel_elements + \ + num_back_channel_elements + channels = 0 + for i in xrange(elms): + channels += 1 + element_is_cpe = r.bits(1) + if element_is_cpe: + channels += 1 + r.skip(4) + channels += num_lfe_channel_elements + self.channels = channels + + r.skip(4 * num_lfe_channel_elements) + r.skip(4 * num_assoc_data_elements) + r.skip(5 * num_valid_cc_elements) + r.align() + comment_field_bytes = r.bits(8) + r.skip(8 * comment_field_bytes) + + +class AACError(MutagenError): + pass + + +class AACInfo(StreamInfo): + """AAC stream information. + + Attributes: + + * channels -- number of audio channels + * length -- file length in seconds, as a float + * sample_rate -- audio sampling rate in Hz + * bitrate -- audio bitrate, in bits per second + + The length of the stream is just a guess and might not be correct. + """ + + channels = 0 + length = 0 + sample_rate = 0 + bitrate = 0 + + def __init__(self, fileobj): + # skip id3v2 header + start_offset = 0 + header = fileobj.read(10) + from mutagen.id3 import BitPaddedInt + if header.startswith(b"ID3"): + size = BitPaddedInt(header[6:]) + start_offset = size + 10 + + fileobj.seek(start_offset) + adif = fileobj.read(4) + if adif == b"ADIF": + self._parse_adif(fileobj) + self._type = "ADIF" + else: + self._parse_adts(fileobj, start_offset) + self._type = "ADTS" + + def _parse_adif(self, fileobj): + r = BitReader(fileobj) + try: + copyright_id_present = r.bits(1) + if copyright_id_present: + r.skip(72) # copyright_id + r.skip(1 + 1) # original_copy, home + bitstream_type = r.bits(1) + self.bitrate = r.bits(23) + npce = r.bits(4) + if bitstream_type == 0: + r.skip(20) # adif_buffer_fullness + + pce = ProgramConfigElement(r) + try: + self.sample_rate = _FREQS[pce.sampling_frequency_index] + except IndexError: + pass + self.channels = pce.channels + + # other pces.. + for i in xrange(npce): + ProgramConfigElement(r) + r.align() + except BitReaderError as e: + raise AACError(e) + + # use bitrate + data size to guess length + start = fileobj.tell() + fileobj.seek(0, 2) + length = fileobj.tell() - start + if self.bitrate != 0: + self.length = (8.0 * length) / self.bitrate + + def _parse_adts(self, fileobj, start_offset): + max_initial_read = 512 + max_resync_read = 10 + max_sync_tries = 10 + + frames_max = 100 + frames_needed = 3 + + # Try up to X times to find a sync word and read up to Y frames. + # If more than Z frames are valid we assume a valid stream + offset = start_offset + for i in xrange(max_sync_tries): + fileobj.seek(offset) + s = _ADTSStream.find_stream(fileobj, max_initial_read) + if s is None: + raise AACError("sync not found") + # start right after the last found offset + offset += s.offset + 1 + + for i in xrange(frames_max): + if not s.parse_frame(): + break + if not s.sync(max_resync_read): + break + + if s.parsed_frames >= frames_needed: + break + else: + raise AACError( + "no valid stream found (only %d frames)" % s.parsed_frames) + + self.sample_rate = s.frequency + self.channels = s.channels + self.bitrate = s.bitrate + + # size from stream start to end of file + fileobj.seek(0, 2) + stream_size = fileobj.tell() - (offset + s.offset) + # approx + self.length = float(s.samples * stream_size) / (s.size * s.frequency) + + def pprint(self): + return u"AAC (%s), %d Hz, %.2f seconds, %d channel(s), %d bps" % ( + self._type, self.sample_rate, self.length, self.channels, + self.bitrate) + + +class AAC(FileType): + """Load ADTS or ADIF streams containing AAC. + + Tagging is not supported. + Use the ID3/APEv2 classes directly instead. + """ + + _mimes = ["audio/x-aac"] + + def load(self, filename): + self.filename = filename + with open(filename, "rb") as h: + self.info = AACInfo(h) + + def add_tags(self): + raise AACError("doesn't support tags") + + @staticmethod + def score(filename, fileobj, header): + filename = filename.lower() + s = endswith(filename, ".aac") or endswith(filename, ".adts") or \ + endswith(filename, ".adif") + s += b"ADIF" in header + return s + + +Open = AAC +error = AACError + +__all__ = ["AAC", "Open"] diff --git a/resources/lib/mutagen/aiff.py b/resources/lib/mutagen/aiff.py new file mode 100644 index 00000000..dc580063 --- /dev/null +++ b/resources/lib/mutagen/aiff.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2014 Evan Purkhiser +# 2014 Ben Ockmore +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""AIFF audio stream information and tags.""" + +import sys +import struct +from struct import pack + +from ._compat import endswith, text_type, reraise +from mutagen import StreamInfo, FileType + +from mutagen.id3 import ID3 +from mutagen.id3._util import ID3NoHeaderError, error as ID3Error +from mutagen._util import resize_bytes, delete_bytes, MutagenError + +__all__ = ["AIFF", "Open", "delete"] + + +class error(MutagenError, RuntimeError): + pass + + +class InvalidChunk(error, IOError): + pass + + +# based on stdlib's aifc +_HUGE_VAL = 1.79769313486231e+308 + + +def is_valid_chunk_id(id): + assert isinstance(id, text_type) + + return ((len(id) <= 4) and (min(id) >= u' ') and + (max(id) <= u'~')) + + +def read_float(data): # 10 bytes + expon, himant, lomant = struct.unpack('>hLL', data) + sign = 1 + if expon < 0: + sign = -1 + expon = expon + 0x8000 + if expon == himant == lomant == 0: + f = 0.0 + elif expon == 0x7FFF: + f = _HUGE_VAL + else: + expon = expon - 16383 + f = (himant * 0x100000000 + lomant) * pow(2.0, expon - 63) + return sign * f + + +class IFFChunk(object): + """Representation of a single IFF chunk""" + + # Chunk headers are 8 bytes long (4 for ID and 4 for the size) + HEADER_SIZE = 8 + + def __init__(self, fileobj, parent_chunk=None): + self.__fileobj = fileobj + self.parent_chunk = parent_chunk + self.offset = fileobj.tell() + + header = fileobj.read(self.HEADER_SIZE) + if len(header) < self.HEADER_SIZE: + raise InvalidChunk() + + self.id, self.data_size = struct.unpack('>4si', header) + + try: + self.id = self.id.decode('ascii') + except UnicodeDecodeError: + raise InvalidChunk() + + if not is_valid_chunk_id(self.id): + raise InvalidChunk() + + self.size = self.HEADER_SIZE + self.data_size + self.data_offset = fileobj.tell() + + def read(self): + """Read the chunks data""" + + self.__fileobj.seek(self.data_offset) + return self.__fileobj.read(self.data_size) + + def write(self, data): + """Write the chunk data""" + + if len(data) > self.data_size: + raise ValueError + + self.__fileobj.seek(self.data_offset) + self.__fileobj.write(data) + + def delete(self): + """Removes the chunk from the file""" + + delete_bytes(self.__fileobj, self.size, self.offset) + if self.parent_chunk is not None: + self.parent_chunk._update_size( + self.parent_chunk.data_size - self.size) + + def _update_size(self, data_size): + """Update the size of the chunk""" + + self.__fileobj.seek(self.offset + 4) + self.__fileobj.write(pack('>I', data_size)) + if self.parent_chunk is not None: + size_diff = self.data_size - data_size + self.parent_chunk._update_size( + self.parent_chunk.data_size - size_diff) + self.data_size = data_size + self.size = data_size + self.HEADER_SIZE + + def resize(self, new_data_size): + """Resize the file and update the chunk sizes""" + + resize_bytes( + self.__fileobj, self.data_size, new_data_size, self.data_offset) + self._update_size(new_data_size) + + +class IFFFile(object): + """Representation of a IFF file""" + + def __init__(self, fileobj): + self.__fileobj = fileobj + self.__chunks = {} + + # AIFF Files always start with the FORM chunk which contains a 4 byte + # ID before the start of other chunks + fileobj.seek(0) + self.__chunks[u'FORM'] = IFFChunk(fileobj) + + # Skip past the 4 byte FORM id + fileobj.seek(IFFChunk.HEADER_SIZE + 4) + + # Where the next chunk can be located. We need to keep track of this + # since the size indicated in the FORM header may not match up with the + # offset determined from the size of the last chunk in the file + self.__next_offset = fileobj.tell() + + # Load all of the chunks + while True: + try: + chunk = IFFChunk(fileobj, self[u'FORM']) + except InvalidChunk: + break + self.__chunks[chunk.id.strip()] = chunk + + # Calculate the location of the next chunk, + # considering the pad byte + self.__next_offset = chunk.offset + chunk.size + self.__next_offset += self.__next_offset % 2 + fileobj.seek(self.__next_offset) + + def __contains__(self, id_): + """Check if the IFF file contains a specific chunk""" + + assert isinstance(id_, text_type) + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + + return id_ in self.__chunks + + def __getitem__(self, id_): + """Get a chunk from the IFF file""" + + assert isinstance(id_, text_type) + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + + try: + return self.__chunks[id_] + except KeyError: + raise KeyError( + "%r has no %r chunk" % (self.__fileobj.name, id_)) + + def __delitem__(self, id_): + """Remove a chunk from the IFF file""" + + assert isinstance(id_, text_type) + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + + self.__chunks.pop(id_).delete() + + def insert_chunk(self, id_): + """Insert a new chunk at the end of the IFF file""" + + assert isinstance(id_, text_type) + + if not is_valid_chunk_id(id_): + raise KeyError("AIFF key must be four ASCII characters.") + + self.__fileobj.seek(self.__next_offset) + self.__fileobj.write(pack('>4si', id_.ljust(4).encode('ascii'), 0)) + self.__fileobj.seek(self.__next_offset) + chunk = IFFChunk(self.__fileobj, self[u'FORM']) + self[u'FORM']._update_size(self[u'FORM'].data_size + chunk.size) + + self.__chunks[id_] = chunk + self.__next_offset = chunk.offset + chunk.size + + +class AIFFInfo(StreamInfo): + """AIFF audio stream information. + + Information is parsed from the COMM chunk of the AIFF file + + Useful attributes: + + * length -- audio length, in seconds + * bitrate -- audio bitrate, in bits per second + * channels -- The number of audio channels + * sample_rate -- audio sample rate, in Hz + * sample_size -- The audio sample size + """ + + length = 0 + bitrate = 0 + channels = 0 + sample_rate = 0 + + def __init__(self, fileobj): + iff = IFFFile(fileobj) + try: + common_chunk = iff[u'COMM'] + except KeyError as e: + raise error(str(e)) + + data = common_chunk.read() + + info = struct.unpack('>hLh10s', data[:18]) + channels, frame_count, sample_size, sample_rate = info + + self.sample_rate = int(read_float(sample_rate)) + self.sample_size = sample_size + self.channels = channels + self.bitrate = channels * sample_size * self.sample_rate + self.length = frame_count / float(self.sample_rate) + + def pprint(self): + return u"%d channel AIFF @ %d bps, %s Hz, %.2f seconds" % ( + self.channels, self.bitrate, self.sample_rate, self.length) + + +class _IFFID3(ID3): + """A AIFF file with ID3v2 tags""" + + def _pre_load_header(self, fileobj): + try: + fileobj.seek(IFFFile(fileobj)[u'ID3'].data_offset) + except (InvalidChunk, KeyError): + raise ID3NoHeaderError("No ID3 chunk") + + def save(self, filename=None, v2_version=4, v23_sep='/', padding=None): + """Save ID3v2 data to the AIFF file""" + + if filename is None: + filename = self.filename + + # Unlike the parent ID3.save method, we won't save to a blank file + # since we would have to construct a empty AIFF file + with open(filename, 'rb+') as fileobj: + iff_file = IFFFile(fileobj) + + if u'ID3' not in iff_file: + iff_file.insert_chunk(u'ID3') + + chunk = iff_file[u'ID3'] + + try: + data = self._prepare_data( + fileobj, chunk.data_offset, chunk.data_size, v2_version, + v23_sep, padding) + except ID3Error as e: + reraise(error, e, sys.exc_info()[2]) + + new_size = len(data) + new_size += new_size % 2 # pad byte + assert new_size % 2 == 0 + chunk.resize(new_size) + data += (new_size - len(data)) * b'\x00' + assert new_size == len(data) + chunk.write(data) + + def delete(self, filename=None): + """Completely removes the ID3 chunk from the AIFF file""" + + if filename is None: + filename = self.filename + delete(filename) + self.clear() + + +def delete(filename): + """Completely removes the ID3 chunk from the AIFF file""" + + with open(filename, "rb+") as file_: + try: + del IFFFile(file_)[u'ID3'] + except KeyError: + pass + + +class AIFF(FileType): + """An AIFF audio file. + + :ivar info: :class:`AIFFInfo` + :ivar tags: :class:`ID3` + """ + + _mimes = ["audio/aiff", "audio/x-aiff"] + + @staticmethod + def score(filename, fileobj, header): + filename = filename.lower() + + return (header.startswith(b"FORM") * 2 + endswith(filename, b".aif") + + endswith(filename, b".aiff") + endswith(filename, b".aifc")) + + def add_tags(self): + """Add an empty ID3 tag to the file.""" + if self.tags is None: + self.tags = _IFFID3() + else: + raise error("an ID3 tag already exists") + + def load(self, filename, **kwargs): + """Load stream and tag information from a file.""" + self.filename = filename + + try: + self.tags = _IFFID3(filename, **kwargs) + except ID3NoHeaderError: + self.tags = None + except ID3Error as e: + raise error(e) + + with open(filename, "rb") as fileobj: + self.info = AIFFInfo(fileobj) + + +Open = AIFF diff --git a/resources/lib/mutagen/apev2.py b/resources/lib/mutagen/apev2.py new file mode 100644 index 00000000..3b79aba9 --- /dev/null +++ b/resources/lib/mutagen/apev2.py @@ -0,0 +1,710 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""APEv2 reading and writing. + +The APEv2 format is most commonly used with Musepack files, but is +also the format of choice for WavPack and other formats. Some MP3s +also have APEv2 tags, but this can cause problems with many MP3 +decoders and taggers. + +APEv2 tags, like Vorbis comments, are freeform key=value pairs. APEv2 +keys can be any ASCII string with characters from 0x20 to 0x7E, +between 2 and 255 characters long. Keys are case-sensitive, but +readers are recommended to be case insensitive, and it is forbidden to +multiple keys which differ only in case. Keys are usually stored +title-cased (e.g. 'Artist' rather than 'artist'). + +APEv2 values are slightly more structured than Vorbis comments; values +are flagged as one of text, binary, or an external reference (usually +a URI). + +Based off the format specification found at +http://wiki.hydrogenaudio.org/index.php?title=APEv2_specification. +""" + +__all__ = ["APEv2", "APEv2File", "Open", "delete"] + +import sys +import struct +from collections import MutableSequence + +from ._compat import (cBytesIO, PY3, text_type, PY2, reraise, swap_to_string, + xrange) +from mutagen import Metadata, FileType, StreamInfo +from mutagen._util import (DictMixin, cdata, delete_bytes, total_ordering, + MutagenError) + + +def is_valid_apev2_key(key): + if not isinstance(key, text_type): + if PY3: + raise TypeError("APEv2 key must be str") + + try: + key = key.decode('ascii') + except UnicodeDecodeError: + return False + + # PY26 - Change to set literal syntax (since set is faster than list here) + return ((2 <= len(key) <= 255) and (min(key) >= u' ') and + (max(key) <= u'~') and + (key not in [u"OggS", u"TAG", u"ID3", u"MP+"])) + +# There are three different kinds of APE tag values. +# "0: Item contains text information coded in UTF-8 +# 1: Item contains binary information +# 2: Item is a locator of external stored information [e.g. URL] +# 3: reserved" +TEXT, BINARY, EXTERNAL = xrange(3) + +HAS_HEADER = 1 << 31 +HAS_NO_FOOTER = 1 << 30 +IS_HEADER = 1 << 29 + + +class error(IOError, MutagenError): + pass + + +class APENoHeaderError(error, ValueError): + pass + + +class APEUnsupportedVersionError(error, ValueError): + pass + + +class APEBadItemError(error, ValueError): + pass + + +class _APEv2Data(object): + # Store offsets of the important parts of the file. + start = header = data = footer = end = None + # Footer or header; seek here and read 32 to get version/size/items/flags + metadata = None + # Actual tag data + tag = None + + version = None + size = None + items = None + flags = 0 + + # The tag is at the start rather than the end. A tag at both + # the start and end of the file (i.e. the tag is the whole file) + # is not considered to be at the start. + is_at_start = False + + def __init__(self, fileobj): + self.__find_metadata(fileobj) + + if self.header is None: + self.metadata = self.footer + elif self.footer is None: + self.metadata = self.header + else: + self.metadata = max(self.header, self.footer) + + if self.metadata is None: + return + + self.__fill_missing(fileobj) + self.__fix_brokenness(fileobj) + if self.data is not None: + fileobj.seek(self.data) + self.tag = fileobj.read(self.size) + + def __find_metadata(self, fileobj): + # Try to find a header or footer. + + # Check for a simple footer. + try: + fileobj.seek(-32, 2) + except IOError: + fileobj.seek(0, 2) + return + if fileobj.read(8) == b"APETAGEX": + fileobj.seek(-8, 1) + self.footer = self.metadata = fileobj.tell() + return + + # Check for an APEv2 tag followed by an ID3v1 tag at the end. + try: + fileobj.seek(-128, 2) + if fileobj.read(3) == b"TAG": + + fileobj.seek(-35, 1) # "TAG" + header length + if fileobj.read(8) == b"APETAGEX": + fileobj.seek(-8, 1) + self.footer = fileobj.tell() + return + + # ID3v1 tag at the end, maybe preceded by Lyrics3v2. + # (http://www.id3.org/lyrics3200.html) + # (header length - "APETAGEX") - "LYRICS200" + fileobj.seek(15, 1) + if fileobj.read(9) == b'LYRICS200': + fileobj.seek(-15, 1) # "LYRICS200" + size tag + try: + offset = int(fileobj.read(6)) + except ValueError: + raise IOError + + fileobj.seek(-32 - offset - 6, 1) + if fileobj.read(8) == b"APETAGEX": + fileobj.seek(-8, 1) + self.footer = fileobj.tell() + return + + except IOError: + pass + + # Check for a tag at the start. + fileobj.seek(0, 0) + if fileobj.read(8) == b"APETAGEX": + self.is_at_start = True + self.header = 0 + + def __fill_missing(self, fileobj): + fileobj.seek(self.metadata + 8) + self.version = fileobj.read(4) + self.size = cdata.uint_le(fileobj.read(4)) + self.items = cdata.uint_le(fileobj.read(4)) + self.flags = cdata.uint_le(fileobj.read(4)) + + if self.header is not None: + self.data = self.header + 32 + # If we're reading the header, the size is the header + # offset + the size, which includes the footer. + self.end = self.data + self.size + fileobj.seek(self.end - 32, 0) + if fileobj.read(8) == b"APETAGEX": + self.footer = self.end - 32 + elif self.footer is not None: + self.end = self.footer + 32 + self.data = self.end - self.size + if self.flags & HAS_HEADER: + self.header = self.data - 32 + else: + self.header = self.data + else: + raise APENoHeaderError("No APE tag found") + + # exclude the footer from size + if self.footer is not None: + self.size -= 32 + + def __fix_brokenness(self, fileobj): + # Fix broken tags written with PyMusepack. + if self.header is not None: + start = self.header + else: + start = self.data + fileobj.seek(start) + + while start > 0: + # Clean up broken writing from pre-Mutagen PyMusepack. + # It didn't remove the first 24 bytes of header. + try: + fileobj.seek(-24, 1) + except IOError: + break + else: + if fileobj.read(8) == b"APETAGEX": + fileobj.seek(-8, 1) + start = fileobj.tell() + else: + break + self.start = start + + +class _CIDictProxy(DictMixin): + + def __init__(self, *args, **kwargs): + self.__casemap = {} + self.__dict = {} + super(_CIDictProxy, self).__init__(*args, **kwargs) + # Internally all names are stored as lowercase, but the case + # they were set with is remembered and used when saving. This + # is roughly in line with the standard, which says that keys + # are case-sensitive but two keys differing only in case are + # not allowed, and recommends case-insensitive + # implementations. + + def __getitem__(self, key): + return self.__dict[key.lower()] + + def __setitem__(self, key, value): + lower = key.lower() + self.__casemap[lower] = key + self.__dict[lower] = value + + def __delitem__(self, key): + lower = key.lower() + del(self.__casemap[lower]) + del(self.__dict[lower]) + + def keys(self): + return [self.__casemap.get(key, key) for key in self.__dict.keys()] + + +class APEv2(_CIDictProxy, Metadata): + """A file with an APEv2 tag. + + ID3v1 tags are silently ignored and overwritten. + """ + + filename = None + + def pprint(self): + """Return tag key=value pairs in a human-readable format.""" + + items = sorted(self.items()) + return u"\n".join(u"%s=%s" % (k, v.pprint()) for k, v in items) + + def load(self, filename): + """Load tags from a filename.""" + + self.filename = filename + with open(filename, "rb") as fileobj: + data = _APEv2Data(fileobj) + + if data.tag: + self.clear() + self.__parse_tag(data.tag, data.items) + else: + raise APENoHeaderError("No APE tag found") + + def __parse_tag(self, tag, count): + fileobj = cBytesIO(tag) + + for i in xrange(count): + size_data = fileobj.read(4) + # someone writes wrong item counts + if not size_data: + break + size = cdata.uint_le(size_data) + flags = cdata.uint_le(fileobj.read(4)) + + # Bits 1 and 2 bits are flags, 0-3 + # Bit 0 is read/write flag, ignored + kind = (flags & 6) >> 1 + if kind == 3: + raise APEBadItemError("value type must be 0, 1, or 2") + key = value = fileobj.read(1) + while key[-1:] != b'\x00' and value: + value = fileobj.read(1) + key += value + if key[-1:] == b"\x00": + key = key[:-1] + if PY3: + try: + key = key.decode("ascii") + except UnicodeError as err: + reraise(APEBadItemError, err, sys.exc_info()[2]) + value = fileobj.read(size) + + value = _get_value_type(kind)._new(value) + + self[key] = value + + def __getitem__(self, key): + if not is_valid_apev2_key(key): + raise KeyError("%r is not a valid APEv2 key" % key) + if PY2: + key = key.encode('ascii') + + return super(APEv2, self).__getitem__(key) + + def __delitem__(self, key): + if not is_valid_apev2_key(key): + raise KeyError("%r is not a valid APEv2 key" % key) + if PY2: + key = key.encode('ascii') + + super(APEv2, self).__delitem__(key) + + def __setitem__(self, key, value): + """'Magic' value setter. + + This function tries to guess at what kind of value you want to + store. If you pass in a valid UTF-8 or Unicode string, it + treats it as a text value. If you pass in a list, it treats it + as a list of string/Unicode values. If you pass in a string + that is not valid UTF-8, it assumes it is a binary value. + + Python 3: all bytes will be assumed to be a byte value, even + if they are valid utf-8. + + If you need to force a specific type of value (e.g. binary + data that also happens to be valid UTF-8, or an external + reference), use the APEValue factory and set the value to the + result of that:: + + from mutagen.apev2 import APEValue, EXTERNAL + tag['Website'] = APEValue('http://example.org', EXTERNAL) + """ + + if not is_valid_apev2_key(key): + raise KeyError("%r is not a valid APEv2 key" % key) + + if PY2: + key = key.encode('ascii') + + if not isinstance(value, _APEValue): + # let's guess at the content if we're not already a value... + if isinstance(value, text_type): + # unicode? we've got to be text. + value = APEValue(value, TEXT) + elif isinstance(value, list): + items = [] + for v in value: + if not isinstance(v, text_type): + if PY3: + raise TypeError("item in list not str") + v = v.decode("utf-8") + items.append(v) + + # list? text. + value = APEValue(u"\0".join(items), TEXT) + else: + if PY3: + value = APEValue(value, BINARY) + else: + try: + value.decode("utf-8") + except UnicodeError: + # invalid UTF8 text, probably binary + value = APEValue(value, BINARY) + else: + # valid UTF8, probably text + value = APEValue(value, TEXT) + + super(APEv2, self).__setitem__(key, value) + + def save(self, filename=None): + """Save changes to a file. + + If no filename is given, the one most recently loaded is used. + + Tags are always written at the end of the file, and include + a header and a footer. + """ + + filename = filename or self.filename + try: + fileobj = open(filename, "r+b") + except IOError: + fileobj = open(filename, "w+b") + data = _APEv2Data(fileobj) + + if data.is_at_start: + delete_bytes(fileobj, data.end - data.start, data.start) + elif data.start is not None: + fileobj.seek(data.start) + # Delete an ID3v1 tag if present, too. + fileobj.truncate() + fileobj.seek(0, 2) + + tags = [] + for key, value in self.items(): + # Packed format for an item: + # 4B: Value length + # 4B: Value type + # Key name + # 1B: Null + # Key value + value_data = value._write() + if not isinstance(key, bytes): + key = key.encode("utf-8") + tag_data = bytearray() + tag_data += struct.pack("<2I", len(value_data), value.kind << 1) + tag_data += key + b"\0" + value_data + tags.append(bytes(tag_data)) + + # "APE tags items should be sorted ascending by size... This is + # not a MUST, but STRONGLY recommended. Actually the items should + # be sorted by importance/byte, but this is not feasible." + tags.sort(key=len) + num_tags = len(tags) + tags = b"".join(tags) + + header = bytearray(b"APETAGEX") + # version, tag size, item count, flags + header += struct.pack("<4I", 2000, len(tags) + 32, num_tags, + HAS_HEADER | IS_HEADER) + header += b"\0" * 8 + fileobj.write(header) + + fileobj.write(tags) + + footer = bytearray(b"APETAGEX") + footer += struct.pack("<4I", 2000, len(tags) + 32, num_tags, + HAS_HEADER) + footer += b"\0" * 8 + + fileobj.write(footer) + fileobj.close() + + def delete(self, filename=None): + """Remove tags from a file.""" + + filename = filename or self.filename + with open(filename, "r+b") as fileobj: + data = _APEv2Data(fileobj) + if data.start is not None and data.size is not None: + delete_bytes(fileobj, data.end - data.start, data.start) + + self.clear() + + +Open = APEv2 + + +def delete(filename): + """Remove tags from a file.""" + + try: + APEv2(filename).delete() + except APENoHeaderError: + pass + + +def _get_value_type(kind): + """Returns a _APEValue subclass or raises ValueError""" + + if kind == TEXT: + return APETextValue + elif kind == BINARY: + return APEBinaryValue + elif kind == EXTERNAL: + return APEExtValue + raise ValueError("unknown kind %r" % kind) + + +def APEValue(value, kind): + """APEv2 tag value factory. + + Use this if you need to specify the value's type manually. Binary + and text data are automatically detected by APEv2.__setitem__. + """ + + try: + type_ = _get_value_type(kind) + except ValueError: + raise ValueError("kind must be TEXT, BINARY, or EXTERNAL") + else: + return type_(value) + + +class _APEValue(object): + + kind = None + value = None + + def __init__(self, value, kind=None): + # kind kwarg is for backwards compat + if kind is not None and kind != self.kind: + raise ValueError + self.value = self._validate(value) + + @classmethod + def _new(cls, data): + instance = cls.__new__(cls) + instance._parse(data) + return instance + + def _parse(self, data): + """Sets value or raises APEBadItemError""" + + raise NotImplementedError + + def _write(self): + """Returns bytes""" + + raise NotImplementedError + + def _validate(self, value): + """Returns validated value or raises TypeError/ValueErrr""" + + raise NotImplementedError + + def __repr__(self): + return "%s(%r, %d)" % (type(self).__name__, self.value, self.kind) + + +@swap_to_string +@total_ordering +class _APEUtf8Value(_APEValue): + + def _parse(self, data): + try: + self.value = data.decode("utf-8") + except UnicodeDecodeError as e: + reraise(APEBadItemError, e, sys.exc_info()[2]) + + def _validate(self, value): + if not isinstance(value, text_type): + if PY3: + raise TypeError("value not str") + else: + value = value.decode("utf-8") + return value + + def _write(self): + return self.value.encode("utf-8") + + def __len__(self): + return len(self.value) + + def __bytes__(self): + return self._write() + + def __eq__(self, other): + return self.value == other + + def __lt__(self, other): + return self.value < other + + def __str__(self): + return self.value + + +class APETextValue(_APEUtf8Value, MutableSequence): + """An APEv2 text value. + + Text values are Unicode/UTF-8 strings. They can be accessed like + strings (with a null separating the values), or arrays of strings. + """ + + kind = TEXT + + def __iter__(self): + """Iterate over the strings of the value (not the characters)""" + + return iter(self.value.split(u"\0")) + + def __getitem__(self, index): + return self.value.split(u"\0")[index] + + def __len__(self): + return self.value.count(u"\0") + 1 + + def __setitem__(self, index, value): + if not isinstance(value, text_type): + if PY3: + raise TypeError("value not str") + else: + value = value.decode("utf-8") + + values = list(self) + values[index] = value + self.value = u"\0".join(values) + + def insert(self, index, value): + if not isinstance(value, text_type): + if PY3: + raise TypeError("value not str") + else: + value = value.decode("utf-8") + + values = list(self) + values.insert(index, value) + self.value = u"\0".join(values) + + def __delitem__(self, index): + values = list(self) + del values[index] + self.value = u"\0".join(values) + + def pprint(self): + return u" / ".join(self) + + +@swap_to_string +@total_ordering +class APEBinaryValue(_APEValue): + """An APEv2 binary value.""" + + kind = BINARY + + def _parse(self, data): + self.value = data + + def _write(self): + return self.value + + def _validate(self, value): + if not isinstance(value, bytes): + raise TypeError("value not bytes") + return bytes(value) + + def __len__(self): + return len(self.value) + + def __bytes__(self): + return self._write() + + def __eq__(self, other): + return self.value == other + + def __lt__(self, other): + return self.value < other + + def pprint(self): + return u"[%d bytes]" % len(self) + + +class APEExtValue(_APEUtf8Value): + """An APEv2 external value. + + External values are usually URI or IRI strings. + """ + + kind = EXTERNAL + + def pprint(self): + return u"[External] %s" % self.value + + +class APEv2File(FileType): + class _Info(StreamInfo): + length = 0 + bitrate = 0 + + def __init__(self, fileobj): + pass + + @staticmethod + def pprint(): + return u"Unknown format with APEv2 tag." + + def load(self, filename): + self.filename = filename + self.info = self._Info(open(filename, "rb")) + try: + self.tags = APEv2(filename) + except APENoHeaderError: + self.tags = None + + def add_tags(self): + if self.tags is None: + self.tags = APEv2() + else: + raise error("%r already has tags: %r" % (self, self.tags)) + + @staticmethod + def score(filename, fileobj, header): + try: + fileobj.seek(-160, 2) + except IOError: + fileobj.seek(0) + footer = fileobj.read() + return ((b"APETAGEX" in footer) - header.startswith(b"ID3")) diff --git a/resources/lib/mutagen/asf/__init__.py b/resources/lib/mutagen/asf/__init__.py new file mode 100644 index 00000000..e667192d --- /dev/null +++ b/resources/lib/mutagen/asf/__init__.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2005-2006 Joe Wreschnig +# Copyright (C) 2006-2007 Lukas Lalinsky +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write ASF (Window Media Audio) files.""" + +__all__ = ["ASF", "Open"] + +from mutagen import FileType, Metadata, StreamInfo +from mutagen._util import resize_bytes, DictMixin +from mutagen._compat import string_types, long_, PY3, izip + +from ._util import error, ASFError, ASFHeaderError +from ._objects import HeaderObject, MetadataLibraryObject, MetadataObject, \ + ExtendedContentDescriptionObject, HeaderExtensionObject, \ + ContentDescriptionObject +from ._attrs import ASFGUIDAttribute, ASFWordAttribute, ASFQWordAttribute, \ + ASFDWordAttribute, ASFBoolAttribute, ASFByteArrayAttribute, \ + ASFUnicodeAttribute, ASFBaseAttribute, ASFValue + + +# pyflakes +error, ASFError, ASFHeaderError, ASFValue + + +class ASFInfo(StreamInfo): + """ASF stream information.""" + + length = 0.0 + """Length in seconds (`float`)""" + + sample_rate = 0 + """Sample rate in Hz (`int`)""" + + bitrate = 0 + """Bitrate in bps (`int`)""" + + channels = 0 + """Number of channels (`int`)""" + + codec_type = u"" + """Name of the codec type of the first audio stream or + an empty string if unknown. Example: ``Windows Media Audio 9 Standard`` + (:class:`mutagen.text`) + """ + + codec_name = u"" + """Name and maybe version of the codec used. Example: + ``Windows Media Audio 9.1`` (:class:`mutagen.text`) + """ + + codec_description = u"" + """Further information on the codec used. + Example: ``64 kbps, 48 kHz, stereo 2-pass CBR`` (:class:`mutagen.text`) + """ + + def __init__(self): + self.length = 0.0 + self.sample_rate = 0 + self.bitrate = 0 + self.channels = 0 + self.codec_type = u"" + self.codec_name = u"" + self.codec_description = u"" + + def pprint(self): + """Returns a stream information text summary + + :rtype: text + """ + + s = u"ASF (%s) %d bps, %s Hz, %d channels, %.2f seconds" % ( + self.codec_type or self.codec_name or u"???", self.bitrate, + self.sample_rate, self.channels, self.length) + return s + + +class ASFTags(list, DictMixin, Metadata): + """Dictionary containing ASF attributes.""" + + def __getitem__(self, key): + """A list of values for the key. + + This is a copy, so comment['title'].append('a title') will not + work. + + """ + + # PY3 only + if isinstance(key, slice): + return list.__getitem__(self, key) + + values = [value for (k, value) in self if k == key] + if not values: + raise KeyError(key) + else: + return values + + def __delitem__(self, key): + """Delete all values associated with the key.""" + + # PY3 only + if isinstance(key, slice): + return list.__delitem__(self, key) + + to_delete = [x for x in self if x[0] == key] + if not to_delete: + raise KeyError(key) + else: + for k in to_delete: + self.remove(k) + + def __contains__(self, key): + """Return true if the key has any values.""" + for k, value in self: + if k == key: + return True + else: + return False + + def __setitem__(self, key, values): + """Set a key's value or values. + + Setting a value overwrites all old ones. The value may be a + list of Unicode or UTF-8 strings, or a single Unicode or UTF-8 + string. + """ + + # PY3 only + if isinstance(key, slice): + return list.__setitem__(self, key, values) + + if not isinstance(values, list): + values = [values] + + to_append = [] + for value in values: + if not isinstance(value, ASFBaseAttribute): + if isinstance(value, string_types): + value = ASFUnicodeAttribute(value) + elif PY3 and isinstance(value, bytes): + value = ASFByteArrayAttribute(value) + elif isinstance(value, bool): + value = ASFBoolAttribute(value) + elif isinstance(value, int): + value = ASFDWordAttribute(value) + elif isinstance(value, long_): + value = ASFQWordAttribute(value) + else: + raise TypeError("Invalid type %r" % type(value)) + to_append.append((key, value)) + + try: + del(self[key]) + except KeyError: + pass + + self.extend(to_append) + + def keys(self): + """Return a sequence of all keys in the comment.""" + + return self and set(next(izip(*self))) + + def as_dict(self): + """Return a copy of the comment data in a real dict.""" + + d = {} + for key, value in self: + d.setdefault(key, []).append(value) + return d + + def pprint(self): + """Returns a string containing all key, value pairs. + + :rtype: text + """ + + return "\n".join("%s=%s" % (k, v) for k, v in self) + + +UNICODE = ASFUnicodeAttribute.TYPE +"""Unicode string type""" + +BYTEARRAY = ASFByteArrayAttribute.TYPE +"""Byte array type""" + +BOOL = ASFBoolAttribute.TYPE +"""Bool type""" + +DWORD = ASFDWordAttribute.TYPE +""""DWord type (uint32)""" + +QWORD = ASFQWordAttribute.TYPE +"""QWord type (uint64)""" + +WORD = ASFWordAttribute.TYPE +"""Word type (uint16)""" + +GUID = ASFGUIDAttribute.TYPE +"""GUID type""" + + +class ASF(FileType): + """An ASF file, probably containing WMA or WMV. + + :param filename: a filename to load + :raises mutagen.asf.error: In case loading fails + """ + + _mimes = ["audio/x-ms-wma", "audio/x-ms-wmv", "video/x-ms-asf", + "audio/x-wma", "video/x-wmv"] + + info = None + """A `ASFInfo` instance""" + + tags = None + """A `ASFTags` instance""" + + def load(self, filename): + self.filename = filename + self.info = ASFInfo() + self.tags = ASFTags() + + with open(filename, "rb") as fileobj: + self._tags = {} + + self._header = HeaderObject.parse_full(self, fileobj) + + for guid in [ContentDescriptionObject.GUID, + ExtendedContentDescriptionObject.GUID, MetadataObject.GUID, + MetadataLibraryObject.GUID]: + self.tags.extend(self._tags.pop(guid, [])) + + assert not self._tags + + def save(self, filename=None, padding=None): + """Save tag changes back to the loaded file. + + :param padding: A callback which returns the amount of padding to use. + See :class:`mutagen.PaddingInfo` + + :raises mutagen.asf.error: In case saving fails + """ + + if filename is not None and filename != self.filename: + raise ValueError("saving to another file not supported atm") + + # Move attributes to the right objects + self.to_content_description = {} + self.to_extended_content_description = {} + self.to_metadata = {} + self.to_metadata_library = [] + for name, value in self.tags: + library_only = (value.data_size() > 0xFFFF or value.TYPE == GUID) + can_cont_desc = value.TYPE == UNICODE + + if library_only or value.language is not None: + self.to_metadata_library.append((name, value)) + elif value.stream is not None: + if name not in self.to_metadata: + self.to_metadata[name] = value + else: + self.to_metadata_library.append((name, value)) + elif name in ContentDescriptionObject.NAMES: + if name not in self.to_content_description and can_cont_desc: + self.to_content_description[name] = value + else: + self.to_metadata_library.append((name, value)) + else: + if name not in self.to_extended_content_description: + self.to_extended_content_description[name] = value + else: + self.to_metadata_library.append((name, value)) + + # Add missing objects + header = self._header + if header.get_child(ContentDescriptionObject.GUID) is None: + header.objects.append(ContentDescriptionObject()) + if header.get_child(ExtendedContentDescriptionObject.GUID) is None: + header.objects.append(ExtendedContentDescriptionObject()) + header_ext = header.get_child(HeaderExtensionObject.GUID) + if header_ext is None: + header_ext = HeaderExtensionObject() + header.objects.append(header_ext) + if header_ext.get_child(MetadataObject.GUID) is None: + header_ext.objects.append(MetadataObject()) + if header_ext.get_child(MetadataLibraryObject.GUID) is None: + header_ext.objects.append(MetadataLibraryObject()) + + # Render to file + with open(self.filename, "rb+") as fileobj: + old_size = header.parse_size(fileobj)[0] + data = header.render_full(self, fileobj, old_size, padding) + size = len(data) + resize_bytes(fileobj, old_size, size, 0) + fileobj.seek(0) + fileobj.write(data) + + def add_tags(self): + raise ASFError + + def delete(self, filename=None): + + if filename is not None and filename != self.filename: + raise ValueError("saving to another file not supported atm") + + self.tags.clear() + self.save(padding=lambda x: 0) + + @staticmethod + def score(filename, fileobj, header): + return header.startswith(HeaderObject.GUID) * 2 + +Open = ASF diff --git a/resources/lib/mutagen/asf/__pycache__/__init__.cpython-35.pyc b/resources/lib/mutagen/asf/__pycache__/__init__.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..277e2c5fe677f3fb5deae56dc081e4c55d092175 GIT binary patch literal 8567 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 GqW=Or25PbA9rZQva?ZzcJ*OakzhFC{@G1 zp&Ck+!@Qx&hLoGCVXBRcDreM2R+Y1ABd5wawUJllyfXM?Mm5INMnRPexX!9tPBq5W z#)K+QDC1HsuWDoJF{+IzXPGJ^l|{zTOY zRof?3_wA@Um8e=&wMnT8LW#FdC#vpOwJE78?}#yQAW?N%)ecBixe=>6lc+kQYO_*x zb_WAe5|O|`RhyHlb33Zy*1W16;@$Ebdugq7c$@vWTFQF+-3OITyWO(gwyrntc;jv7 z^S0gI+H}0km78b0G41F|-E~Tax4+$LR~mLp*Bn;eMz1Rr7tXD{b5ZM-uJWC|Umlrd z9PZyCaX?vAp=^>q^q^)^59%n-NgiE{k}*lqnWz^2qjFX~hTfoW(Rrw!XH(F_Wpp7* z@Km8!f-W0ZwFzF9C)v}rqU6y(xZE%KDfU%uTJq8vwHe7DptNfTB@fA)kkgzujnRhaQZ_PK$r$RPsr0MN z-!qwim{UJQA?jpQ^GoWX$mxDT>C3d8pnOK^;|-$Lej)Czm5kL=&MP!3%{!gS9mmU~ z7oEz6m)opp*YR?{R%vt`Z`{VYUaPd7Qr^qDPUE(o1Z!RvMHM}Ptlr1#{QU(@)eYe$o?TuR>)Yosh^|o_*vr@eeQtqvdP8;Xut%`g5mQ7i>i-;}!0HB+48&#*Msj*eqs>X7@-^po_gHswQjW$1{a^0f&1K37SoLN#>rR zmT)aiH@&=l?dFw>YhIS8lIdlt4cE)H+IJl-jqsCMyaQ6u&Yim3b|8JcH;Y7>(?<8` zPKJ6FV;qqy;fS&d**OA6x>MhxiMZDL&X!n0$#yVRTars_Nly){-9@=?W86(rEuun{Bt=brRRJTnv|6Ttgqx73%sW4yM%c%Lcf;(BIugD!S`+M2o4w{rzQiqzGTkYmf zx(g?Dck&ghXS?4xS?lh5#qtdp7e6&hFzu{(Vi;Akvfna!eShmgqkbY8tJOE=@G;CNj`8m?7i?$-qM%3Yv{;rM_#bwn=FK z&f7g-^#~#|)S=*p_`rKPxorwUXVf7$!9yaH@lN~p>94+dy5ab}r@H3S>3OeEsaHvT+wfQLl zt)1uFA!8Oo8FA^0OUeU@6;``DB(_am_HwlcKgl%$cdKy;nP3h`))apQR8334THeGM`tQHIXJR zqte9X?xd*~z@aW7NyxG=SeBu2?oOcNJrOlN1DUu)GbuhatX`VcFX0Jq!nQYBwN3-x z!7JMKcRH1Z?<-&_cCA%~7ob@PASK*~CboL}s*Q^4Za4t?nm)p+N14!J=wnQNi3tY( zN`HyTmznf!N^%5PJ&uG|u7#v%X~m?v zw_jn>fT7@8>7X|YuW-Ftr|0R90Ivombk-OMM+Slu0+(mc`e(vv%JU1IQ*LVmFpQD* zsggV)78)~>0}z6Enr|bsbu?xt@Cfj|@D6J?n%`7e_}2RqN`ER2#^}DJ+P?pVJI>Jv z;A$YYBbaFzH*VH&A+Gv+ruw95s84XUR?0?QiDxKL{L+}0uQ}p$b8P9RHxo2`MM?#X zbq@S#`b96}Zn<8;`Min|;O$n=mCyxyMiQNL5yc%GE+17!#yD!s_>hzo_o9qtL*Yy2!e6atw2@8>em29kQa*PAu|jwkXtKd0K&wZuXQI*YOC35 zvp0db6Lq)Vbla6?)zPG*vrNt)@y5e})u?gMucLZk%TpNoPI8?2iz%wG7>>-V^wIqB z%r;89?~Pri>y=ZM`&i3>+~ z6}6DF5L4Wp5Eww4zT^iudbCsJv2$=1lLx6MsxKdwe8;;#ZFi= zCJeG9-NOio&yGJ_75S$(pWYY>X4}6Lp_wjzzg|q?Lxt#zAA*M-W1JC?&fvX0wLFY` z#6`>WCvV%%cWnE|5%Qe*?65Y!+AgUFQ&2-JWd!vGCWSv4O`X+NOH%@F`V}_hG?PUp z0%Y_YK02LXj%uaPGU4PyFERNB6T%S(!FMa}U4S5wDUt|cy^6BCI9$HCI8M9*HWh$T z;h#(vtGFHh*cZz7`Vy83jswdnnL^|L45{LZhMnH5mx7X8`eZ1b7;kD_-Gm zMpI08@}d54Bo+n)F!Vx;=LK{Qf4W|8R`ixdmaT=8HNSUCpP?H#nHquy;;a5P!WzLa zO@D@A0cA}EMj8bI>4LzfQh-1_k(M`K&;wda6qUVv1)%s0ZSl=Rr^Z@2zX+ZX2-^XFObheZllsk& zKv(RBv;tEKv_g2#3hxqK@v`gSz;_0LSu&!KWd8)p{uxKe{t>f#cnAAwfD)P|Ll0mb zZRmMrL$9E06fFD;$p&6oqTw-#rB6{phfMLI$LSHbnU{7Afq;J(=GxrVEI>A6G zGEGyNhWk3BW?Y)+N5u-OA}@*mZChkl;JElvr){-vTN(?^PJ^WQ%PPq}`QphJ8Hm-( z)SGQ@;^T0^u_tLZHVJo}j*PLD1-{8BVri=A&+d|K9lmH7fggRbM`wWdJBIs{5y>+A zlAxB3KX}I1wLJ!WAP?J~j!;bNOGHaiE8WdMYs{IA@RHK{^qCNyjab_M19avigj0ne zv9Xwehh)>deh~ID^?U4dv8EBIb0oq6VK33~!@^!^41}<=D+UNV$3`*mRJij2+rM|* zxrE357e@ql1ao4@qgktpBmDhb;Lge)p;FVT!AoKf|E37*Vv6shbQJKs!Dj9N zo@8Tp4LsX?)u?&T*CV2(>rGI5cEF?Q65ftbOY6uN2|DK@yri`J`GL+#gm9`5o;&DV zif};CNp$-22c7puG4LWm=R+vwaWd4O?4*DWYyEtI4)=)0G3V@VASd=nh&bDHYfpnX zm-mb~pGA~|k+K*=PYpPi_X0TIj<7%jiL>y6@J42WJLfrdF2>iNJP|zEHMVddAIBDk z)DrF5HMGb?Y!tNABSPFs>=`3VBSI{t$6h3`v?8qRZ1?lS6&hMELXakc=MF2K2m>4{ zCbUZy`eQC+=v->=Q<=|4(0`Gjas}@43i%ldDy)?bD&NN)=VxTM8G+=|^>IBJA>_Z;aD@hM2(E!|x9z_Ee78%W^CFkVPF?LjR)(;S_Zkww6KJQa>e9+TTXS z;@M#$`KfP#-6{r-k!B89~(dxufFdVBP+6I zo|P?A$u)D<3E_thgiEuWm%E&rZp{Ya`XEA`Q8${$!(FUzDZ?#z z@Ff*@aiYIW^+RQ>VPzQ`SG!Lx;EN}2azDhE&nr_w#`zXE2|(9#mXw6I-mETI?|)E<2$5&+*F~xP*>_4|Vtr4tM-G7A?VXXYBe3R93|dHvUgy0|$j-BLDyZ literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/asf/__pycache__/_objects.cpython-35.pyc b/resources/lib/mutagen/asf/__pycache__/_objects.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb0810d8909b8a57de791aca68842bd2ba3565d6 GIT binary patch literal 15903 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 NVtv;%exDt){tt%Az9#?x literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/asf/__pycache__/_util.cpython-35.pyc b/resources/lib/mutagen/asf/__pycache__/_util.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..661bff507dd00b7435a4f08bb07b58b7deed1cf4 GIT binary patch literal 10603 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" % ( + type(self).__name__, bytes2guid(self.GUID), self.objects) + + def pprint(self): + l = [] + l.append("%s(%s)" % (type(self).__name__, bytes2guid(self.GUID))) + for o in self.objects: + for e in o.pprint().splitlines(): + l.append(" " + e) + return "\n".join(l) + + +class UnknownObject(BaseObject): + """Unknown ASF object.""" + + def __init__(self, guid): + super(UnknownObject, self).__init__() + assert isinstance(guid, bytes) + self.GUID = guid + + +@BaseObject._register +class HeaderObject(BaseObject): + """ASF header.""" + + GUID = guid2bytes("75B22630-668E-11CF-A6D9-00AA0062CE6C") + + @classmethod + def parse_full(cls, asf, fileobj): + """Raises ASFHeaderError""" + + header = cls() + + size, num_objects = cls.parse_size(fileobj) + for i in xrange(num_objects): + guid, size = struct.unpack("<16sQ", fileobj.read(24)) + obj = BaseObject._get_object(guid) + data = fileobj.read(size - 24) + obj.parse(asf, data) + header.objects.append(obj) + + return header + + @classmethod + def parse_size(cls, fileobj): + """Returns (size, num_objects) + + Raises ASFHeaderError + """ + + header = fileobj.read(30) + if len(header) != 30 or header[:16] != HeaderObject.GUID: + raise ASFHeaderError("Not an ASF file.") + + return struct.unpack("= 0 + info = PaddingInfo(available - needed_size, content_size) + + # add padding + padding = info._get_padding(padding_func) + padding_obj.parse(asf, b"\x00" * padding) + data += padding_obj.render(asf) + num_objects += 1 + + data = (HeaderObject.GUID + + struct.pack(" 0: + texts.append(data[pos:end].decode("utf-16-le").strip(u"\x00")) + else: + texts.append(None) + pos = end + + for key, value in izip(self.NAMES, texts): + if value is not None: + value = ASFUnicodeAttribute(value=value) + asf._tags.setdefault(self.GUID, []).append((key, value)) + + def render(self, asf): + def render_text(name): + value = asf.to_content_description.get(name) + if value is not None: + return text_type(value).encode("utf-16-le") + b"\x00\x00" + else: + return b"" + + texts = [render_text(x) for x in self.NAMES] + data = struct.pack("= 0 + asf.info.length = max((length / 10000000.0) - (preroll / 1000.0), 0.0) + + +@BaseObject._register +class StreamPropertiesObject(BaseObject): + """Stream properties.""" + + GUID = guid2bytes("B7DC0791-A9B7-11CF-8EE6-00C00C205365") + + def parse(self, asf, data): + super(StreamPropertiesObject, self).parse(asf, data) + channels, sample_rate, bitrate = struct.unpack("H", int(s[19:23], 16)), + p(">Q", int(s[24:], 16))[2:], + ]) + + +def bytes2guid(s): + """Converts a serialized GUID to a text GUID""" + + assert isinstance(s, bytes) + + u = struct.unpack + v = [] + v.extend(u("HQ", s[8:10] + b"\x00\x00" + s[10:])) + return "%08X-%04X-%04X-%04X-%012X" % tuple(v) + + +# Names from http://windows.microsoft.com/en-za/windows7/c00d10d1-[0-9A-F]{1,4} +CODECS = { + 0x0000: u"Unknown Wave Format", + 0x0001: u"Microsoft PCM Format", + 0x0002: u"Microsoft ADPCM Format", + 0x0003: u"IEEE Float", + 0x0004: u"Compaq Computer VSELP", + 0x0005: u"IBM CVSD", + 0x0006: u"Microsoft CCITT A-Law", + 0x0007: u"Microsoft CCITT u-Law", + 0x0008: u"Microsoft DTS", + 0x0009: u"Microsoft DRM", + 0x000A: u"Windows Media Audio 9 Voice", + 0x000B: u"Windows Media Audio 10 Voice", + 0x000C: u"OGG Vorbis", + 0x000D: u"FLAC", + 0x000E: u"MOT AMR", + 0x000F: u"Nice Systems IMBE", + 0x0010: u"OKI ADPCM", + 0x0011: u"Intel IMA ADPCM", + 0x0012: u"Videologic MediaSpace ADPCM", + 0x0013: u"Sierra Semiconductor ADPCM", + 0x0014: u"Antex Electronics G.723 ADPCM", + 0x0015: u"DSP Solutions DIGISTD", + 0x0016: u"DSP Solutions DIGIFIX", + 0x0017: u"Dialogic OKI ADPCM", + 0x0018: u"MediaVision ADPCM", + 0x0019: u"Hewlett-Packard CU codec", + 0x001A: u"Hewlett-Packard Dynamic Voice", + 0x0020: u"Yamaha ADPCM", + 0x0021: u"Speech Compression SONARC", + 0x0022: u"DSP Group True Speech", + 0x0023: u"Echo Speech EchoSC1", + 0x0024: u"Ahead Inc. Audiofile AF36", + 0x0025: u"Audio Processing Technology APTX", + 0x0026: u"Ahead Inc. AudioFile AF10", + 0x0027: u"Aculab Prosody 1612", + 0x0028: u"Merging Technologies S.A. LRC", + 0x0030: u"Dolby Labs AC2", + 0x0031: u"Microsoft GSM 6.10", + 0x0032: u"Microsoft MSNAudio", + 0x0033: u"Antex Electronics ADPCME", + 0x0034: u"Control Resources VQLPC", + 0x0035: u"DSP Solutions Digireal", + 0x0036: u"DSP Solutions DigiADPCM", + 0x0037: u"Control Resources CR10", + 0x0038: u"Natural MicroSystems VBXADPCM", + 0x0039: u"Crystal Semiconductor IMA ADPCM", + 0x003A: u"Echo Speech EchoSC3", + 0x003B: u"Rockwell ADPCM", + 0x003C: u"Rockwell DigiTalk", + 0x003D: u"Xebec Multimedia Solutions", + 0x0040: u"Antex Electronics G.721 ADPCM", + 0x0041: u"Antex Electronics G.728 CELP", + 0x0042: u"Intel G.723", + 0x0043: u"Intel G.723.1", + 0x0044: u"Intel G.729 Audio", + 0x0045: u"Sharp G.726 Audio", + 0x0050: u"Microsoft MPEG-1", + 0x0052: u"InSoft RT24", + 0x0053: u"InSoft PAC", + 0x0055: u"MP3 - MPEG Layer III", + 0x0059: u"Lucent G.723", + 0x0060: u"Cirrus Logic", + 0x0061: u"ESS Technology ESPCM", + 0x0062: u"Voxware File-Mode", + 0x0063: u"Canopus Atrac", + 0x0064: u"APICOM G.726 ADPCM", + 0x0065: u"APICOM G.722 ADPCM", + 0x0066: u"Microsoft DSAT", + 0x0067: u"Microsoft DSAT Display", + 0x0069: u"Voxware Byte Aligned", + 0x0070: u"Voxware AC8", + 0x0071: u"Voxware AC10", + 0x0072: u"Voxware AC16", + 0x0073: u"Voxware AC20", + 0x0074: u"Voxware RT24 MetaVoice", + 0x0075: u"Voxware RT29 MetaSound", + 0x0076: u"Voxware RT29HW", + 0x0077: u"Voxware VR12", + 0x0078: u"Voxware VR18", + 0x0079: u"Voxware TQ40", + 0x007A: u"Voxware SC3", + 0x007B: u"Voxware SC3", + 0x0080: u"Softsound", + 0x0081: u"Voxware TQ60", + 0x0082: u"Microsoft MSRT24", + 0x0083: u"AT&T Labs G.729A", + 0x0084: u"Motion Pixels MVI MV12", + 0x0085: u"DataFusion Systems G.726", + 0x0086: u"DataFusion Systems GSM610", + 0x0088: u"Iterated Systems ISIAudio", + 0x0089: u"Onlive", + 0x008A: u"Multitude FT SX20", + 0x008B: u"Infocom ITS ACM G.721", + 0x008C: u"Convedia G.729", + 0x008D: u"Congruency Audio", + 0x0091: u"Siemens Business Communications SBC24", + 0x0092: u"Sonic Foundry Dolby AC3 SPDIF", + 0x0093: u"MediaSonic G.723", + 0x0094: u"Aculab Prosody 8KBPS", + 0x0097: u"ZyXEL ADPCM", + 0x0098: u"Philips LPCBB", + 0x0099: u"Studer Professional Audio AG Packed", + 0x00A0: u"Malden Electronics PHONYTALK", + 0x00A1: u"Racal Recorder GSM", + 0x00A2: u"Racal Recorder G720.a", + 0x00A3: u"Racal Recorder G723.1", + 0x00A4: u"Racal Recorder Tetra ACELP", + 0x00B0: u"NEC AAC", + 0x00FF: u"CoreAAC Audio", + 0x0100: u"Rhetorex ADPCM", + 0x0101: u"BeCubed Software IRAT", + 0x0111: u"Vivo G.723", + 0x0112: u"Vivo Siren", + 0x0120: u"Philips CELP", + 0x0121: u"Philips Grundig", + 0x0123: u"Digital G.723", + 0x0125: u"Sanyo ADPCM", + 0x0130: u"Sipro Lab Telecom ACELP.net", + 0x0131: u"Sipro Lab Telecom ACELP.4800", + 0x0132: u"Sipro Lab Telecom ACELP.8V3", + 0x0133: u"Sipro Lab Telecom ACELP.G.729", + 0x0134: u"Sipro Lab Telecom ACELP.G.729A", + 0x0135: u"Sipro Lab Telecom ACELP.KELVIN", + 0x0136: u"VoiceAge AMR", + 0x0140: u"Dictaphone G.726 ADPCM", + 0x0141: u"Dictaphone CELP68", + 0x0142: u"Dictaphone CELP54", + 0x0150: u"Qualcomm PUREVOICE", + 0x0151: u"Qualcomm HALFRATE", + 0x0155: u"Ring Zero Systems TUBGSM", + 0x0160: u"Windows Media Audio Standard", + 0x0161: u"Windows Media Audio 9 Standard", + 0x0162: u"Windows Media Audio 9 Professional", + 0x0163: u"Windows Media Audio 9 Lossless", + 0x0164: u"Windows Media Audio Pro over SPDIF", + 0x0170: u"Unisys NAP ADPCM", + 0x0171: u"Unisys NAP ULAW", + 0x0172: u"Unisys NAP ALAW", + 0x0173: u"Unisys NAP 16K", + 0x0174: u"Sycom ACM SYC008", + 0x0175: u"Sycom ACM SYC701 G725", + 0x0176: u"Sycom ACM SYC701 CELP54", + 0x0177: u"Sycom ACM SYC701 CELP68", + 0x0178: u"Knowledge Adventure ADPCM", + 0x0180: u"Fraunhofer IIS MPEG-2 AAC", + 0x0190: u"Digital Theater Systems DTS", + 0x0200: u"Creative Labs ADPCM", + 0x0202: u"Creative Labs FastSpeech8", + 0x0203: u"Creative Labs FastSpeech10", + 0x0210: u"UHER informatic GmbH ADPCM", + 0x0215: u"Ulead DV Audio", + 0x0216: u"Ulead DV Audio", + 0x0220: u"Quarterdeck", + 0x0230: u"I-link Worldwide ILINK VC", + 0x0240: u"Aureal Semiconductor RAW SPORT", + 0x0249: u"Generic Passthru", + 0x0250: u"Interactive Products HSX", + 0x0251: u"Interactive Products RPELP", + 0x0260: u"Consistent Software CS2", + 0x0270: u"Sony SCX", + 0x0271: u"Sony SCY", + 0x0272: u"Sony ATRAC3", + 0x0273: u"Sony SPC", + 0x0280: u"Telum Audio", + 0x0281: u"Telum IA Audio", + 0x0285: u"Norcom Voice Systems ADPCM", + 0x0300: u"Fujitsu TOWNS SND", + 0x0350: u"Micronas SC4 Speech", + 0x0351: u"Micronas CELP833", + 0x0400: u"Brooktree BTV Digital", + 0x0401: u"Intel Music Coder", + 0x0402: u"Intel Audio", + 0x0450: u"QDesign Music", + 0x0500: u"On2 AVC0 Audio", + 0x0501: u"On2 AVC1 Audio", + 0x0680: u"AT&T Labs VME VMPCM", + 0x0681: u"AT&T Labs TPC", + 0x08AE: u"ClearJump Lightwave Lossless", + 0x1000: u"Olivetti GSM", + 0x1001: u"Olivetti ADPCM", + 0x1002: u"Olivetti CELP", + 0x1003: u"Olivetti SBC", + 0x1004: u"Olivetti OPR", + 0x1100: u"Lernout & Hauspie", + 0x1101: u"Lernout & Hauspie CELP", + 0x1102: u"Lernout & Hauspie SBC8", + 0x1103: u"Lernout & Hauspie SBC12", + 0x1104: u"Lernout & Hauspie SBC16", + 0x1400: u"Norris Communication", + 0x1401: u"ISIAudio", + 0x1500: u"AT&T Labs Soundspace Music Compression", + 0x1600: u"Microsoft MPEG ADTS AAC", + 0x1601: u"Microsoft MPEG RAW AAC", + 0x1608: u"Nokia MPEG ADTS AAC", + 0x1609: u"Nokia MPEG RAW AAC", + 0x181C: u"VoxWare MetaVoice RT24", + 0x1971: u"Sonic Foundry Lossless", + 0x1979: u"Innings Telecom ADPCM", + 0x1FC4: u"NTCSoft ALF2CD ACM", + 0x2000: u"Dolby AC3", + 0x2001: u"DTS", + 0x4143: u"Divio AAC", + 0x4201: u"Nokia Adaptive Multi-Rate", + 0x4243: u"Divio G.726", + 0x4261: u"ITU-T H.261", + 0x4263: u"ITU-T H.263", + 0x4264: u"ITU-T H.264", + 0x674F: u"Ogg Vorbis Mode 1", + 0x6750: u"Ogg Vorbis Mode 2", + 0x6751: u"Ogg Vorbis Mode 3", + 0x676F: u"Ogg Vorbis Mode 1+", + 0x6770: u"Ogg Vorbis Mode 2+", + 0x6771: u"Ogg Vorbis Mode 3+", + 0x7000: u"3COM NBX Audio", + 0x706D: u"FAAD AAC Audio", + 0x77A1: u"True Audio Lossless Audio", + 0x7A21: u"GSM-AMR CBR 3GPP Audio", + 0x7A22: u"GSM-AMR VBR 3GPP Audio", + 0xA100: u"Comverse Infosys G723.1", + 0xA101: u"Comverse Infosys AVQSBC", + 0xA102: u"Comverse Infosys SBC", + 0xA103: u"Symbol Technologies G729a", + 0xA104: u"VoiceAge AMR WB", + 0xA105: u"Ingenient Technologies G.726", + 0xA106: u"ISO/MPEG-4 Advanced Audio Coding (AAC)", + 0xA107: u"Encore Software Ltd's G.726", + 0xA108: u"ZOLL Medical Corporation ASAO", + 0xA109: u"Speex Voice", + 0xA10A: u"Vianix MASC Speech Compression", + 0xA10B: u"Windows Media 9 Spectrum Analyzer Output", + 0xA10C: u"Media Foundation Spectrum Analyzer Output", + 0xA10D: u"GSM 6.10 (Full-Rate) Speech", + 0xA10E: u"GSM 6.20 (Half-Rate) Speech", + 0xA10F: u"GSM 6.60 (Enchanced Full-Rate) Speech", + 0xA110: u"GSM 6.90 (Adaptive Multi-Rate) Speech", + 0xA111: u"GSM Adaptive Multi-Rate WideBand Speech", + 0xA112: u"Polycom G.722", + 0xA113: u"Polycom G.728", + 0xA114: u"Polycom G.729a", + 0xA115: u"Polycom Siren", + 0xA116: u"Global IP Sound ILBC", + 0xA117: u"Radio Time Time Shifted Radio", + 0xA118: u"Nice Systems ACA", + 0xA119: u"Nice Systems ADPCM", + 0xA11A: u"Vocord Group ITU-T G.721", + 0xA11B: u"Vocord Group ITU-T G.726", + 0xA11C: u"Vocord Group ITU-T G.722.1", + 0xA11D: u"Vocord Group ITU-T G.728", + 0xA11E: u"Vocord Group ITU-T G.729", + 0xA11F: u"Vocord Group ITU-T G.729a", + 0xA120: u"Vocord Group ITU-T G.723.1", + 0xA121: u"Vocord Group LBC", + 0xA122: u"Nice G.728", + 0xA123: u"France Telecom G.729 ACM Audio", + 0xA124: u"CODIAN Audio", + 0xCC12: u"Intel YUV12 Codec", + 0xCFCC: u"Digital Processing Systems Perception Motion JPEG", + 0xD261: u"DEC H.261", + 0xD263: u"DEC H.263", + 0xFFFE: u"Extensible Wave Format", + 0xFFFF: u"Unregistered", +} diff --git a/resources/lib/mutagen/easyid3.py b/resources/lib/mutagen/easyid3.py new file mode 100644 index 00000000..f8dd2de0 --- /dev/null +++ b/resources/lib/mutagen/easyid3.py @@ -0,0 +1,534 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""Easier access to ID3 tags. + +EasyID3 is a wrapper around mutagen.id3.ID3 to make ID3 tags appear +more like Vorbis or APEv2 tags. +""" + +import mutagen.id3 + +from ._compat import iteritems, text_type, PY2 +from mutagen import Metadata +from mutagen._util import DictMixin, dict_match +from mutagen.id3 import ID3, error, delete, ID3FileType + + +__all__ = ['EasyID3', 'Open', 'delete'] + + +class EasyID3KeyError(KeyError, ValueError, error): + """Raised when trying to get/set an invalid key. + + Subclasses both KeyError and ValueError for API compatibility, + catching KeyError is preferred. + """ + + +class EasyID3(DictMixin, Metadata): + """A file with an ID3 tag. + + Like Vorbis comments, EasyID3 keys are case-insensitive ASCII + strings. Only a subset of ID3 frames are supported by default. Use + EasyID3.RegisterKey and its wrappers to support more. + + You can also set the GetFallback, SetFallback, and DeleteFallback + to generic key getter/setter/deleter functions, which are called + if no specific handler is registered for a key. Additionally, + ListFallback can be used to supply an arbitrary list of extra + keys. These can be set on EasyID3 or on individual instances after + creation. + + To use an EasyID3 class with mutagen.mp3.MP3:: + + from mutagen.mp3 import EasyMP3 as MP3 + MP3(filename) + + Because many of the attributes are constructed on the fly, things + like the following will not work:: + + ezid3["performer"].append("Joe") + + Instead, you must do:: + + values = ezid3["performer"] + values.append("Joe") + ezid3["performer"] = values + + """ + + Set = {} + Get = {} + Delete = {} + List = {} + + # For compatibility. + valid_keys = Get + + GetFallback = None + SetFallback = None + DeleteFallback = None + ListFallback = None + + @classmethod + def RegisterKey(cls, key, + getter=None, setter=None, deleter=None, lister=None): + """Register a new key mapping. + + A key mapping is four functions, a getter, setter, deleter, + and lister. The key may be either a string or a glob pattern. + + The getter, deleted, and lister receive an ID3 instance and + the requested key name. The setter also receives the desired + value, which will be a list of strings. + + The getter, setter, and deleter are used to implement __getitem__, + __setitem__, and __delitem__. + + The lister is used to implement keys(). It should return a + list of keys that are actually in the ID3 instance, provided + by its associated getter. + """ + key = key.lower() + if getter is not None: + cls.Get[key] = getter + if setter is not None: + cls.Set[key] = setter + if deleter is not None: + cls.Delete[key] = deleter + if lister is not None: + cls.List[key] = lister + + @classmethod + def RegisterTextKey(cls, key, frameid): + """Register a text key. + + If the key you need to register is a simple one-to-one mapping + of ID3 frame name to EasyID3 key, then you can use this + function:: + + EasyID3.RegisterTextKey("title", "TIT2") + """ + def getter(id3, key): + return list(id3[frameid]) + + def setter(id3, key, value): + try: + frame = id3[frameid] + except KeyError: + id3.add(mutagen.id3.Frames[frameid](encoding=3, text=value)) + else: + frame.encoding = 3 + frame.text = value + + def deleter(id3, key): + del(id3[frameid]) + + cls.RegisterKey(key, getter, setter, deleter) + + @classmethod + def RegisterTXXXKey(cls, key, desc): + """Register a user-defined text frame key. + + Some ID3 tags are stored in TXXX frames, which allow a + freeform 'description' which acts as a subkey, + e.g. TXXX:BARCODE.:: + + EasyID3.RegisterTXXXKey('barcode', 'BARCODE'). + """ + frameid = "TXXX:" + desc + + def getter(id3, key): + return list(id3[frameid]) + + def setter(id3, key, value): + try: + frame = id3[frameid] + except KeyError: + enc = 0 + # Store 8859-1 if we can, per MusicBrainz spec. + for v in value: + if v and max(v) > u'\x7f': + enc = 3 + break + + id3.add(mutagen.id3.TXXX(encoding=enc, text=value, desc=desc)) + else: + frame.text = value + + def deleter(id3, key): + del(id3[frameid]) + + cls.RegisterKey(key, getter, setter, deleter) + + def __init__(self, filename=None): + self.__id3 = ID3() + if filename is not None: + self.load(filename) + + load = property(lambda s: s.__id3.load, + lambda s, v: setattr(s.__id3, 'load', v)) + + def save(self, *args, **kwargs): + # ignore v2_version until we support 2.3 here + kwargs.pop("v2_version", None) + self.__id3.save(*args, **kwargs) + + delete = property(lambda s: s.__id3.delete, + lambda s, v: setattr(s.__id3, 'delete', v)) + + filename = property(lambda s: s.__id3.filename, + lambda s, fn: setattr(s.__id3, 'filename', fn)) + + size = property(lambda s: s.__id3.size, + lambda s, fn: setattr(s.__id3, 'size', s)) + + def __getitem__(self, key): + key = key.lower() + func = dict_match(self.Get, key, self.GetFallback) + if func is not None: + return func(self.__id3, key) + else: + raise EasyID3KeyError("%r is not a valid key" % key) + + def __setitem__(self, key, value): + key = key.lower() + if PY2: + if isinstance(value, basestring): + value = [value] + else: + if isinstance(value, text_type): + value = [value] + func = dict_match(self.Set, key, self.SetFallback) + if func is not None: + return func(self.__id3, key, value) + else: + raise EasyID3KeyError("%r is not a valid key" % key) + + def __delitem__(self, key): + key = key.lower() + func = dict_match(self.Delete, key, self.DeleteFallback) + if func is not None: + return func(self.__id3, key) + else: + raise EasyID3KeyError("%r is not a valid key" % key) + + def keys(self): + keys = [] + for key in self.Get.keys(): + if key in self.List: + keys.extend(self.List[key](self.__id3, key)) + elif key in self: + keys.append(key) + if self.ListFallback is not None: + keys.extend(self.ListFallback(self.__id3, "")) + return keys + + def pprint(self): + """Print tag key=value pairs.""" + strings = [] + for key in sorted(self.keys()): + values = self[key] + for value in values: + strings.append("%s=%s" % (key, value)) + return "\n".join(strings) + + +Open = EasyID3 + + +def genre_get(id3, key): + return id3["TCON"].genres + + +def genre_set(id3, key, value): + try: + frame = id3["TCON"] + except KeyError: + id3.add(mutagen.id3.TCON(encoding=3, text=value)) + else: + frame.encoding = 3 + frame.genres = value + + +def genre_delete(id3, key): + del(id3["TCON"]) + + +def date_get(id3, key): + return [stamp.text for stamp in id3["TDRC"].text] + + +def date_set(id3, key, value): + id3.add(mutagen.id3.TDRC(encoding=3, text=value)) + + +def date_delete(id3, key): + del(id3["TDRC"]) + + +def original_date_get(id3, key): + return [stamp.text for stamp in id3["TDOR"].text] + + +def original_date_set(id3, key, value): + id3.add(mutagen.id3.TDOR(encoding=3, text=value)) + + +def original_date_delete(id3, key): + del(id3["TDOR"]) + + +def performer_get(id3, key): + people = [] + wanted_role = key.split(":", 1)[1] + try: + mcl = id3["TMCL"] + except KeyError: + raise KeyError(key) + for role, person in mcl.people: + if role == wanted_role: + people.append(person) + if people: + return people + else: + raise KeyError(key) + + +def performer_set(id3, key, value): + wanted_role = key.split(":", 1)[1] + try: + mcl = id3["TMCL"] + except KeyError: + mcl = mutagen.id3.TMCL(encoding=3, people=[]) + id3.add(mcl) + mcl.encoding = 3 + people = [p for p in mcl.people if p[0] != wanted_role] + for v in value: + people.append((wanted_role, v)) + mcl.people = people + + +def performer_delete(id3, key): + wanted_role = key.split(":", 1)[1] + try: + mcl = id3["TMCL"] + except KeyError: + raise KeyError(key) + people = [p for p in mcl.people if p[0] != wanted_role] + if people == mcl.people: + raise KeyError(key) + elif people: + mcl.people = people + else: + del(id3["TMCL"]) + + +def performer_list(id3, key): + try: + mcl = id3["TMCL"] + except KeyError: + return [] + else: + return list(set("performer:" + p[0] for p in mcl.people)) + + +def musicbrainz_trackid_get(id3, key): + return [id3["UFID:http://musicbrainz.org"].data.decode('ascii')] + + +def musicbrainz_trackid_set(id3, key, value): + if len(value) != 1: + raise ValueError("only one track ID may be set per song") + value = value[0].encode('ascii') + try: + frame = id3["UFID:http://musicbrainz.org"] + except KeyError: + frame = mutagen.id3.UFID(owner="http://musicbrainz.org", data=value) + id3.add(frame) + else: + frame.data = value + + +def musicbrainz_trackid_delete(id3, key): + del(id3["UFID:http://musicbrainz.org"]) + + +def website_get(id3, key): + urls = [frame.url for frame in id3.getall("WOAR")] + if urls: + return urls + else: + raise EasyID3KeyError(key) + + +def website_set(id3, key, value): + id3.delall("WOAR") + for v in value: + id3.add(mutagen.id3.WOAR(url=v)) + + +def website_delete(id3, key): + id3.delall("WOAR") + + +def gain_get(id3, key): + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + raise EasyID3KeyError(key) + else: + return [u"%+f dB" % frame.gain] + + +def gain_set(id3, key, value): + if len(value) != 1: + raise ValueError( + "there must be exactly one gain value, not %r.", value) + gain = float(value[0].split()[0]) + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1) + id3.add(frame) + frame.gain = gain + + +def gain_delete(id3, key): + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + pass + else: + if frame.peak: + frame.gain = 0.0 + else: + del(id3["RVA2:" + key[11:-5]]) + + +def peak_get(id3, key): + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + raise EasyID3KeyError(key) + else: + return [u"%f" % frame.peak] + + +def peak_set(id3, key, value): + if len(value) != 1: + raise ValueError( + "there must be exactly one peak value, not %r.", value) + peak = float(value[0]) + if peak >= 2 or peak < 0: + raise ValueError("peak must be => 0 and < 2.") + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + frame = mutagen.id3.RVA2(desc=key[11:-5], gain=0, peak=0, channel=1) + id3.add(frame) + frame.peak = peak + + +def peak_delete(id3, key): + try: + frame = id3["RVA2:" + key[11:-5]] + except KeyError: + pass + else: + if frame.gain: + frame.peak = 0.0 + else: + del(id3["RVA2:" + key[11:-5]]) + + +def peakgain_list(id3, key): + keys = [] + for frame in id3.getall("RVA2"): + keys.append("replaygain_%s_gain" % frame.desc) + keys.append("replaygain_%s_peak" % frame.desc) + return keys + +for frameid, key in iteritems({ + "TALB": "album", + "TBPM": "bpm", + "TCMP": "compilation", # iTunes extension + "TCOM": "composer", + "TCOP": "copyright", + "TENC": "encodedby", + "TEXT": "lyricist", + "TLEN": "length", + "TMED": "media", + "TMOO": "mood", + "TIT2": "title", + "TIT3": "version", + "TPE1": "artist", + "TPE2": "performer", + "TPE3": "conductor", + "TPE4": "arranger", + "TPOS": "discnumber", + "TPUB": "organization", + "TRCK": "tracknumber", + "TOLY": "author", + "TSO2": "albumartistsort", # iTunes extension + "TSOA": "albumsort", + "TSOC": "composersort", # iTunes extension + "TSOP": "artistsort", + "TSOT": "titlesort", + "TSRC": "isrc", + "TSST": "discsubtitle", + "TLAN": "language", +}): + EasyID3.RegisterTextKey(key, frameid) + +EasyID3.RegisterKey("genre", genre_get, genre_set, genre_delete) +EasyID3.RegisterKey("date", date_get, date_set, date_delete) +EasyID3.RegisterKey("originaldate", original_date_get, original_date_set, + original_date_delete) +EasyID3.RegisterKey( + "performer:*", performer_get, performer_set, performer_delete, + performer_list) +EasyID3.RegisterKey("musicbrainz_trackid", musicbrainz_trackid_get, + musicbrainz_trackid_set, musicbrainz_trackid_delete) +EasyID3.RegisterKey("website", website_get, website_set, website_delete) +EasyID3.RegisterKey( + "replaygain_*_gain", gain_get, gain_set, gain_delete, peakgain_list) +EasyID3.RegisterKey("replaygain_*_peak", peak_get, peak_set, peak_delete) + +# At various times, information for this came from +# http://musicbrainz.org/docs/specs/metadata_tags.html +# http://bugs.musicbrainz.org/ticket/1383 +# http://musicbrainz.org/doc/MusicBrainzTag +for desc, key in iteritems({ + u"MusicBrainz Artist Id": "musicbrainz_artistid", + u"MusicBrainz Album Id": "musicbrainz_albumid", + u"MusicBrainz Album Artist Id": "musicbrainz_albumartistid", + u"MusicBrainz TRM Id": "musicbrainz_trmid", + u"MusicIP PUID": "musicip_puid", + u"MusicMagic Fingerprint": "musicip_fingerprint", + u"MusicBrainz Album Status": "musicbrainz_albumstatus", + u"MusicBrainz Album Type": "musicbrainz_albumtype", + u"MusicBrainz Album Release Country": "releasecountry", + u"MusicBrainz Disc Id": "musicbrainz_discid", + u"ASIN": "asin", + u"ALBUMARTISTSORT": "albumartistsort", + u"BARCODE": "barcode", + u"CATALOGNUMBER": "catalognumber", + u"MusicBrainz Release Track Id": "musicbrainz_releasetrackid", + u"MusicBrainz Release Group Id": "musicbrainz_releasegroupid", + u"MusicBrainz Work Id": "musicbrainz_workid", + u"Acoustid Fingerprint": "acoustid_fingerprint", + u"Acoustid Id": "acoustid_id", +}): + EasyID3.RegisterTXXXKey(key, desc) + + +class EasyID3FileType(ID3FileType): + """Like ID3FileType, but uses EasyID3 for tags.""" + ID3 = EasyID3 diff --git a/resources/lib/mutagen/easymp4.py b/resources/lib/mutagen/easymp4.py new file mode 100644 index 00000000..b965f37d --- /dev/null +++ b/resources/lib/mutagen/easymp4.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2009 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +from mutagen import Metadata +from mutagen._util import DictMixin, dict_match +from mutagen.mp4 import MP4, MP4Tags, error, delete +from ._compat import PY2, text_type, PY3 + + +__all__ = ["EasyMP4Tags", "EasyMP4", "delete", "error"] + + +class EasyMP4KeyError(error, KeyError, ValueError): + pass + + +class EasyMP4Tags(DictMixin, Metadata): + """A file with MPEG-4 iTunes metadata. + + Like Vorbis comments, EasyMP4Tags keys are case-insensitive ASCII + strings, and values are a list of Unicode strings (and these lists + are always of length 0 or 1). + + If you need access to the full MP4 metadata feature set, you should use + MP4, not EasyMP4. + """ + + Set = {} + Get = {} + Delete = {} + List = {} + + def __init__(self, *args, **kwargs): + self.__mp4 = MP4Tags(*args, **kwargs) + self.load = self.__mp4.load + self.save = self.__mp4.save + self.delete = self.__mp4.delete + self._padding = self.__mp4._padding + + filename = property(lambda s: s.__mp4.filename, + lambda s, fn: setattr(s.__mp4, 'filename', fn)) + + @classmethod + def RegisterKey(cls, key, + getter=None, setter=None, deleter=None, lister=None): + """Register a new key mapping. + + A key mapping is four functions, a getter, setter, deleter, + and lister. The key may be either a string or a glob pattern. + + The getter, deleted, and lister receive an MP4Tags instance + and the requested key name. The setter also receives the + desired value, which will be a list of strings. + + The getter, setter, and deleter are used to implement __getitem__, + __setitem__, and __delitem__. + + The lister is used to implement keys(). It should return a + list of keys that are actually in the MP4 instance, provided + by its associated getter. + """ + key = key.lower() + if getter is not None: + cls.Get[key] = getter + if setter is not None: + cls.Set[key] = setter + if deleter is not None: + cls.Delete[key] = deleter + if lister is not None: + cls.List[key] = lister + + @classmethod + def RegisterTextKey(cls, key, atomid): + """Register a text key. + + If the key you need to register is a simple one-to-one mapping + of MP4 atom name to EasyMP4Tags key, then you can use this + function:: + + EasyMP4Tags.RegisterTextKey("artist", "\xa9ART") + """ + def getter(tags, key): + return tags[atomid] + + def setter(tags, key, value): + tags[atomid] = value + + def deleter(tags, key): + del(tags[atomid]) + + cls.RegisterKey(key, getter, setter, deleter) + + @classmethod + def RegisterIntKey(cls, key, atomid, min_value=0, max_value=(2 ** 16) - 1): + """Register a scalar integer key. + """ + + def getter(tags, key): + return list(map(text_type, tags[atomid])) + + def setter(tags, key, value): + clamp = lambda x: int(min(max(min_value, x), max_value)) + tags[atomid] = [clamp(v) for v in map(int, value)] + + def deleter(tags, key): + del(tags[atomid]) + + cls.RegisterKey(key, getter, setter, deleter) + + @classmethod + def RegisterIntPairKey(cls, key, atomid, min_value=0, + max_value=(2 ** 16) - 1): + def getter(tags, key): + ret = [] + for (track, total) in tags[atomid]: + if total: + ret.append(u"%d/%d" % (track, total)) + else: + ret.append(text_type(track)) + return ret + + def setter(tags, key, value): + clamp = lambda x: int(min(max(min_value, x), max_value)) + data = [] + for v in value: + try: + tracks, total = v.split("/") + tracks = clamp(int(tracks)) + total = clamp(int(total)) + except (ValueError, TypeError): + tracks = clamp(int(v)) + total = min_value + data.append((tracks, total)) + tags[atomid] = data + + def deleter(tags, key): + del(tags[atomid]) + + cls.RegisterKey(key, getter, setter, deleter) + + @classmethod + def RegisterFreeformKey(cls, key, name, mean="com.apple.iTunes"): + """Register a text key. + + If the key you need to register is a simple one-to-one mapping + of MP4 freeform atom (----) and name to EasyMP4Tags key, then + you can use this function:: + + EasyMP4Tags.RegisterFreeformKey( + "musicbrainz_artistid", "MusicBrainz Artist Id") + """ + atomid = "----:" + mean + ":" + name + + def getter(tags, key): + return [s.decode("utf-8", "replace") for s in tags[atomid]] + + def setter(tags, key, value): + encoded = [] + for v in value: + if not isinstance(v, text_type): + if PY3: + raise TypeError("%r not str" % v) + v = v.decode("utf-8") + encoded.append(v.encode("utf-8")) + tags[atomid] = encoded + + def deleter(tags, key): + del(tags[atomid]) + + cls.RegisterKey(key, getter, setter, deleter) + + def __getitem__(self, key): + key = key.lower() + func = dict_match(self.Get, key) + if func is not None: + return func(self.__mp4, key) + else: + raise EasyMP4KeyError("%r is not a valid key" % key) + + def __setitem__(self, key, value): + key = key.lower() + + if PY2: + if isinstance(value, basestring): + value = [value] + else: + if isinstance(value, text_type): + value = [value] + + func = dict_match(self.Set, key) + if func is not None: + return func(self.__mp4, key, value) + else: + raise EasyMP4KeyError("%r is not a valid key" % key) + + def __delitem__(self, key): + key = key.lower() + func = dict_match(self.Delete, key) + if func is not None: + return func(self.__mp4, key) + else: + raise EasyMP4KeyError("%r is not a valid key" % key) + + def keys(self): + keys = [] + for key in self.Get.keys(): + if key in self.List: + keys.extend(self.List[key](self.__mp4, key)) + elif key in self: + keys.append(key) + return keys + + def pprint(self): + """Print tag key=value pairs.""" + strings = [] + for key in sorted(self.keys()): + values = self[key] + for value in values: + strings.append("%s=%s" % (key, value)) + return "\n".join(strings) + +for atomid, key in { + '\xa9nam': 'title', + '\xa9alb': 'album', + '\xa9ART': 'artist', + 'aART': 'albumartist', + '\xa9day': 'date', + '\xa9cmt': 'comment', + 'desc': 'description', + '\xa9grp': 'grouping', + '\xa9gen': 'genre', + 'cprt': 'copyright', + 'soal': 'albumsort', + 'soaa': 'albumartistsort', + 'soar': 'artistsort', + 'sonm': 'titlesort', + 'soco': 'composersort', +}.items(): + EasyMP4Tags.RegisterTextKey(key, atomid) + +for name, key in { + 'MusicBrainz Artist Id': 'musicbrainz_artistid', + 'MusicBrainz Track Id': 'musicbrainz_trackid', + 'MusicBrainz Album Id': 'musicbrainz_albumid', + 'MusicBrainz Album Artist Id': 'musicbrainz_albumartistid', + 'MusicIP PUID': 'musicip_puid', + 'MusicBrainz Album Status': 'musicbrainz_albumstatus', + 'MusicBrainz Album Type': 'musicbrainz_albumtype', + 'MusicBrainz Release Country': 'releasecountry', +}.items(): + EasyMP4Tags.RegisterFreeformKey(key, name) + +for name, key in { + "tmpo": "bpm", +}.items(): + EasyMP4Tags.RegisterIntKey(key, name) + +for name, key in { + "trkn": "tracknumber", + "disk": "discnumber", +}.items(): + EasyMP4Tags.RegisterIntPairKey(key, name) + + +class EasyMP4(MP4): + """Like :class:`MP4 `, + but uses :class:`EasyMP4Tags` for tags. + + :ivar info: :class:`MP4Info ` + :ivar tags: :class:`EasyMP4Tags` + """ + + MP4Tags = EasyMP4Tags + + Get = EasyMP4Tags.Get + Set = EasyMP4Tags.Set + Delete = EasyMP4Tags.Delete + List = EasyMP4Tags.List + RegisterTextKey = EasyMP4Tags.RegisterTextKey + RegisterKey = EasyMP4Tags.RegisterKey diff --git a/resources/lib/mutagen/flac.py b/resources/lib/mutagen/flac.py new file mode 100644 index 00000000..e6cd1cf7 --- /dev/null +++ b/resources/lib/mutagen/flac.py @@ -0,0 +1,876 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""Read and write FLAC Vorbis comments and stream information. + +Read more about FLAC at http://flac.sourceforge.net. + +FLAC supports arbitrary metadata blocks. The two most interesting ones +are the FLAC stream information block, and the Vorbis comment block; +these are also the only ones Mutagen can currently read. + +This module does not handle Ogg FLAC files. + +Based off documentation available at +http://flac.sourceforge.net/format.html +""" + +__all__ = ["FLAC", "Open", "delete"] + +import struct +from ._vorbis import VCommentDict +import mutagen + +from ._compat import cBytesIO, endswith, chr_, xrange +from mutagen._util import resize_bytes, MutagenError, get_size +from mutagen._tags import PaddingInfo +from mutagen.id3 import BitPaddedInt +from functools import reduce + + +class error(IOError, MutagenError): + pass + + +class FLACNoHeaderError(error): + pass + + +class FLACVorbisError(ValueError, error): + pass + + +def to_int_be(data): + """Convert an arbitrarily-long string to a long using big-endian + byte order.""" + return reduce(lambda a, b: (a << 8) + b, bytearray(data), 0) + + +class StrictFileObject(object): + """Wraps a file-like object and raises an exception if the requested + amount of data to read isn't returned.""" + + def __init__(self, fileobj): + self._fileobj = fileobj + for m in ["close", "tell", "seek", "write", "name"]: + if hasattr(fileobj, m): + setattr(self, m, getattr(fileobj, m)) + + def read(self, size=-1): + data = self._fileobj.read(size) + if size >= 0 and len(data) != size: + raise error("file said %d bytes, read %d bytes" % ( + size, len(data))) + return data + + def tryread(self, *args): + return self._fileobj.read(*args) + + +class MetadataBlock(object): + """A generic block of FLAC metadata. + + This class is extended by specific used as an ancestor for more specific + blocks, and also as a container for data blobs of unknown blocks. + + Attributes: + + * data -- raw binary data for this block + """ + + _distrust_size = False + """For block types setting this, we don't trust the size field and + use the size of the content instead.""" + + _invalid_overflow_size = -1 + """In case the real size was bigger than what is representable by the + 24 bit size field, we save the wrong specified size here. This can + only be set if _distrust_size is True""" + + _MAX_SIZE = 2 ** 24 - 1 + + def __init__(self, data): + """Parse the given data string or file-like as a metadata block. + The metadata header should not be included.""" + if data is not None: + if not isinstance(data, StrictFileObject): + if isinstance(data, bytes): + data = cBytesIO(data) + elif not hasattr(data, 'read'): + raise TypeError( + "StreamInfo requires string data or a file-like") + data = StrictFileObject(data) + self.load(data) + + def load(self, data): + self.data = data.read() + + def write(self): + return self.data + + @classmethod + def _writeblock(cls, block, is_last=False): + """Returns the block content + header. + + Raises error. + """ + + data = bytearray() + code = (block.code | 128) if is_last else block.code + datum = block.write() + size = len(datum) + if size > cls._MAX_SIZE: + if block._distrust_size and block._invalid_overflow_size != -1: + # The original size of this block was (1) wrong and (2) + # the real size doesn't allow us to save the file + # according to the spec (too big for 24 bit uint). Instead + # simply write back the original wrong size.. at least + # we don't make the file more "broken" as it is. + size = block._invalid_overflow_size + else: + raise error("block is too long to write") + assert not size > cls._MAX_SIZE + length = struct.pack(">I", size)[-3:] + data.append(code) + data += length + data += datum + return data + + @classmethod + def _writeblocks(cls, blocks, available, cont_size, padding_func): + """Render metadata block as a byte string.""" + + # write everything except padding + data = bytearray() + for block in blocks: + if isinstance(block, Padding): + continue + data += cls._writeblock(block) + blockssize = len(data) + + # take the padding overhead into account. we always add one + # to make things simple. + padding_block = Padding() + blockssize += len(cls._writeblock(padding_block)) + + # finally add a padding block + info = PaddingInfo(available - blockssize, cont_size) + padding_block.length = min(info._get_padding(padding_func), + cls._MAX_SIZE) + data += cls._writeblock(padding_block, is_last=True) + + return data + + +class StreamInfo(MetadataBlock, mutagen.StreamInfo): + """FLAC stream information. + + This contains information about the audio data in the FLAC file. + Unlike most stream information objects in Mutagen, changes to this + one will rewritten to the file when it is saved. Unless you are + actually changing the audio stream itself, don't change any + attributes of this block. + + Attributes: + + * min_blocksize -- minimum audio block size + * max_blocksize -- maximum audio block size + * sample_rate -- audio sample rate in Hz + * channels -- audio channels (1 for mono, 2 for stereo) + * bits_per_sample -- bits per sample + * total_samples -- total samples in file + * length -- audio length in seconds + """ + + code = 0 + + def __eq__(self, other): + try: + return (self.min_blocksize == other.min_blocksize and + self.max_blocksize == other.max_blocksize and + self.sample_rate == other.sample_rate and + self.channels == other.channels and + self.bits_per_sample == other.bits_per_sample and + self.total_samples == other.total_samples) + except: + return False + + __hash__ = MetadataBlock.__hash__ + + def load(self, data): + self.min_blocksize = int(to_int_be(data.read(2))) + self.max_blocksize = int(to_int_be(data.read(2))) + self.min_framesize = int(to_int_be(data.read(3))) + self.max_framesize = int(to_int_be(data.read(3))) + # first 16 bits of sample rate + sample_first = to_int_be(data.read(2)) + # last 4 bits of sample rate, 3 of channels, first 1 of bits/sample + sample_channels_bps = to_int_be(data.read(1)) + # last 4 of bits/sample, 36 of total samples + bps_total = to_int_be(data.read(5)) + + sample_tail = sample_channels_bps >> 4 + self.sample_rate = int((sample_first << 4) + sample_tail) + if not self.sample_rate: + raise error("A sample rate value of 0 is invalid") + self.channels = int(((sample_channels_bps >> 1) & 7) + 1) + bps_tail = bps_total >> 36 + bps_head = (sample_channels_bps & 1) << 4 + self.bits_per_sample = int(bps_head + bps_tail + 1) + self.total_samples = bps_total & 0xFFFFFFFFF + self.length = self.total_samples / float(self.sample_rate) + + self.md5_signature = to_int_be(data.read(16)) + + def write(self): + f = cBytesIO() + f.write(struct.pack(">I", self.min_blocksize)[-2:]) + f.write(struct.pack(">I", self.max_blocksize)[-2:]) + f.write(struct.pack(">I", self.min_framesize)[-3:]) + f.write(struct.pack(">I", self.max_framesize)[-3:]) + + # first 16 bits of sample rate + f.write(struct.pack(">I", self.sample_rate >> 4)[-2:]) + # 4 bits sample, 3 channel, 1 bps + byte = (self.sample_rate & 0xF) << 4 + byte += ((self.channels - 1) & 7) << 1 + byte += ((self.bits_per_sample - 1) >> 4) & 1 + f.write(chr_(byte)) + # 4 bits of bps, 4 of sample count + byte = ((self.bits_per_sample - 1) & 0xF) << 4 + byte += (self.total_samples >> 32) & 0xF + f.write(chr_(byte)) + # last 32 of sample count + f.write(struct.pack(">I", self.total_samples & 0xFFFFFFFF)) + # MD5 signature + sig = self.md5_signature + f.write(struct.pack( + ">4I", (sig >> 96) & 0xFFFFFFFF, (sig >> 64) & 0xFFFFFFFF, + (sig >> 32) & 0xFFFFFFFF, sig & 0xFFFFFFFF)) + return f.getvalue() + + def pprint(self): + return u"FLAC, %.2f seconds, %d Hz" % (self.length, self.sample_rate) + + +class SeekPoint(tuple): + """A single seek point in a FLAC file. + + Placeholder seek points have first_sample of 0xFFFFFFFFFFFFFFFFL, + and byte_offset and num_samples undefined. Seek points must be + sorted in ascending order by first_sample number. Seek points must + be unique by first_sample number, except for placeholder + points. Placeholder points must occur last in the table and there + may be any number of them. + + Attributes: + + * first_sample -- sample number of first sample in the target frame + * byte_offset -- offset from first frame to target frame + * num_samples -- number of samples in target frame + """ + + def __new__(cls, first_sample, byte_offset, num_samples): + return super(cls, SeekPoint).__new__( + cls, (first_sample, byte_offset, num_samples)) + + first_sample = property(lambda self: self[0]) + byte_offset = property(lambda self: self[1]) + num_samples = property(lambda self: self[2]) + + +class SeekTable(MetadataBlock): + """Read and write FLAC seek tables. + + Attributes: + + * seekpoints -- list of SeekPoint objects + """ + + __SEEKPOINT_FORMAT = '>QQH' + __SEEKPOINT_SIZE = struct.calcsize(__SEEKPOINT_FORMAT) + + code = 3 + + def __init__(self, data): + self.seekpoints = [] + super(SeekTable, self).__init__(data) + + def __eq__(self, other): + try: + return (self.seekpoints == other.seekpoints) + except (AttributeError, TypeError): + return False + + __hash__ = MetadataBlock.__hash__ + + def load(self, data): + self.seekpoints = [] + sp = data.tryread(self.__SEEKPOINT_SIZE) + while len(sp) == self.__SEEKPOINT_SIZE: + self.seekpoints.append(SeekPoint( + *struct.unpack(self.__SEEKPOINT_FORMAT, sp))) + sp = data.tryread(self.__SEEKPOINT_SIZE) + + def write(self): + f = cBytesIO() + for seekpoint in self.seekpoints: + packed = struct.pack( + self.__SEEKPOINT_FORMAT, + seekpoint.first_sample, seekpoint.byte_offset, + seekpoint.num_samples) + f.write(packed) + return f.getvalue() + + def __repr__(self): + return "<%s seekpoints=%r>" % (type(self).__name__, self.seekpoints) + + +class VCFLACDict(VCommentDict): + """Read and write FLAC Vorbis comments. + + FLACs don't use the framing bit at the end of the comment block. + So this extends VCommentDict to not use the framing bit. + """ + + code = 4 + _distrust_size = True + + def load(self, data, errors='replace', framing=False): + super(VCFLACDict, self).load(data, errors=errors, framing=framing) + + def write(self, framing=False): + return super(VCFLACDict, self).write(framing=framing) + + +class CueSheetTrackIndex(tuple): + """Index for a track in a cuesheet. + + For CD-DA, an index_number of 0 corresponds to the track + pre-gap. The first index in a track must have a number of 0 or 1, + and subsequently, index_numbers must increase by 1. Index_numbers + must be unique within a track. And index_offset must be evenly + divisible by 588 samples. + + Attributes: + + * index_number -- index point number + * index_offset -- offset in samples from track start + """ + + def __new__(cls, index_number, index_offset): + return super(cls, CueSheetTrackIndex).__new__( + cls, (index_number, index_offset)) + + index_number = property(lambda self: self[0]) + index_offset = property(lambda self: self[1]) + + +class CueSheetTrack(object): + """A track in a cuesheet. + + For CD-DA, track_numbers must be 1-99, or 170 for the + lead-out. Track_numbers must be unique within a cue sheet. There + must be atleast one index in every track except the lead-out track + which must have none. + + Attributes: + + * track_number -- track number + * start_offset -- track offset in samples from start of FLAC stream + * isrc -- ISRC code + * type -- 0 for audio, 1 for digital data + * pre_emphasis -- true if the track is recorded with pre-emphasis + * indexes -- list of CueSheetTrackIndex objects + """ + + def __init__(self, track_number, start_offset, isrc='', type_=0, + pre_emphasis=False): + self.track_number = track_number + self.start_offset = start_offset + self.isrc = isrc + self.type = type_ + self.pre_emphasis = pre_emphasis + self.indexes = [] + + def __eq__(self, other): + try: + return (self.track_number == other.track_number and + self.start_offset == other.start_offset and + self.isrc == other.isrc and + self.type == other.type and + self.pre_emphasis == other.pre_emphasis and + self.indexes == other.indexes) + except (AttributeError, TypeError): + return False + + __hash__ = object.__hash__ + + def __repr__(self): + return (("<%s number=%r, offset=%d, isrc=%r, type=%r, " + "pre_emphasis=%r, indexes=%r)>") % + (type(self).__name__, self.track_number, self.start_offset, + self.isrc, self.type, self.pre_emphasis, self.indexes)) + + +class CueSheet(MetadataBlock): + """Read and write FLAC embedded cue sheets. + + Number of tracks should be from 1 to 100. There should always be + exactly one lead-out track and that track must be the last track + in the cue sheet. + + Attributes: + + * media_catalog_number -- media catalog number in ASCII + * lead_in_samples -- number of lead-in samples + * compact_disc -- true if the cuesheet corresponds to a compact disc + * tracks -- list of CueSheetTrack objects + * lead_out -- lead-out as CueSheetTrack or None if lead-out was not found + """ + + __CUESHEET_FORMAT = '>128sQB258xB' + __CUESHEET_SIZE = struct.calcsize(__CUESHEET_FORMAT) + __CUESHEET_TRACK_FORMAT = '>QB12sB13xB' + __CUESHEET_TRACK_SIZE = struct.calcsize(__CUESHEET_TRACK_FORMAT) + __CUESHEET_TRACKINDEX_FORMAT = '>QB3x' + __CUESHEET_TRACKINDEX_SIZE = struct.calcsize(__CUESHEET_TRACKINDEX_FORMAT) + + code = 5 + + media_catalog_number = b'' + lead_in_samples = 88200 + compact_disc = True + + def __init__(self, data): + self.tracks = [] + super(CueSheet, self).__init__(data) + + def __eq__(self, other): + try: + return (self.media_catalog_number == other.media_catalog_number and + self.lead_in_samples == other.lead_in_samples and + self.compact_disc == other.compact_disc and + self.tracks == other.tracks) + except (AttributeError, TypeError): + return False + + __hash__ = MetadataBlock.__hash__ + + def load(self, data): + header = data.read(self.__CUESHEET_SIZE) + media_catalog_number, lead_in_samples, flags, num_tracks = \ + struct.unpack(self.__CUESHEET_FORMAT, header) + self.media_catalog_number = media_catalog_number.rstrip(b'\0') + self.lead_in_samples = lead_in_samples + self.compact_disc = bool(flags & 0x80) + self.tracks = [] + for i in xrange(num_tracks): + track = data.read(self.__CUESHEET_TRACK_SIZE) + start_offset, track_number, isrc_padded, flags, num_indexes = \ + struct.unpack(self.__CUESHEET_TRACK_FORMAT, track) + isrc = isrc_padded.rstrip(b'\0') + type_ = (flags & 0x80) >> 7 + pre_emphasis = bool(flags & 0x40) + val = CueSheetTrack( + track_number, start_offset, isrc, type_, pre_emphasis) + for j in xrange(num_indexes): + index = data.read(self.__CUESHEET_TRACKINDEX_SIZE) + index_offset, index_number = struct.unpack( + self.__CUESHEET_TRACKINDEX_FORMAT, index) + val.indexes.append( + CueSheetTrackIndex(index_number, index_offset)) + self.tracks.append(val) + + def write(self): + f = cBytesIO() + flags = 0 + if self.compact_disc: + flags |= 0x80 + packed = struct.pack( + self.__CUESHEET_FORMAT, self.media_catalog_number, + self.lead_in_samples, flags, len(self.tracks)) + f.write(packed) + for track in self.tracks: + track_flags = 0 + track_flags |= (track.type & 1) << 7 + if track.pre_emphasis: + track_flags |= 0x40 + track_packed = struct.pack( + self.__CUESHEET_TRACK_FORMAT, track.start_offset, + track.track_number, track.isrc, track_flags, + len(track.indexes)) + f.write(track_packed) + for index in track.indexes: + index_packed = struct.pack( + self.__CUESHEET_TRACKINDEX_FORMAT, + index.index_offset, index.index_number) + f.write(index_packed) + return f.getvalue() + + def __repr__(self): + return (("<%s media_catalog_number=%r, lead_in=%r, compact_disc=%r, " + "tracks=%r>") % + (type(self).__name__, self.media_catalog_number, + self.lead_in_samples, self.compact_disc, self.tracks)) + + +class Picture(MetadataBlock): + """Read and write FLAC embed pictures. + + Attributes: + + * type -- picture type (same as types for ID3 APIC frames) + * mime -- MIME type of the picture + * desc -- picture's description + * width -- width in pixels + * height -- height in pixels + * depth -- color depth in bits-per-pixel + * colors -- number of colors for indexed palettes (like GIF), + 0 for non-indexed + * data -- picture data + + To create a picture from file (in order to add to a FLAC file), + instantiate this object without passing anything to the constructor and + then set the properties manually:: + + p = Picture() + + with open("Folder.jpg", "rb") as f: + pic.data = f.read() + + pic.type = id3.PictureType.COVER_FRONT + pic.mime = u"image/jpeg" + pic.width = 500 + pic.height = 500 + pic.depth = 16 # color depth + """ + + code = 6 + _distrust_size = True + + def __init__(self, data=None): + self.type = 0 + self.mime = u'' + self.desc = u'' + self.width = 0 + self.height = 0 + self.depth = 0 + self.colors = 0 + self.data = b'' + super(Picture, self).__init__(data) + + def __eq__(self, other): + try: + return (self.type == other.type and + self.mime == other.mime and + self.desc == other.desc and + self.width == other.width and + self.height == other.height and + self.depth == other.depth and + self.colors == other.colors and + self.data == other.data) + except (AttributeError, TypeError): + return False + + __hash__ = MetadataBlock.__hash__ + + def load(self, data): + self.type, length = struct.unpack('>2I', data.read(8)) + self.mime = data.read(length).decode('UTF-8', 'replace') + length, = struct.unpack('>I', data.read(4)) + self.desc = data.read(length).decode('UTF-8', 'replace') + (self.width, self.height, self.depth, + self.colors, length) = struct.unpack('>5I', data.read(20)) + self.data = data.read(length) + + def write(self): + f = cBytesIO() + mime = self.mime.encode('UTF-8') + f.write(struct.pack('>2I', self.type, len(mime))) + f.write(mime) + desc = self.desc.encode('UTF-8') + f.write(struct.pack('>I', len(desc))) + f.write(desc) + f.write(struct.pack('>5I', self.width, self.height, self.depth, + self.colors, len(self.data))) + f.write(self.data) + return f.getvalue() + + def __repr__(self): + return "<%s '%s' (%d bytes)>" % (type(self).__name__, self.mime, + len(self.data)) + + +class Padding(MetadataBlock): + """Empty padding space for metadata blocks. + + To avoid rewriting the entire FLAC file when editing comments, + metadata is often padded. Padding should occur at the end, and no + more than one padding block should be in any FLAC file. + """ + + code = 1 + + def __init__(self, data=b""): + super(Padding, self).__init__(data) + + def load(self, data): + self.length = len(data.read()) + + def write(self): + try: + return b"\x00" * self.length + # On some 64 bit platforms this won't generate a MemoryError + # or OverflowError since you might have enough RAM, but it + # still generates a ValueError. On other 64 bit platforms, + # this will still succeed for extremely large values. + # Those should never happen in the real world, and if they + # do, writeblocks will catch it. + except (OverflowError, ValueError, MemoryError): + raise error("cannot write %d bytes" % self.length) + + def __eq__(self, other): + return isinstance(other, Padding) and self.length == other.length + + __hash__ = MetadataBlock.__hash__ + + def __repr__(self): + return "<%s (%d bytes)>" % (type(self).__name__, self.length) + + +class FLAC(mutagen.FileType): + """A FLAC audio file. + + Attributes: + + * cuesheet -- CueSheet object, if any + * seektable -- SeekTable object, if any + * pictures -- list of embedded pictures + """ + + _mimes = ["audio/x-flac", "application/x-flac"] + + info = None + """A `StreamInfo`""" + + tags = None + """A `VCommentDict`""" + + METADATA_BLOCKS = [StreamInfo, Padding, None, SeekTable, VCFLACDict, + CueSheet, Picture] + """Known metadata block types, indexed by ID.""" + + @staticmethod + def score(filename, fileobj, header_data): + return (header_data.startswith(b"fLaC") + + endswith(filename.lower(), ".flac") * 3) + + def __read_metadata_block(self, fileobj): + byte = ord(fileobj.read(1)) + size = to_int_be(fileobj.read(3)) + code = byte & 0x7F + last_block = bool(byte & 0x80) + + try: + block_type = self.METADATA_BLOCKS[code] or MetadataBlock + except IndexError: + block_type = MetadataBlock + + if block_type._distrust_size: + # Some jackass is writing broken Metadata block length + # for Vorbis comment blocks, and the FLAC reference + # implementaton can parse them (mostly by accident), + # so we have to too. Instead of parsing the size + # given, parse an actual Vorbis comment, leaving + # fileobj in the right position. + # http://code.google.com/p/mutagen/issues/detail?id=52 + # ..same for the Picture block: + # http://code.google.com/p/mutagen/issues/detail?id=106 + start = fileobj.tell() + block = block_type(fileobj) + real_size = fileobj.tell() - start + if real_size > MetadataBlock._MAX_SIZE: + block._invalid_overflow_size = size + else: + data = fileobj.read(size) + block = block_type(data) + block.code = code + + if block.code == VCFLACDict.code: + if self.tags is None: + self.tags = block + else: + raise FLACVorbisError("> 1 Vorbis comment block found") + elif block.code == CueSheet.code: + if self.cuesheet is None: + self.cuesheet = block + else: + raise error("> 1 CueSheet block found") + elif block.code == SeekTable.code: + if self.seektable is None: + self.seektable = block + else: + raise error("> 1 SeekTable block found") + self.metadata_blocks.append(block) + return not last_block + + def add_tags(self): + """Add a Vorbis comment block to the file.""" + if self.tags is None: + self.tags = VCFLACDict() + self.metadata_blocks.append(self.tags) + else: + raise FLACVorbisError("a Vorbis comment already exists") + + add_vorbiscomment = add_tags + + def delete(self, filename=None): + """Remove Vorbis comments from a file. + + If no filename is given, the one most recently loaded is used. + """ + if filename is None: + filename = self.filename + + if self.tags is not None: + self.metadata_blocks.remove(self.tags) + self.save(padding=lambda x: 0) + self.metadata_blocks.append(self.tags) + self.tags.clear() + + vc = property(lambda s: s.tags, doc="Alias for tags; don't use this.") + + def load(self, filename): + """Load file information from a filename.""" + + self.metadata_blocks = [] + self.tags = None + self.cuesheet = None + self.seektable = None + self.filename = filename + fileobj = StrictFileObject(open(filename, "rb")) + try: + self.__check_header(fileobj) + while self.__read_metadata_block(fileobj): + pass + finally: + fileobj.close() + + try: + self.metadata_blocks[0].length + except (AttributeError, IndexError): + raise FLACNoHeaderError("Stream info block not found") + + @property + def info(self): + return self.metadata_blocks[0] + + def add_picture(self, picture): + """Add a new picture to the file.""" + self.metadata_blocks.append(picture) + + def clear_pictures(self): + """Delete all pictures from the file.""" + + blocks = [b for b in self.metadata_blocks if b.code != Picture.code] + self.metadata_blocks = blocks + + @property + def pictures(self): + """List of embedded pictures""" + + return [b for b in self.metadata_blocks if b.code == Picture.code] + + def save(self, filename=None, deleteid3=False, padding=None): + """Save metadata blocks to a file. + + If no filename is given, the one most recently loaded is used. + """ + + if filename is None: + filename = self.filename + + with open(filename, 'rb+') as f: + header = self.__check_header(f) + audio_offset = self.__find_audio_offset(f) + # "fLaC" and maybe ID3 + available = audio_offset - header + + # Delete ID3v2 + if deleteid3 and header > 4: + available += header - 4 + header = 4 + + content_size = get_size(f) - audio_offset + assert content_size >= 0 + data = MetadataBlock._writeblocks( + self.metadata_blocks, available, content_size, padding) + data_size = len(data) + + resize_bytes(f, available, data_size, header) + f.seek(header - 4) + f.write(b"fLaC") + f.write(data) + + # Delete ID3v1 + if deleteid3: + try: + f.seek(-128, 2) + except IOError: + pass + else: + if f.read(3) == b"TAG": + f.seek(-128, 2) + f.truncate() + + def __find_audio_offset(self, fileobj): + byte = 0x00 + while not (byte & 0x80): + byte = ord(fileobj.read(1)) + size = to_int_be(fileobj.read(3)) + try: + block_type = self.METADATA_BLOCKS[byte & 0x7F] + except IndexError: + block_type = None + + if block_type and block_type._distrust_size: + # See comments in read_metadata_block; the size can't + # be trusted for Vorbis comment blocks and Picture block + block_type(fileobj) + else: + fileobj.read(size) + return fileobj.tell() + + def __check_header(self, fileobj): + """Returns the offset of the flac block start + (skipping id3 tags if found). The passed fileobj will be advanced to + that offset as well. + """ + + size = 4 + header = fileobj.read(4) + if header != b"fLaC": + size = None + if header[:3] == b"ID3": + size = 14 + BitPaddedInt(fileobj.read(6)[2:]) + fileobj.seek(size - 4) + if fileobj.read(4) != b"fLaC": + size = None + if size is None: + raise FLACNoHeaderError( + "%r is not a valid FLAC file" % fileobj.name) + return size + + +Open = FLAC + + +def delete(filename): + """Remove tags from a file.""" + FLAC(filename).delete() diff --git a/resources/lib/mutagen/id3/__init__.py b/resources/lib/mutagen/id3/__init__.py new file mode 100644 index 00000000..9aef865b --- /dev/null +++ b/resources/lib/mutagen/id3/__init__.py @@ -0,0 +1,1093 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# 2006 Lukas Lalinsky +# 2013 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""ID3v2 reading and writing. + +This is based off of the following references: + +* http://id3.org/id3v2.4.0-structure +* http://id3.org/id3v2.4.0-frames +* http://id3.org/id3v2.3.0 +* http://id3.org/id3v2-00 +* http://id3.org/ID3v1 + +Its largest deviation from the above (versions 2.3 and 2.2) is that it +will not interpret the / characters as a separator, and will almost +always accept null separators to generate multi-valued text frames. + +Because ID3 frame structure differs between frame types, each frame is +implemented as a different class (e.g. TIT2 as mutagen.id3.TIT2). Each +frame's documentation contains a list of its attributes. + +Since this file's documentation is a little unwieldy, you are probably +interested in the :class:`ID3` class to start with. +""" + +__all__ = ['ID3', 'ID3FileType', 'Frames', 'Open', 'delete'] + +import struct +import errno + +from struct import unpack, pack, error as StructError + +import mutagen +from mutagen._util import insert_bytes, delete_bytes, DictProxy, enum +from mutagen._tags import PaddingInfo +from .._compat import chr_, PY3 + +from ._util import * +from ._frames import * +from ._specs import * + + +@enum +class ID3v1SaveOptions(object): + + REMOVE = 0 + """ID3v1 tags will be removed""" + + UPDATE = 1 + """ID3v1 tags will be updated but not added""" + + CREATE = 2 + """ID3v1 tags will be created and/or updated""" + + +def _fullread(fileobj, size): + """Read a certain number of bytes from the source file. + + Raises ValueError on invalid size input or EOFError/IOError. + """ + + if size < 0: + raise ValueError('Requested bytes (%s) less than zero' % size) + data = fileobj.read(size) + if len(data) != size: + raise EOFError("Not enough data to read") + return data + + +class ID3Header(object): + + _V24 = (2, 4, 0) + _V23 = (2, 3, 0) + _V22 = (2, 2, 0) + _V11 = (1, 1) + + f_unsynch = property(lambda s: bool(s._flags & 0x80)) + f_extended = property(lambda s: bool(s._flags & 0x40)) + f_experimental = property(lambda s: bool(s._flags & 0x20)) + f_footer = property(lambda s: bool(s._flags & 0x10)) + + def __init__(self, fileobj=None): + """Raises ID3NoHeaderError, ID3UnsupportedVersionError or error""" + + if fileobj is None: + # for testing + self._flags = 0 + return + + fn = getattr(fileobj, "name", "") + try: + data = _fullread(fileobj, 10) + except EOFError: + raise ID3NoHeaderError("%s: too small" % fn) + + id3, vmaj, vrev, flags, size = unpack('>3sBBB4s', data) + self._flags = flags + self.size = BitPaddedInt(size) + 10 + self.version = (2, vmaj, vrev) + + if id3 != b'ID3': + raise ID3NoHeaderError("%r doesn't start with an ID3 tag" % fn) + + if vmaj not in [2, 3, 4]: + raise ID3UnsupportedVersionError("%r ID3v2.%d not supported" + % (fn, vmaj)) + + if not BitPaddedInt.has_valid_padding(size): + raise error("Header size not synchsafe") + + if (self.version >= self._V24) and (flags & 0x0f): + raise error( + "%r has invalid flags %#02x" % (fn, flags)) + elif (self._V23 <= self.version < self._V24) and (flags & 0x1f): + raise error( + "%r has invalid flags %#02x" % (fn, flags)) + + if self.f_extended: + try: + extsize_data = _fullread(fileobj, 4) + except EOFError: + raise error("%s: too small" % fn) + + if PY3: + frame_id = extsize_data.decode("ascii", "replace") + else: + frame_id = extsize_data + + if frame_id in Frames: + # Some tagger sets the extended header flag but + # doesn't write an extended header; in this case, the + # ID3 data follows immediately. Since no extended + # header is going to be long enough to actually match + # a frame, and if it's *not* a frame we're going to be + # completely lost anyway, this seems to be the most + # correct check. + # http://code.google.com/p/quodlibet/issues/detail?id=126 + self._flags ^= 0x40 + extsize = 0 + fileobj.seek(-4, 1) + elif self.version >= self._V24: + # "Where the 'Extended header size' is the size of the whole + # extended header, stored as a 32 bit synchsafe integer." + extsize = BitPaddedInt(extsize_data) - 4 + if not BitPaddedInt.has_valid_padding(extsize_data): + raise error( + "Extended header size not synchsafe") + else: + # "Where the 'Extended header size', currently 6 or 10 bytes, + # excludes itself." + extsize = unpack('>L', extsize_data)[0] + + try: + self._extdata = _fullread(fileobj, extsize) + except EOFError: + raise error("%s: too small" % fn) + + +class ID3(DictProxy, mutagen.Metadata): + """A file with an ID3v2 tag. + + Attributes: + + * version -- ID3 tag version as a tuple + * unknown_frames -- raw frame data of any unknown frames found + * size -- the total size of the ID3 tag, including the header + """ + + __module__ = "mutagen.id3" + + PEDANTIC = True + """Deprecated. Doesn't have any effect""" + + filename = None + + def __init__(self, *args, **kwargs): + self.unknown_frames = [] + self.__unknown_version = None + self._header = None + self._version = (2, 4, 0) + super(ID3, self).__init__(*args, **kwargs) + + @property + def version(self): + """ID3 tag version as a tuple (of the loaded file)""" + + if self._header is not None: + return self._header.version + return self._version + + @version.setter + def version(self, value): + self._version = value + + @property + def f_unsynch(self): + if self._header is not None: + return self._header.f_unsynch + return False + + @property + def f_extended(self): + if self._header is not None: + return self._header.f_extended + return False + + @property + def size(self): + if self._header is not None: + return self._header.size + return 0 + + def _pre_load_header(self, fileobj): + # XXX: for aiff to adjust the offset.. + pass + + def load(self, filename, known_frames=None, translate=True, v2_version=4): + """Load tags from a filename. + + Keyword arguments: + + * filename -- filename to load tag data from + * known_frames -- dict mapping frame IDs to Frame objects + * translate -- Update all tags to ID3v2.3/4 internally. If you + intend to save, this must be true or you have to + call update_to_v23() / update_to_v24() manually. + * v2_version -- if update_to_v23 or update_to_v24 get called (3 or 4) + + Example of loading a custom frame:: + + my_frames = dict(mutagen.id3.Frames) + class XMYF(Frame): ... + my_frames["XMYF"] = XMYF + mutagen.id3.ID3(filename, known_frames=my_frames) + """ + + if v2_version not in (3, 4): + raise ValueError("Only 3 and 4 possible for v2_version") + + self.filename = filename + self.unknown_frames = [] + self.__known_frames = known_frames + self._header = None + self._padding = 0 # for testing + + with open(filename, 'rb') as fileobj: + self._pre_load_header(fileobj) + + try: + self._header = ID3Header(fileobj) + except (ID3NoHeaderError, ID3UnsupportedVersionError): + frames, offset = _find_id3v1(fileobj) + if frames is None: + raise + + self.version = ID3Header._V11 + for v in frames.values(): + self.add(v) + else: + frames = self.__known_frames + if frames is None: + if self.version >= ID3Header._V23: + frames = Frames + elif self.version >= ID3Header._V22: + frames = Frames_2_2 + + try: + data = _fullread(fileobj, self.size - 10) + except (ValueError, EOFError, IOError) as e: + raise error(e) + + for frame in self.__read_frames(data, frames=frames): + if isinstance(frame, Frame): + self.add(frame) + else: + self.unknown_frames.append(frame) + self.__unknown_version = self.version[:2] + + if translate: + if v2_version == 3: + self.update_to_v23() + else: + self.update_to_v24() + + def getall(self, key): + """Return all frames with a given name (the list may be empty). + + This is best explained by examples:: + + id3.getall('TIT2') == [id3['TIT2']] + id3.getall('TTTT') == [] + id3.getall('TXXX') == [TXXX(desc='woo', text='bar'), + TXXX(desc='baz', text='quuuux'), ...] + + Since this is based on the frame's HashKey, which is + colon-separated, you can use it to do things like + ``getall('COMM:MusicMatch')`` or ``getall('TXXX:QuodLibet:')``. + """ + if key in self: + return [self[key]] + else: + key = key + ":" + return [v for s, v in self.items() if s.startswith(key)] + + def delall(self, key): + """Delete all tags of a given kind; see getall.""" + if key in self: + del(self[key]) + else: + key = key + ":" + for k in list(self.keys()): + if k.startswith(key): + del(self[k]) + + def setall(self, key, values): + """Delete frames of the given type and add frames in 'values'.""" + self.delall(key) + for tag in values: + self[tag.HashKey] = tag + + def pprint(self): + """Return tags in a human-readable format. + + "Human-readable" is used loosely here. The format is intended + to mirror that used for Vorbis or APEv2 output, e.g. + + ``TIT2=My Title`` + + However, ID3 frames can have multiple keys: + + ``POPM=user@example.org=3 128/255`` + """ + frames = sorted(Frame.pprint(s) for s in self.values()) + return "\n".join(frames) + + def loaded_frame(self, tag): + """Deprecated; use the add method.""" + # turn 2.2 into 2.3/2.4 tags + if len(type(tag).__name__) == 3: + tag = type(tag).__base__(tag) + self[tag.HashKey] = tag + + # add = loaded_frame (and vice versa) break applications that + # expect to be able to override loaded_frame (e.g. Quod Libet), + # as does making loaded_frame call add. + def add(self, frame): + """Add a frame to the tag.""" + return self.loaded_frame(frame) + + def __read_frames(self, data, frames): + assert self.version >= ID3Header._V22 + + if self.version < ID3Header._V24 and self.f_unsynch: + try: + data = unsynch.decode(data) + except ValueError: + pass + + if self.version >= ID3Header._V23: + if self.version < ID3Header._V24: + bpi = int + else: + bpi = _determine_bpi(data, frames) + + while data: + header = data[:10] + try: + name, size, flags = unpack('>4sLH', header) + except struct.error: + return # not enough header + if name.strip(b'\x00') == b'': + return + + size = bpi(size) + framedata = data[10:10 + size] + data = data[10 + size:] + self._padding = len(data) + if size == 0: + continue # drop empty frames + + if PY3: + try: + name = name.decode('ascii') + except UnicodeDecodeError: + continue + + try: + # someone writes 2.3 frames with 2.2 names + if name[-1] == "\x00": + tag = Frames_2_2[name[:-1]] + name = tag.__base__.__name__ + + tag = frames[name] + except KeyError: + if is_valid_frame_id(name): + yield header + framedata + else: + try: + yield tag._fromData(self._header, flags, framedata) + except NotImplementedError: + yield header + framedata + except ID3JunkFrameError: + pass + elif self.version >= ID3Header._V22: + while data: + header = data[0:6] + try: + name, size = unpack('>3s3s', header) + except struct.error: + return # not enough header + size, = struct.unpack('>L', b'\x00' + size) + if name.strip(b'\x00') == b'': + return + + framedata = data[6:6 + size] + data = data[6 + size:] + self._padding = len(data) + if size == 0: + continue # drop empty frames + + if PY3: + try: + name = name.decode('ascii') + except UnicodeDecodeError: + continue + + try: + tag = frames[name] + except KeyError: + if is_valid_frame_id(name): + yield header + framedata + else: + try: + yield tag._fromData(self._header, 0, framedata) + except (ID3EncryptionUnsupportedError, + NotImplementedError): + yield header + framedata + except ID3JunkFrameError: + pass + + def _prepare_data(self, fileobj, start, available, v2_version, v23_sep, + pad_func): + if v2_version == 3: + version = ID3Header._V23 + elif v2_version == 4: + version = ID3Header._V24 + else: + raise ValueError("Only 3 or 4 allowed for v2_version") + + # Sort frames by 'importance' + order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"] + order = dict((b, a) for a, b in enumerate(order)) + last = len(order) + frames = sorted(self.items(), + key=lambda a: (order.get(a[0][:4], last), a[0])) + + framedata = [self.__save_frame(frame, version=version, v23_sep=v23_sep) + for (key, frame) in frames] + + # only write unknown frames if they were loaded from the version + # we are saving with or upgraded to it + if self.__unknown_version == version[:2]: + framedata.extend(data for data in self.unknown_frames + if len(data) > 10) + + needed = sum(map(len, framedata)) + 10 + + fileobj.seek(0, 2) + trailing_size = fileobj.tell() - start + + info = PaddingInfo(available - needed, trailing_size) + new_padding = info._get_padding(pad_func) + if new_padding < 0: + raise error("invalid padding") + new_size = needed + new_padding + + new_framesize = BitPaddedInt.to_str(new_size - 10, width=4) + header = pack('>3sBBB4s', b'ID3', v2_version, 0, 0, new_framesize) + + data = bytearray(header) + for frame in framedata: + data += frame + assert new_size >= len(data) + data += (new_size - len(data)) * b'\x00' + assert new_size == len(data) + + return data + + def save(self, filename=None, v1=1, v2_version=4, v23_sep='/', + padding=None): + """Save changes to a file. + + Args: + filename: + Filename to save the tag to. If no filename is given, + the one most recently loaded is used. + v1 (ID3v1SaveOptions): + 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 + v2 (int): + version of ID3v2 tags (3 or 4). + v23_sep (str): + the separator used to join multiple text values + if v2_version == 3. Defaults to '/' but if it's None + will be the ID3v2v2.4 null separator. + padding (function): + A function taking a PaddingInfo which should + return the amount of padding to use. If None (default) + will default to something reasonable. + + By default Mutagen saves ID3v2.4 tags. If you want to save ID3v2.3 + tags, you must call method update_to_v23 before saving the file. + + The lack of a way to update only an ID3v1 tag is intentional. + + Can raise id3.error. + """ + + if filename is None: + filename = self.filename + + try: + f = open(filename, 'rb+') + except IOError as err: + from errno import ENOENT + if err.errno != ENOENT: + raise + f = open(filename, 'ab') # create, then reopen + f = open(filename, 'rb+') + + try: + try: + header = ID3Header(f) + except ID3NoHeaderError: + old_size = 0 + else: + old_size = header.size + + data = self._prepare_data( + f, 0, old_size, v2_version, v23_sep, padding) + new_size = len(data) + + if (old_size < new_size): + insert_bytes(f, new_size - old_size, old_size) + elif (old_size > new_size): + delete_bytes(f, old_size - new_size, new_size) + f.seek(0) + f.write(data) + + self.__save_v1(f, v1) + + finally: + f.close() + + def __save_v1(self, f, v1): + tag, offset = _find_id3v1(f) + has_v1 = tag is not None + + f.seek(offset, 2) + if v1 == ID3v1SaveOptions.UPDATE and has_v1 or \ + v1 == ID3v1SaveOptions.CREATE: + f.write(MakeID3v1(self)) + else: + f.truncate() + + def delete(self, filename=None, delete_v1=True, delete_v2=True): + """Remove tags from a file. + + If no filename is given, the one most recently loaded is used. + + Keyword arguments: + + * delete_v1 -- delete any ID3v1 tag + * delete_v2 -- delete any ID3v2 tag + """ + if filename is None: + filename = self.filename + delete(filename, delete_v1, delete_v2) + self.clear() + + def __save_frame(self, frame, name=None, version=ID3Header._V24, + v23_sep=None): + flags = 0 + if isinstance(frame, TextFrame): + if len(str(frame)) == 0: + return b'' + + if version == ID3Header._V23: + framev23 = frame._get_v23_frame(sep=v23_sep) + framedata = framev23._writeData() + else: + framedata = frame._writeData() + + usize = len(framedata) + if usize > 2048: + # Disabled as this causes iTunes and other programs + # to fail to find these frames, which usually includes + # e.g. APIC. + # framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib') + # flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN + pass + + if version == ID3Header._V24: + bits = 7 + elif version == ID3Header._V23: + bits = 8 + else: + raise ValueError + + datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits) + + if name is not None: + assert isinstance(name, bytes) + frame_name = name + else: + frame_name = type(frame).__name__ + if PY3: + frame_name = frame_name.encode("ascii") + + header = pack('>4s4sH', frame_name, datasize, flags) + return header + framedata + + def __update_common(self): + """Updates done by both v23 and v24 update""" + + if "TCON" in self: + # Get rid of "(xx)Foobr" format. + self["TCON"].genres = self["TCON"].genres + + # ID3v2.2 LNK frames are just way too different to upgrade. + for frame in self.getall("LINK"): + if len(frame.frameid) != 4: + del self[frame.HashKey] + + mimes = {"PNG": "image/png", "JPG": "image/jpeg"} + for pic in self.getall("APIC"): + if pic.mime in mimes: + newpic = APIC( + encoding=pic.encoding, mime=mimes[pic.mime], + type=pic.type, desc=pic.desc, data=pic.data) + self.add(newpic) + + def update_to_v24(self): + """Convert older tags into an ID3v2.4 tag. + + This updates old ID3v2 frames to ID3v2.4 ones (e.g. TYER to + TDRC). If you intend to save tags, you must call this function + at some point; it is called by default when loading the tag. + """ + + self.__update_common() + + if self.__unknown_version == (2, 3): + # convert unknown 2.3 frames (flags/size) to 2.4 + converted = [] + for frame in self.unknown_frames: + try: + name, size, flags = unpack('>4sLH', frame[:10]) + except struct.error: + continue + + try: + frame = BinaryFrame._fromData( + self._header, flags, frame[10:]) + except (error, NotImplementedError): + continue + + converted.append(self.__save_frame(frame, name=name)) + self.unknown_frames[:] = converted + self.__unknown_version = (2, 4) + + # TDAT, TYER, and TIME have been turned into TDRC. + try: + date = text_type(self.get("TYER", "")) + if date.strip(u"\x00"): + self.pop("TYER") + dat = text_type(self.get("TDAT", "")) + if dat.strip("\x00"): + self.pop("TDAT") + date = "%s-%s-%s" % (date, dat[2:], dat[:2]) + time = text_type(self.get("TIME", "")) + if time.strip("\x00"): + self.pop("TIME") + date += "T%s:%s:00" % (time[:2], time[2:]) + if "TDRC" not in self: + self.add(TDRC(encoding=0, text=date)) + except UnicodeDecodeError: + # Old ID3 tags have *lots* of Unicode problems, so if TYER + # is bad, just chuck the frames. + pass + + # TORY can be the first part of a TDOR. + if "TORY" in self: + f = self.pop("TORY") + if "TDOR" not in self: + try: + self.add(TDOR(encoding=0, text=str(f))) + except UnicodeDecodeError: + pass + + # IPLS is now TIPL. + if "IPLS" in self: + f = self.pop("IPLS") + if "TIPL" not in self: + self.add(TIPL(encoding=f.encoding, people=f.people)) + + # These can't be trivially translated to any ID3v2.4 tags, or + # should have been removed already. + for key in ["RVAD", "EQUA", "TRDA", "TSIZ", "TDAT", "TIME", "CRM"]: + if key in self: + del(self[key]) + + def update_to_v23(self): + """Convert older (and newer) tags into an ID3v2.3 tag. + + This updates incompatible ID3v2 frames to ID3v2.3 ones. If you + intend to save tags as ID3v2.3, you must call this function + at some point. + + If you want to to go off spec and include some v2.4 frames + in v2.3, remove them before calling this and add them back afterwards. + """ + + self.__update_common() + + # we could downgrade unknown v2.4 frames here, but given that + # the main reason to save v2.3 is compatibility and this + # might increase the chance of some parser breaking.. better not + + # TMCL, TIPL -> TIPL + if "TIPL" in self or "TMCL" in self: + people = [] + if "TIPL" in self: + f = self.pop("TIPL") + people.extend(f.people) + if "TMCL" in self: + f = self.pop("TMCL") + people.extend(f.people) + if "IPLS" not in self: + self.add(IPLS(encoding=f.encoding, people=people)) + + # TDOR -> TORY + if "TDOR" in self: + f = self.pop("TDOR") + if f.text: + d = f.text[0] + if d.year and "TORY" not in self: + self.add(TORY(encoding=f.encoding, text="%04d" % d.year)) + + # TDRC -> TYER, TDAT, TIME + if "TDRC" in self: + f = self.pop("TDRC") + if f.text: + d = f.text[0] + if d.year and "TYER" not in self: + self.add(TYER(encoding=f.encoding, text="%04d" % d.year)) + if d.month and d.day and "TDAT" not in self: + self.add(TDAT(encoding=f.encoding, + text="%02d%02d" % (d.day, d.month))) + if d.hour and d.minute and "TIME" not in self: + self.add(TIME(encoding=f.encoding, + text="%02d%02d" % (d.hour, d.minute))) + + # New frames added in v2.4 + v24_frames = [ + 'ASPI', 'EQU2', 'RVA2', 'SEEK', 'SIGN', 'TDEN', 'TDOR', + 'TDRC', 'TDRL', 'TDTG', 'TIPL', 'TMCL', 'TMOO', 'TPRO', + 'TSOA', 'TSOP', 'TSOT', 'TSST', + ] + + for key in v24_frames: + if key in self: + del(self[key]) + + +def delete(filename, delete_v1=True, delete_v2=True): + """Remove tags from a file. + + Keyword arguments: + + * delete_v1 -- delete any ID3v1 tag + * delete_v2 -- delete any ID3v2 tag + """ + + with open(filename, 'rb+') as f: + + if delete_v1: + tag, offset = _find_id3v1(f) + if tag is not None: + f.seek(offset, 2) + f.truncate() + + # technically an insize=0 tag is invalid, but we delete it anyway + # (primarily because we used to write it) + if delete_v2: + f.seek(0, 0) + idata = f.read(10) + try: + id3, vmaj, vrev, flags, insize = unpack('>3sBBB4s', idata) + except struct.error: + id3, insize = b'', -1 + insize = BitPaddedInt(insize) + if id3 == b'ID3' and insize >= 0: + delete_bytes(f, insize + 10, 0) + + +# support open(filename) as interface +Open = ID3 + + +def _determine_bpi(data, frames, EMPTY=b"\x00" * 10): + """Takes id3v2.4 frame data and determines if ints or bitpaddedints + should be used for parsing. Needed because iTunes used to write + normal ints for frame sizes. + """ + + # count number of tags found as BitPaddedInt and how far past + o = 0 + asbpi = 0 + while o < len(data) - 10: + part = data[o:o + 10] + if part == EMPTY: + bpioff = -((len(data) - o) % 10) + break + name, size, flags = unpack('>4sLH', part) + size = BitPaddedInt(size) + o += 10 + size + if PY3: + try: + name = name.decode("ascii") + except UnicodeDecodeError: + continue + if name in frames: + asbpi += 1 + else: + bpioff = o - len(data) + + # count number of tags found as int and how far past + o = 0 + asint = 0 + while o < len(data) - 10: + part = data[o:o + 10] + if part == EMPTY: + intoff = -((len(data) - o) % 10) + break + name, size, flags = unpack('>4sLH', part) + o += 10 + size + if PY3: + try: + name = name.decode("ascii") + except UnicodeDecodeError: + continue + if name in frames: + asint += 1 + else: + intoff = o - len(data) + + # if more tags as int, or equal and bpi is past and int is not + if asint > asbpi or (asint == asbpi and (bpioff >= 1 and intoff <= 1)): + return int + return BitPaddedInt + + +def _find_id3v1(fileobj): + """Returns a tuple of (id3tag, offset_to_end) or (None, 0) + + offset mainly because we used to write too short tags in some cases and + we need the offset to delete them. + """ + + # id3v1 is always at the end (after apev2) + + extra_read = b"APETAGEX".index(b"TAG") + + try: + fileobj.seek(-128 - extra_read, 2) + except IOError as e: + if e.errno == errno.EINVAL: + # If the file is too small, might be ok since we wrote too small + # tags at some point. let's see how the parsing goes.. + fileobj.seek(0, 0) + else: + raise + + data = fileobj.read(128 + extra_read) + try: + idx = data.index(b"TAG") + except ValueError: + return (None, 0) + else: + # FIXME: make use of the apev2 parser here + # if TAG is part of APETAGEX assume this is an APEv2 tag + try: + ape_idx = data.index(b"APETAGEX") + except ValueError: + pass + else: + if idx == ape_idx + extra_read: + return (None, 0) + + tag = ParseID3v1(data[idx:]) + if tag is None: + return (None, 0) + + offset = idx - len(data) + return (tag, offset) + + +# ID3v1.1 support. +def ParseID3v1(data): + """Parse an ID3v1 tag, returning a list of ID3v2.4 frames. + + Returns a {frame_name: frame} dict or None. + """ + + try: + data = data[data.index(b"TAG"):] + except ValueError: + return None + if 128 < len(data) or len(data) < 124: + return None + + # Issue #69 - Previous versions of Mutagen, when encountering + # out-of-spec TDRC and TYER frames of less than four characters, + # wrote only the characters available - e.g. "1" or "" - into the + # year field. To parse those, reduce the size of the year field. + # Amazingly, "0s" works as a struct format string. + unpack_fmt = "3s30s30s30s%ds29sBB" % (len(data) - 124) + + try: + tag, title, artist, album, year, comment, track, genre = unpack( + unpack_fmt, data) + except StructError: + return None + + if tag != b"TAG": + return None + + def fix(data): + return data.split(b"\x00")[0].strip().decode('latin1') + + title, artist, album, year, comment = map( + fix, [title, artist, album, year, comment]) + + frames = {} + if title: + frames["TIT2"] = TIT2(encoding=0, text=title) + if artist: + frames["TPE1"] = TPE1(encoding=0, text=[artist]) + if album: + frames["TALB"] = TALB(encoding=0, text=album) + if year: + frames["TDRC"] = TDRC(encoding=0, text=year) + if comment: + frames["COMM"] = COMM( + encoding=0, lang="eng", desc="ID3v1 Comment", text=comment) + # Don't read a track number if it looks like the comment was + # padded with spaces instead of nulls (thanks, WinAmp). + if track and ((track != 32) or (data[-3] == b'\x00'[0])): + frames["TRCK"] = TRCK(encoding=0, text=str(track)) + if genre != 255: + frames["TCON"] = TCON(encoding=0, text=str(genre)) + return frames + + +def MakeID3v1(id3): + """Return an ID3v1.1 tag string from a dict of ID3v2.4 frames.""" + + v1 = {} + + for v2id, name in {"TIT2": "title", "TPE1": "artist", + "TALB": "album"}.items(): + if v2id in id3: + text = id3[v2id].text[0].encode('latin1', 'replace')[:30] + else: + text = b"" + v1[name] = text + (b"\x00" * (30 - len(text))) + + if "COMM" in id3: + cmnt = id3["COMM"].text[0].encode('latin1', 'replace')[:28] + else: + cmnt = b"" + v1["comment"] = cmnt + (b"\x00" * (29 - len(cmnt))) + + if "TRCK" in id3: + try: + v1["track"] = chr_(+id3["TRCK"]) + except ValueError: + v1["track"] = b"\x00" + else: + v1["track"] = b"\x00" + + if "TCON" in id3: + try: + genre = id3["TCON"].genres[0] + except IndexError: + pass + else: + if genre in TCON.GENRES: + v1["genre"] = chr_(TCON.GENRES.index(genre)) + if "genre" not in v1: + v1["genre"] = b"\xff" + + if "TDRC" in id3: + year = text_type(id3["TDRC"]).encode('ascii') + elif "TYER" in id3: + year = text_type(id3["TYER"]).encode('ascii') + else: + year = b"" + v1["year"] = (year + b"\x00\x00\x00\x00")[:4] + + return ( + b"TAG" + + v1["title"] + + v1["artist"] + + v1["album"] + + v1["year"] + + v1["comment"] + + v1["track"] + + v1["genre"] + ) + + +class ID3FileType(mutagen.FileType): + """An unknown type of file with ID3 tags.""" + + ID3 = ID3 + + class _Info(mutagen.StreamInfo): + length = 0 + + def __init__(self, fileobj, offset): + pass + + @staticmethod + def pprint(): + return "Unknown format with ID3 tag" + + @staticmethod + def score(filename, fileobj, header_data): + return header_data.startswith(b"ID3") + + def add_tags(self, ID3=None): + """Add an empty ID3 tag to the file. + + A custom tag reader may be used in instead of the default + mutagen.id3.ID3 object, e.g. an EasyID3 reader. + """ + if ID3 is None: + ID3 = self.ID3 + if self.tags is None: + self.ID3 = ID3 + self.tags = ID3() + else: + raise error("an ID3 tag already exists") + + def load(self, filename, ID3=None, **kwargs): + """Load stream and tag information from a file. + + A custom tag reader may be used in instead of the default + mutagen.id3.ID3 object, e.g. an EasyID3 reader. + """ + + if ID3 is None: + ID3 = self.ID3 + else: + # If this was initialized with EasyID3, remember that for + # when tags are auto-instantiated in add_tags. + self.ID3 = ID3 + self.filename = filename + try: + self.tags = ID3(filename, **kwargs) + except ID3NoHeaderError: + self.tags = None + + if self.tags is not None: + try: + offset = self.tags.size + except AttributeError: + offset = None + else: + offset = None + + with open(filename, "rb") as fileobj: + self.info = self._Info(fileobj, offset) diff --git a/resources/lib/mutagen/id3/__pycache__/__init__.cpython-35.pyc b/resources/lib/mutagen/id3/__pycache__/__init__.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f423db1af7e6371db8a6eea5d505a35cb1aee338 GIT binary patch literal 27785 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 id3._V24: + if tflags & (Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN): + # The data length int is syncsafe in 2.4 (but not 2.3). + # However, we don't actually need the data length int, + # except to work around a QL 0.12 bug, and in that case + # all we need are the raw bytes. + datalen_bytes = data[:4] + data = data[4:] + if tflags & Frame.FLAG24_UNSYNCH or id3.f_unsynch: + try: + data = unsynch.decode(data) + except ValueError: + # Some things write synch-unsafe data with either the frame + # or global unsynch flag set. Try to load them as is. + # https://bitbucket.org/lazka/mutagen/issue/210 + # https://bitbucket.org/lazka/mutagen/issue/223 + pass + if tflags & Frame.FLAG24_ENCRYPT: + raise ID3EncryptionUnsupportedError + if tflags & Frame.FLAG24_COMPRESS: + try: + data = zlib.decompress(data) + except zlib.error as err: + # the initial mutagen that went out with QL 0.12 did not + # write the 4 bytes of uncompressed size. Compensate. + data = datalen_bytes + data + try: + data = zlib.decompress(data) + except zlib.error as err: + raise ID3JunkFrameError( + 'zlib: %s: %r' % (err, data)) + + elif id3.version >= id3._V23: + if tflags & Frame.FLAG23_COMPRESS: + usize, = unpack('>L', data[:4]) + data = data[4:] + if tflags & Frame.FLAG23_ENCRYPT: + raise ID3EncryptionUnsupportedError + if tflags & Frame.FLAG23_COMPRESS: + try: + data = zlib.decompress(data) + except zlib.error as err: + raise ID3JunkFrameError('zlib: %s: %r' % (err, data)) + + frame = cls() + frame._readData(data) + return frame + + def __hash__(self): + raise TypeError("Frame objects are unhashable") + + +class FrameOpt(Frame): + """A frame with optional parts. + + Some ID3 frames have optional data; this class extends Frame to + provide support for those parts. + """ + + _optionalspec = [] + + def __init__(self, *args, **kwargs): + super(FrameOpt, self).__init__(*args, **kwargs) + for spec in self._optionalspec: + if spec.name in kwargs: + validated = spec.validate(self, kwargs[spec.name]) + setattr(self, spec.name, validated) + else: + break + + def _to_other(self, other): + super(FrameOpt, self)._to_other(other) + + # this impl covers subclasses with the same optionalspec + if other._optionalspec is not self._optionalspec: + raise ValueError + + for checker in other._optionalspec: + if hasattr(self, checker.name): + setattr(other, checker.name, getattr(self, checker.name)) + + def _readData(self, data): + """Raises ID3JunkFrameError; Returns leftover data""" + + for reader in self._framespec: + if len(data): + try: + value, data = reader.read(self, data) + except SpecError as e: + raise ID3JunkFrameError(e) + else: + raise ID3JunkFrameError("no data left") + setattr(self, reader.name, value) + + if data: + for reader in self._optionalspec: + if len(data): + try: + value, data = reader.read(self, data) + except SpecError as e: + raise ID3JunkFrameError(e) + else: + break + setattr(self, reader.name, value) + + return data + + def _writeData(self): + data = [] + for writer in self._framespec: + data.append(writer.write(self, getattr(self, writer.name))) + for writer in self._optionalspec: + try: + data.append(writer.write(self, getattr(self, writer.name))) + except AttributeError: + break + return b''.join(data) + + def __repr__(self): + kw = [] + for attr in self._framespec: + kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) + for attr in self._optionalspec: + if hasattr(self, attr.name): + kw.append('%s=%r' % (attr.name, getattr(self, attr.name))) + return '%s(%s)' % (type(self).__name__, ', '.join(kw)) + + +@swap_to_string +class TextFrame(Frame): + """Text strings. + + Text frames support casts to unicode or str objects, as well as + list-like indexing, extend, and append. + + Iterating over a TextFrame iterates over its strings, not its + characters. + + Text frames have a 'text' attribute which is the list of strings, + and an 'encoding' attribute; 0 for ISO-8859 1, 1 UTF-16, 2 for + UTF-16BE, and 3 for UTF-8. If you don't want to worry about + encodings, just set it to 3. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + def __bytes__(self): + return text_type(self).encode('utf-8') + + def __str__(self): + return u'\u0000'.join(self.text) + + def __eq__(self, other): + if isinstance(other, bytes): + return bytes(self) == other + elif isinstance(other, text_type): + return text_type(self) == other + return self.text == other + + __hash__ = Frame.__hash__ + + def __getitem__(self, item): + return self.text[item] + + def __iter__(self): + return iter(self.text) + + def append(self, value): + """Append a string.""" + + return self.text.append(value) + + def extend(self, value): + """Extend the list by appending all strings from the given list.""" + + return self.text.extend(value) + + def _pprint(self): + return " / ".join(self.text) + + +class NumericTextFrame(TextFrame): + """Numerical text strings. + + The numeric value of these frames can be gotten with unary plus, e.g.:: + + frame = TLEN('12345') + length = +frame + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedNumericTextSpec('text'), sep=u'\u0000'), + ] + + def __pos__(self): + """Return the numerical value of the string.""" + return int(self.text[0]) + + +class NumericPartTextFrame(TextFrame): + """Multivalue numerical text strings. + + These strings indicate 'part (e.g. track) X of Y', and unary plus + returns the first value:: + + frame = TRCK('4/15') + track = +frame # track == 4 + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', EncodedNumericPartTextSpec('text'), sep=u'\u0000'), + ] + + def __pos__(self): + return int(self.text[0].split("/")[0]) + + +@swap_to_string +class TimeStampTextFrame(TextFrame): + """A list of time stamps. + + The 'text' attribute in this frame is a list of ID3TimeStamp + objects, not a list of strings. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('text', TimeStampSpec('stamp'), sep=u','), + ] + + def __bytes__(self): + return text_type(self).encode('utf-8') + + def __str__(self): + return u','.join([stamp.text for stamp in self.text]) + + def _pprint(self): + return u" / ".join([stamp.text for stamp in self.text]) + + +@swap_to_string +class UrlFrame(Frame): + """A frame containing a URL string. + + The ID3 specification is silent about IRIs and normalized URL + forms. Mutagen assumes all URLs in files are encoded as Latin 1, + but string conversion of this frame returns a UTF-8 representation + for compatibility with other string conversions. + + The only sane way to handle URLs in MP3s is to restrict them to + ASCII. + """ + + _framespec = [Latin1TextSpec('url')] + + def __bytes__(self): + return self.url.encode('utf-8') + + def __str__(self): + return self.url + + def __eq__(self, other): + return self.url == other + + __hash__ = Frame.__hash__ + + def _pprint(self): + return self.url + + +class UrlFrameU(UrlFrame): + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.url) + + +class TALB(TextFrame): + "Album" + + +class TBPM(NumericTextFrame): + "Beats per minute" + + +class TCOM(TextFrame): + "Composer" + + +class TCON(TextFrame): + """Content type (Genre) + + ID3 has several ways genres can be represented; for convenience, + use the 'genres' property rather than the 'text' attribute. + """ + + from mutagen._constants import GENRES + GENRES = GENRES + + def __get_genres(self): + genres = [] + import re + genre_re = re.compile(r"((?:\((?P[0-9]+|RX|CR)\))*)(?P.+)?") + for value in self.text: + # 255 possible entries in id3v1 + if value.isdigit() and int(value) < 256: + try: + genres.append(self.GENRES[int(value)]) + except IndexError: + genres.append(u"Unknown") + elif value == "CR": + genres.append(u"Cover") + elif value == "RX": + genres.append(u"Remix") + elif value: + newgenres = [] + genreid, dummy, genrename = genre_re.match(value).groups() + + if genreid: + for gid in genreid[1:-1].split(")("): + if gid.isdigit() and int(gid) < len(self.GENRES): + gid = text_type(self.GENRES[int(gid)]) + newgenres.append(gid) + elif gid == "CR": + newgenres.append(u"Cover") + elif gid == "RX": + newgenres.append(u"Remix") + else: + newgenres.append(u"Unknown") + + if genrename: + # "Unescaping" the first parenthesis + if genrename.startswith("(("): + genrename = genrename[1:] + if genrename not in newgenres: + newgenres.append(genrename) + + genres.extend(newgenres) + + return genres + + def __set_genres(self, genres): + if isinstance(genres, string_types): + genres = [genres] + self.text = [self.__decode(g) for g in genres] + + def __decode(self, value): + if isinstance(value, bytes): + enc = EncodedTextSpec._encodings[self.encoding][0] + return value.decode(enc) + else: + return value + + genres = property(__get_genres, __set_genres, None, + "A list of genres parsed from the raw text data.") + + def _pprint(self): + return " / ".join(self.genres) + + +class TCOP(TextFrame): + "Copyright (c)" + + +class TCMP(NumericTextFrame): + "iTunes Compilation Flag" + + +class TDAT(TextFrame): + "Date of recording (DDMM)" + + +class TDEN(TimeStampTextFrame): + "Encoding Time" + + +class TDES(TextFrame): + "iTunes Podcast Description" + + +class TDOR(TimeStampTextFrame): + "Original Release Time" + + +class TDLY(NumericTextFrame): + "Audio Delay (ms)" + + +class TDRC(TimeStampTextFrame): + "Recording Time" + + +class TDRL(TimeStampTextFrame): + "Release Time" + + +class TDTG(TimeStampTextFrame): + "Tagging Time" + + +class TENC(TextFrame): + "Encoder" + + +class TEXT(TextFrame): + "Lyricist" + + +class TFLT(TextFrame): + "File type" + + +class TGID(TextFrame): + "iTunes Podcast Identifier" + + +class TIME(TextFrame): + "Time of recording (HHMM)" + + +class TIT1(TextFrame): + "Content group description" + + +class TIT2(TextFrame): + "Title" + + +class TIT3(TextFrame): + "Subtitle/Description refinement" + + +class TKEY(TextFrame): + "Starting Key" + + +class TLAN(TextFrame): + "Audio Languages" + + +class TLEN(NumericTextFrame): + "Audio Length (ms)" + + +class TMED(TextFrame): + "Source Media Type" + + +class TMOO(TextFrame): + "Mood" + + +class TOAL(TextFrame): + "Original Album" + + +class TOFN(TextFrame): + "Original Filename" + + +class TOLY(TextFrame): + "Original Lyricist" + + +class TOPE(TextFrame): + "Original Artist/Performer" + + +class TORY(NumericTextFrame): + "Original Release Year" + + +class TOWN(TextFrame): + "Owner/Licensee" + + +class TPE1(TextFrame): + "Lead Artist/Performer/Soloist/Group" + + +class TPE2(TextFrame): + "Band/Orchestra/Accompaniment" + + +class TPE3(TextFrame): + "Conductor" + + +class TPE4(TextFrame): + "Interpreter/Remixer/Modifier" + + +class TPOS(NumericPartTextFrame): + "Part of set" + + +class TPRO(TextFrame): + "Produced (P)" + + +class TPUB(TextFrame): + "Publisher" + + +class TRCK(NumericPartTextFrame): + "Track Number" + + +class TRDA(TextFrame): + "Recording Dates" + + +class TRSN(TextFrame): + "Internet Radio Station Name" + + +class TRSO(TextFrame): + "Internet Radio Station Owner" + + +class TSIZ(NumericTextFrame): + "Size of audio data (bytes)" + + +class TSO2(TextFrame): + "iTunes Album Artist Sort" + + +class TSOA(TextFrame): + "Album Sort Order key" + + +class TSOC(TextFrame): + "iTunes Composer Sort" + + +class TSOP(TextFrame): + "Perfomer Sort Order key" + + +class TSOT(TextFrame): + "Title Sort Order key" + + +class TSRC(TextFrame): + "International Standard Recording Code (ISRC)" + + +class TSSE(TextFrame): + "Encoder settings" + + +class TSST(TextFrame): + "Set Subtitle" + + +class TYER(NumericTextFrame): + "Year of recording" + + +class TXXX(TextFrame): + """User-defined text data. + + TXXX frames have a 'desc' attribute which is set to any Unicode + value (though the encoding of the text and the description must be + the same). Many taggers use this frame to store freeform keys. + """ + + _framespec = [ + EncodingSpec('encoding'), + EncodedTextSpec('desc'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def _pprint(self): + return "%s=%s" % (self.desc, " / ".join(self.text)) + + +class WCOM(UrlFrameU): + "Commercial Information" + + +class WCOP(UrlFrame): + "Copyright Information" + + +class WFED(UrlFrame): + "iTunes Podcast Feed" + + +class WOAF(UrlFrame): + "Official File Information" + + +class WOAR(UrlFrameU): + "Official Artist/Performer Information" + + +class WOAS(UrlFrame): + "Official Source Information" + + +class WORS(UrlFrame): + "Official Internet Radio Information" + + +class WPAY(UrlFrame): + "Payment Information" + + +class WPUB(UrlFrame): + "Official Publisher Information" + + +class WXXX(UrlFrame): + """User-defined URL data. + + Like TXXX, this has a freeform description associated with it. + """ + + _framespec = [ + EncodingSpec('encoding'), + EncodedTextSpec('desc'), + Latin1TextSpec('url'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + +class PairedTextFrame(Frame): + """Paired text strings. + + Some ID3 frames pair text strings, to associate names with a more + specific involvement in the song. The 'people' attribute of these + frames contains a list of pairs:: + + [['trumpet', 'Miles Davis'], ['bass', 'Paul Chambers']] + + Like text frames, these frames also have an encoding attribute. + """ + + _framespec = [ + EncodingSpec('encoding'), + MultiSpec('people', + EncodedTextSpec('involvement'), + EncodedTextSpec('person')) + ] + + def __eq__(self, other): + return self.people == other + + __hash__ = Frame.__hash__ + + +class TIPL(PairedTextFrame): + "Involved People List" + + +class TMCL(PairedTextFrame): + "Musicians Credits List" + + +class IPLS(TIPL): + "Involved People List" + + +class BinaryFrame(Frame): + """Binary data + + The 'data' attribute contains the raw byte string. + """ + + _framespec = [BinaryDataSpec('data')] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class MCDI(BinaryFrame): + "Binary dump of CD's TOC" + + +class ETCO(Frame): + """Event timing codes.""" + + _framespec = [ + ByteSpec("format"), + KeyEventSpec("events"), + ] + + def __eq__(self, other): + return self.events == other + + __hash__ = Frame.__hash__ + + +class MLLT(Frame): + """MPEG location lookup table. + + This frame's attributes may be changed in the future based on + feedback from real-world use. + """ + + _framespec = [ + SizedIntegerSpec('frames', 2), + SizedIntegerSpec('bytes', 3), + SizedIntegerSpec('milliseconds', 3), + ByteSpec('bits_for_bytes'), + ByteSpec('bits_for_milliseconds'), + BinaryDataSpec('data'), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class SYTC(Frame): + """Synchronised tempo codes. + + This frame's attributes may be changed in the future based on + feedback from real-world use. + """ + + _framespec = [ + ByteSpec("format"), + BinaryDataSpec("data"), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class USLT(Frame): + """Unsynchronised lyrics/text transcription. + + Lyrics have a three letter ISO language code ('lang'), a + description ('desc'), and a block of plain text ('text'). + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('desc'), + EncodedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def __bytes__(self): + return self.text.encode('utf-8') + + def __str__(self): + return self.text + + def __eq__(self, other): + return self.text == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class SYLT(Frame): + """Synchronised lyrics/text.""" + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + ByteSpec('format'), + ByteSpec('type'), + EncodedTextSpec('desc'), + SynchronizedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def __eq__(self, other): + return str(self) == other + + __hash__ = Frame.__hash__ + + def __str__(self): + return u"".join(text for (text, time) in self.text) + + def __bytes__(self): + return text_type(self).encode("utf-8") + + +class COMM(TextFrame): + """User comment. + + User comment frames have a descrption, like TXXX, and also a three + letter ISO language code in the 'lang' attribute. + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('desc'), + MultiSpec('text', EncodedTextSpec('text'), sep=u'\u0000'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.desc, self.lang) + + def _pprint(self): + return "%s=%s=%s" % (self.desc, self.lang, " / ".join(self.text)) + + +class RVA2(Frame): + """Relative volume adjustment (2). + + This frame is used to implemented volume scaling, and in + particular, normalization using ReplayGain. + + Attributes: + + * desc -- description or context of this adjustment + * channel -- audio channel to adjust (master is 1) + * gain -- a + or - dB gain relative to some reference level + * peak -- peak of the audio as a floating point number, [0, 1] + + When storing ReplayGain tags, use descriptions of 'album' and + 'track' on channel 1. + """ + + _framespec = [ + Latin1TextSpec('desc'), + ChannelSpec('channel'), + VolumeAdjustmentSpec('gain'), + VolumePeakSpec('peak'), + ] + + _channels = ["Other", "Master volume", "Front right", "Front left", + "Back right", "Back left", "Front centre", "Back centre", + "Subwoofer"] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def __eq__(self, other): + try: + return ((str(self) == other) or + (self.desc == other.desc and + self.channel == other.channel and + self.gain == other.gain and + self.peak == other.peak)) + except AttributeError: + return False + + __hash__ = Frame.__hash__ + + def __str__(self): + return "%s: %+0.4f dB/%0.4f" % ( + self._channels[self.channel], self.gain, self.peak) + + +class EQU2(Frame): + """Equalisation (2). + + Attributes: + method -- interpolation method (0 = band, 1 = linear) + desc -- identifying description + adjustments -- list of (frequency, vol_adjustment) pairs + """ + + _framespec = [ + ByteSpec("method"), + Latin1TextSpec("desc"), + VolumeAdjustmentsSpec("adjustments"), + ] + + def __eq__(self, other): + return self.adjustments == other + + __hash__ = Frame.__hash__ + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + +# class RVAD: unsupported +# class EQUA: unsupported + + +class RVRB(Frame): + """Reverb.""" + + _framespec = [ + SizedIntegerSpec('left', 2), + SizedIntegerSpec('right', 2), + ByteSpec('bounce_left'), + ByteSpec('bounce_right'), + ByteSpec('feedback_ltl'), + ByteSpec('feedback_ltr'), + ByteSpec('feedback_rtr'), + ByteSpec('feedback_rtl'), + ByteSpec('premix_ltr'), + ByteSpec('premix_rtl'), + ] + + def __eq__(self, other): + return (self.left, self.right) == other + + __hash__ = Frame.__hash__ + + +class APIC(Frame): + """Attached (or linked) Picture. + + Attributes: + + * encoding -- text encoding for the description + * mime -- a MIME type (e.g. image/jpeg) or '-->' if the data is a URI + * type -- the source of the image (3 is the album front cover) + * desc -- a text description of the image + * data -- raw image data, as a byte string + + Mutagen will automatically compress large images when saving tags. + """ + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('mime'), + ByteSpec('type'), + EncodedTextSpec('desc'), + BinaryDataSpec('data'), + ] + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def _validate_from_22(self, other, checker): + if checker.name == "mime": + self.mime = other.mime.decode("ascii", "ignore") + else: + super(APIC, self)._validate_from_22(other, checker) + + def _pprint(self): + return "%s (%s, %d bytes)" % ( + self.desc, self.mime, len(self.data)) + + +class PCNT(Frame): + """Play counter. + + The 'count' attribute contains the (recorded) number of times this + file has been played. + + This frame is basically obsoleted by POPM. + """ + + _framespec = [IntegerSpec('count')] + + def __eq__(self, other): + return self.count == other + + __hash__ = Frame.__hash__ + + def __pos__(self): + return self.count + + def _pprint(self): + return text_type(self.count) + + +class POPM(FrameOpt): + """Popularimeter. + + This frame keys a rating (out of 255) and a play count to an email + address. + + Attributes: + + * email -- email this POPM frame is for + * rating -- rating from 0 to 255 + * count -- number of times the files has been played (optional) + """ + + _framespec = [ + Latin1TextSpec('email'), + ByteSpec('rating'), + ] + + _optionalspec = [IntegerSpec('count')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.email) + + def __eq__(self, other): + return self.rating == other + + __hash__ = FrameOpt.__hash__ + + def __pos__(self): + return self.rating + + def _pprint(self): + return "%s=%r %r/255" % ( + self.email, getattr(self, 'count', None), self.rating) + + +class GEOB(Frame): + """General Encapsulated Object. + + A blob of binary data, that is not a picture (those go in APIC). + + Attributes: + + * encoding -- encoding of the description + * mime -- MIME type of the data or '-->' if the data is a URI + * filename -- suggested filename if extracted + * desc -- text description of the data + * data -- raw data, as a byte string + """ + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('mime'), + EncodedTextSpec('filename'), + EncodedTextSpec('desc'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.desc) + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +class RBUF(FrameOpt): + """Recommended buffer size. + + Attributes: + + * size -- recommended buffer size in bytes + * info -- if ID3 tags may be elsewhere in the file (optional) + * offset -- the location of the next ID3 tag, if any + + Mutagen will not find the next tag itself. + """ + + _framespec = [SizedIntegerSpec('size', 3)] + + _optionalspec = [ + ByteSpec('info'), + SizedIntegerSpec('offset', 4), + ] + + def __eq__(self, other): + return self.size == other + + __hash__ = FrameOpt.__hash__ + + def __pos__(self): + return self.size + + +@swap_to_string +class AENC(FrameOpt): + """Audio encryption. + + Attributes: + + * owner -- key identifying this encryption type + * preview_start -- unencrypted data block offset + * preview_length -- number of unencrypted blocks + * data -- data required for decryption (optional) + + Mutagen cannot decrypt files. + """ + + _framespec = [ + Latin1TextSpec('owner'), + SizedIntegerSpec('preview_start', 2), + SizedIntegerSpec('preview_length', 2), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.owner) + + def __bytes__(self): + return self.owner.encode('utf-8') + + def __str__(self): + return self.owner + + def __eq__(self, other): + return self.owner == other + + __hash__ = FrameOpt.__hash__ + + +class LINK(FrameOpt): + """Linked information. + + Attributes: + + * frameid -- the ID of the linked frame + * url -- the location of the linked frame + * data -- further ID information for the frame + """ + + _framespec = [ + StringSpec('frameid', 4), + Latin1TextSpec('url'), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + try: + return "%s:%s:%s:%s" % ( + self.FrameID, self.frameid, self.url, _bytes2key(self.data)) + except AttributeError: + return "%s:%s:%s" % (self.FrameID, self.frameid, self.url) + + def __eq__(self, other): + try: + return (self.frameid, self.url, self.data) == other + except AttributeError: + return (self.frameid, self.url) == other + + __hash__ = FrameOpt.__hash__ + + +class POSS(Frame): + """Position synchronisation frame + + Attribute: + + * format -- format of the position attribute (frames or milliseconds) + * position -- current position of the file + """ + + _framespec = [ + ByteSpec('format'), + IntegerSpec('position'), + ] + + def __pos__(self): + return self.position + + def __eq__(self, other): + return self.position == other + + __hash__ = Frame.__hash__ + + +class UFID(Frame): + """Unique file identifier. + + Attributes: + + * owner -- format/type of identifier + * data -- identifier + """ + + _framespec = [ + Latin1TextSpec('owner'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.owner) + + def __eq__(s, o): + if isinstance(o, UFI): + return s.owner == o.owner and s.data == o.data + else: + return s.data == o + + __hash__ = Frame.__hash__ + + def _pprint(self): + return "%s=%r" % (self.owner, self.data) + + +@swap_to_string +class USER(Frame): + """Terms of use. + + Attributes: + + * encoding -- text encoding + * lang -- ISO three letter language code + * text -- licensing terms for the audio + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('lang', 3), + EncodedTextSpec('text'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.lang) + + def __bytes__(self): + return self.text.encode('utf-8') + + def __str__(self): + return self.text + + def __eq__(self, other): + return self.text == other + + __hash__ = Frame.__hash__ + + def _pprint(self): + return "%r=%s" % (self.lang, self.text) + + +@swap_to_string +class OWNE(Frame): + """Ownership frame.""" + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('price'), + StringSpec('date', 8), + EncodedTextSpec('seller'), + ] + + def __bytes__(self): + return self.seller.encode('utf-8') + + def __str__(self): + return self.seller + + def __eq__(self, other): + return self.seller == other + + __hash__ = Frame.__hash__ + + +class COMR(FrameOpt): + """Commercial frame.""" + + _framespec = [ + EncodingSpec('encoding'), + Latin1TextSpec('price'), + StringSpec('valid_until', 8), + Latin1TextSpec('contact'), + ByteSpec('format'), + EncodedTextSpec('seller'), + EncodedTextSpec('desc'), + ] + + _optionalspec = [ + Latin1TextSpec('mime'), + BinaryDataSpec('logo'), + ] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, _bytes2key(self._writeData())) + + def __eq__(self, other): + return self._writeData() == other._writeData() + + __hash__ = FrameOpt.__hash__ + + +@swap_to_string +class ENCR(Frame): + """Encryption method registration. + + The standard does not allow multiple ENCR frames with the same owner + or the same method. Mutagen only verifies that the owner is unique. + """ + + _framespec = [ + Latin1TextSpec('owner'), + ByteSpec('method'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return "%s:%s" % (self.FrameID, self.owner) + + def __bytes__(self): + return self.data + + def __eq__(self, other): + return self.data == other + + __hash__ = Frame.__hash__ + + +@swap_to_string +class GRID(FrameOpt): + """Group identification registration.""" + + _framespec = [ + Latin1TextSpec('owner'), + ByteSpec('group'), + ] + + _optionalspec = [BinaryDataSpec('data')] + + @property + def HashKey(self): + return '%s:%s' % (self.FrameID, self.group) + + def __pos__(self): + return self.group + + def __bytes__(self): + return self.owner.encode('utf-8') + + def __str__(self): + return self.owner + + def __eq__(self, other): + return self.owner == other or self.group == other + + __hash__ = FrameOpt.__hash__ + + +@swap_to_string +class PRIV(Frame): + """Private frame.""" + + _framespec = [ + Latin1TextSpec('owner'), + BinaryDataSpec('data'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % ( + self.FrameID, self.owner, _bytes2key(self.data)) + + def __bytes__(self): + return self.data + + def __eq__(self, other): + return self.data == other + + def _pprint(self): + return "%s=%r" % (self.owner, self.data) + + __hash__ = Frame.__hash__ + + +@swap_to_string +class SIGN(Frame): + """Signature frame.""" + + _framespec = [ + ByteSpec('group'), + BinaryDataSpec('sig'), + ] + + @property + def HashKey(self): + return '%s:%s:%s' % (self.FrameID, self.group, _bytes2key(self.sig)) + + def __bytes__(self): + return self.sig + + def __eq__(self, other): + return self.sig == other + + __hash__ = Frame.__hash__ + + +class SEEK(Frame): + """Seek frame. + + Mutagen does not find tags at seek offsets. + """ + + _framespec = [IntegerSpec('offset')] + + def __pos__(self): + return self.offset + + def __eq__(self, other): + return self.offset == other + + __hash__ = Frame.__hash__ + + +class ASPI(Frame): + """Audio seek point index. + + Attributes: S, L, N, b, and Fi. For the meaning of these, see + the ID3v2.4 specification. Fi is a list of integers. + """ + _framespec = [ + SizedIntegerSpec("S", 4), + SizedIntegerSpec("L", 4), + SizedIntegerSpec("N", 2), + ByteSpec("b"), + ASPIIndexSpec("Fi"), + ] + + def __eq__(self, other): + return self.Fi == other + + __hash__ = Frame.__hash__ + + +# ID3v2.2 frames +class UFI(UFID): + "Unique File Identifier" + + +class TT1(TIT1): + "Content group description" + + +class TT2(TIT2): + "Title" + + +class TT3(TIT3): + "Subtitle/Description refinement" + + +class TP1(TPE1): + "Lead Artist/Performer/Soloist/Group" + + +class TP2(TPE2): + "Band/Orchestra/Accompaniment" + + +class TP3(TPE3): + "Conductor" + + +class TP4(TPE4): + "Interpreter/Remixer/Modifier" + + +class TCM(TCOM): + "Composer" + + +class TXT(TEXT): + "Lyricist" + + +class TLA(TLAN): + "Audio Language(s)" + + +class TCO(TCON): + "Content Type (Genre)" + + +class TAL(TALB): + "Album" + + +class TPA(TPOS): + "Part of set" + + +class TRK(TRCK): + "Track Number" + + +class TRC(TSRC): + "International Standard Recording Code (ISRC)" + + +class TYE(TYER): + "Year of recording" + + +class TDA(TDAT): + "Date of recording (DDMM)" + + +class TIM(TIME): + "Time of recording (HHMM)" + + +class TRD(TRDA): + "Recording Dates" + + +class TMT(TMED): + "Source Media Type" + + +class TFT(TFLT): + "File Type" + + +class TBP(TBPM): + "Beats per minute" + + +class TCP(TCMP): + "iTunes Compilation Flag" + + +class TCR(TCOP): + "Copyright (C)" + + +class TPB(TPUB): + "Publisher" + + +class TEN(TENC): + "Encoder" + + +class TSS(TSSE): + "Encoder settings" + + +class TOF(TOFN): + "Original Filename" + + +class TLE(TLEN): + "Audio Length (ms)" + + +class TSI(TSIZ): + "Audio Data size (bytes)" + + +class TDY(TDLY): + "Audio Delay (ms)" + + +class TKE(TKEY): + "Starting Key" + + +class TOT(TOAL): + "Original Album" + + +class TOA(TOPE): + "Original Artist/Perfomer" + + +class TOL(TOLY): + "Original Lyricist" + + +class TOR(TORY): + "Original Release Year" + + +class TXX(TXXX): + "User-defined Text" + + +class WAF(WOAF): + "Official File Information" + + +class WAR(WOAR): + "Official Artist/Performer Information" + + +class WAS(WOAS): + "Official Source Information" + + +class WCM(WCOM): + "Commercial Information" + + +class WCP(WCOP): + "Copyright Information" + + +class WPB(WPUB): + "Official Publisher Information" + + +class WXX(WXXX): + "User-defined URL" + + +class IPL(IPLS): + "Involved people list" + + +class MCI(MCDI): + "Binary dump of CD's TOC" + + +class ETC(ETCO): + "Event timing codes" + + +class MLL(MLLT): + "MPEG location lookup table" + + +class STC(SYTC): + "Synced tempo codes" + + +class ULT(USLT): + "Unsychronised lyrics/text transcription" + + +class SLT(SYLT): + "Synchronised lyrics/text" + + +class COM(COMM): + "Comment" + + +# class RVA(RVAD) +# class EQU(EQUA) + + +class REV(RVRB): + "Reverb" + + +class PIC(APIC): + """Attached Picture. + + The 'mime' attribute of an ID3v2.2 attached picture must be either + 'PNG' or 'JPG'. + """ + + _framespec = [ + EncodingSpec('encoding'), + StringSpec('mime', 3), + ByteSpec('type'), + EncodedTextSpec('desc'), + BinaryDataSpec('data') + ] + + def _to_other(self, other): + if not isinstance(other, APIC): + raise TypeError + + other.encoding = self.encoding + other.mime = self.mime + other.type = self.type + other.desc = self.desc + other.data = self.data + + +class GEO(GEOB): + "General Encapsulated Object" + + +class CNT(PCNT): + "Play counter" + + +class POP(POPM): + "Popularimeter" + + +class BUF(RBUF): + "Recommended buffer size" + + +class CRM(Frame): + """Encrypted meta frame""" + _framespec = [Latin1TextSpec('owner'), Latin1TextSpec('desc'), + BinaryDataSpec('data')] + + def __eq__(self, other): + return self.data == other + __hash__ = Frame.__hash__ + + +class CRA(AENC): + "Audio encryption" + + +class LNK(LINK): + """Linked information""" + + _framespec = [ + StringSpec('frameid', 3), + Latin1TextSpec('url') + ] + + _optionalspec = [BinaryDataSpec('data')] + + def _to_other(self, other): + if not isinstance(other, LINK): + raise TypeError + + other.frameid = self.frameid + other.url = self.url + if hasattr(self, "data"): + other.data = self.data + + +Frames = {} +"""All supported ID3v2.3/4 frames, keyed by frame name.""" + + +Frames_2_2 = {} +"""All supported ID3v2.2 frames, keyed by frame name.""" + + +k, v = None, None +for k, v in iteritems(globals()): + if isinstance(v, type) and issubclass(v, Frame): + v.__module__ = "mutagen.id3" + + if len(k) == 3: + Frames_2_2[k] = v + elif len(k) == 4: + Frames[k] = v + +try: + del k + del v +except NameError: + pass diff --git a/resources/lib/mutagen/id3/_specs.py b/resources/lib/mutagen/id3/_specs.py new file mode 100644 index 00000000..4358a65d --- /dev/null +++ b/resources/lib/mutagen/id3/_specs.py @@ -0,0 +1,635 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +import struct +from struct import unpack, pack + +from .._compat import text_type, chr_, PY3, swap_to_string, string_types, \ + xrange +from .._util import total_ordering, decode_terminated, enum, izip +from ._util import BitPaddedInt + + +@enum +class PictureType(object): + """Enumeration of image types defined by the ID3 standard for the APIC + frame, but also reused in WMA/FLAC/VorbisComment. + """ + + OTHER = 0 + """Other""" + + FILE_ICON = 1 + """32x32 pixels 'file icon' (PNG only)""" + + OTHER_FILE_ICON = 2 + """Other file icon""" + + COVER_FRONT = 3 + """Cover (front)""" + + COVER_BACK = 4 + """Cover (back)""" + + LEAFLET_PAGE = 5 + """Leaflet page""" + + MEDIA = 6 + """Media (e.g. label side of CD)""" + + LEAD_ARTIST = 7 + """Lead artist/lead performer/soloist""" + + ARTIST = 8 + """Artist/performer""" + + CONDUCTOR = 9 + """Conductor""" + + BAND = 10 + """Band/Orchestra""" + + COMPOSER = 11 + """Composer""" + + LYRICIST = 12 + """Lyricist/text writer""" + + RECORDING_LOCATION = 13 + """Recording Location""" + + DURING_RECORDING = 14 + """During recording""" + + DURING_PERFORMANCE = 15 + """During performance""" + + SCREEN_CAPTURE = 16 + """Movie/video screen capture""" + + FISH = 17 + """A bright coloured fish""" + + ILLUSTRATION = 18 + """Illustration""" + + BAND_LOGOTYPE = 19 + """Band/artist logotype""" + + PUBLISHER_LOGOTYPE = 20 + """Publisher/Studio logotype""" + + +class SpecError(Exception): + pass + + +class Spec(object): + + def __init__(self, name): + self.name = name + + def __hash__(self): + raise TypeError("Spec objects are unhashable") + + def _validate23(self, frame, value, **kwargs): + """Return a possibly modified value which, if written, + results in valid id3v2.3 data. + """ + + return value + + def read(self, frame, data): + """Returns the (value, left_data) or raises SpecError""" + + raise NotImplementedError + + def write(self, frame, value): + raise NotImplementedError + + def validate(self, frame, value): + """Returns the validated data or raises ValueError/TypeError""" + + raise NotImplementedError + + +class ByteSpec(Spec): + def read(self, frame, data): + return bytearray(data)[0], data[1:] + + def write(self, frame, value): + return chr_(value) + + def validate(self, frame, value): + if value is not None: + chr_(value) + return value + + +class IntegerSpec(Spec): + def read(self, frame, data): + return int(BitPaddedInt(data, bits=8)), b'' + + def write(self, frame, value): + return BitPaddedInt.to_str(value, bits=8, width=-1) + + def validate(self, frame, value): + return value + + +class SizedIntegerSpec(Spec): + def __init__(self, name, size): + self.name, self.__sz = name, size + + def read(self, frame, data): + return int(BitPaddedInt(data[:self.__sz], bits=8)), data[self.__sz:] + + def write(self, frame, value): + return BitPaddedInt.to_str(value, bits=8, width=self.__sz) + + def validate(self, frame, value): + return value + + +@enum +class Encoding(object): + """Text Encoding""" + + LATIN1 = 0 + """ISO-8859-1""" + + UTF16 = 1 + """UTF-16 with BOM""" + + UTF16BE = 2 + """UTF-16BE without BOM""" + + UTF8 = 3 + """UTF-8""" + + +class EncodingSpec(ByteSpec): + + def read(self, frame, data): + enc, data = super(EncodingSpec, self).read(frame, data) + if enc not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE, + Encoding.UTF8): + raise SpecError('Invalid Encoding: %r' % enc) + return enc, data + + def validate(self, frame, value): + if value is None: + return None + if value not in (Encoding.LATIN1, Encoding.UTF16, Encoding.UTF16BE, + Encoding.UTF8): + raise ValueError('Invalid Encoding: %r' % value) + return value + + def _validate23(self, frame, value, **kwargs): + # only 0, 1 are valid in v2.3, default to utf-16 + if value not in (Encoding.LATIN1, Encoding.UTF16): + value = Encoding.UTF16 + return value + + +class StringSpec(Spec): + """A fixed size ASCII only payload.""" + + def __init__(self, name, length): + super(StringSpec, self).__init__(name) + self.len = length + + def read(s, frame, data): + chunk = data[:s.len] + try: + ascii = chunk.decode("ascii") + except UnicodeDecodeError: + raise SpecError("not ascii") + else: + if PY3: + chunk = ascii + + return chunk, data[s.len:] + + def write(s, frame, value): + if value is None: + return b'\x00' * s.len + else: + if PY3: + value = value.encode("ascii") + return (bytes(value) + b'\x00' * s.len)[:s.len] + + def validate(s, frame, value): + if value is None: + return None + + if PY3: + if not isinstance(value, str): + raise TypeError("%s has to be str" % s.name) + value.encode("ascii") + else: + if not isinstance(value, bytes): + value = value.encode("ascii") + + if len(value) == s.len: + return value + + raise ValueError('Invalid StringSpec[%d] data: %r' % (s.len, value)) + + +class BinaryDataSpec(Spec): + def read(self, frame, data): + return data, b'' + + def write(self, frame, value): + if value is None: + return b"" + if isinstance(value, bytes): + return value + value = text_type(value).encode("ascii") + return value + + def validate(self, frame, value): + if value is None: + return None + + if isinstance(value, bytes): + return value + elif PY3: + raise TypeError("%s has to be bytes" % self.name) + + value = text_type(value).encode("ascii") + return value + + +class EncodedTextSpec(Spec): + + _encodings = { + Encoding.LATIN1: ('latin1', b'\x00'), + Encoding.UTF16: ('utf16', b'\x00\x00'), + Encoding.UTF16BE: ('utf_16_be', b'\x00\x00'), + Encoding.UTF8: ('utf8', b'\x00'), + } + + def read(self, frame, data): + enc, term = self._encodings[frame.encoding] + try: + # allow missing termination + return decode_terminated(data, enc, strict=False) + except ValueError: + # utf-16 termination with missing BOM, or single NULL + if not data[:len(term)].strip(b"\x00"): + return u"", data[len(term):] + + # utf-16 data with single NULL, see issue 169 + try: + return decode_terminated(data + b"\x00", enc) + except ValueError: + raise SpecError("Decoding error") + + def write(self, frame, value): + enc, term = self._encodings[frame.encoding] + return value.encode(enc) + term + + def validate(self, frame, value): + return text_type(value) + + +class MultiSpec(Spec): + def __init__(self, name, *specs, **kw): + super(MultiSpec, self).__init__(name) + self.specs = specs + self.sep = kw.get('sep') + + def read(self, frame, data): + values = [] + while data: + record = [] + for spec in self.specs: + value, data = spec.read(frame, data) + record.append(value) + if len(self.specs) != 1: + values.append(record) + else: + values.append(record[0]) + return values, data + + def write(self, frame, value): + data = [] + if len(self.specs) == 1: + for v in value: + data.append(self.specs[0].write(frame, v)) + else: + for record in value: + for v, s in izip(record, self.specs): + data.append(s.write(frame, v)) + return b''.join(data) + + def validate(self, frame, value): + if value is None: + return [] + if self.sep and isinstance(value, string_types): + value = value.split(self.sep) + if isinstance(value, list): + if len(self.specs) == 1: + return [self.specs[0].validate(frame, v) for v in value] + else: + return [ + [s.validate(frame, v) for (v, s) in izip(val, self.specs)] + for val in value] + raise ValueError('Invalid MultiSpec data: %r' % value) + + def _validate23(self, frame, value, **kwargs): + if len(self.specs) != 1: + return [[s._validate23(frame, v, **kwargs) + for (v, s) in izip(val, self.specs)] + for val in value] + + spec = self.specs[0] + + # Merge single text spec multispecs only. + # (TimeStampSpec beeing the exception, but it's not a valid v2.3 frame) + if not isinstance(spec, EncodedTextSpec) or \ + isinstance(spec, TimeStampSpec): + return value + + value = [spec._validate23(frame, v, **kwargs) for v in value] + if kwargs.get("sep") is not None: + return [spec.validate(frame, kwargs["sep"].join(value))] + return value + + +class EncodedNumericTextSpec(EncodedTextSpec): + pass + + +class EncodedNumericPartTextSpec(EncodedTextSpec): + pass + + +class Latin1TextSpec(EncodedTextSpec): + def read(self, frame, data): + if b'\x00' in data: + data, ret = data.split(b'\x00', 1) + else: + ret = b'' + return data.decode('latin1'), ret + + def write(self, data, value): + return value.encode('latin1') + b'\x00' + + def validate(self, frame, value): + return text_type(value) + + +@swap_to_string +@total_ordering +class ID3TimeStamp(object): + """A time stamp in ID3v2 format. + + This is a restricted form of the ISO 8601 standard; time stamps + take the form of: + YYYY-MM-DD HH:MM:SS + Or some partial form (YYYY-MM-DD HH, YYYY, etc.). + + The 'text' attribute contains the raw text data of the time stamp. + """ + + import re + + def __init__(self, text): + if isinstance(text, ID3TimeStamp): + text = text.text + elif not isinstance(text, text_type): + if PY3: + raise TypeError("not a str") + text = text.decode("utf-8") + + self.text = text + + __formats = ['%04d'] + ['%02d'] * 5 + __seps = ['-', '-', ' ', ':', ':', 'x'] + + def get_text(self): + parts = [self.year, self.month, self.day, + self.hour, self.minute, self.second] + pieces = [] + for i, part in enumerate(parts): + if part is None: + break + pieces.append(self.__formats[i] % part + self.__seps[i]) + return u''.join(pieces)[:-1] + + def set_text(self, text, splitre=re.compile('[-T:/.]|\s+')): + year, month, day, hour, minute, second = \ + splitre.split(text + ':::::')[:6] + for a in 'year month day hour minute second'.split(): + try: + v = int(locals()[a]) + except ValueError: + v = None + setattr(self, a, v) + + text = property(get_text, set_text, doc="ID3v2.4 date and time.") + + def __str__(self): + return self.text + + def __bytes__(self): + return self.text.encode("utf-8") + + def __repr__(self): + return repr(self.text) + + def __eq__(self, other): + return self.text == other.text + + def __lt__(self, other): + return self.text < other.text + + __hash__ = object.__hash__ + + def encode(self, *args): + return self.text.encode(*args) + + +class TimeStampSpec(EncodedTextSpec): + def read(self, frame, data): + value, data = super(TimeStampSpec, self).read(frame, data) + return self.validate(frame, value), data + + def write(self, frame, data): + return super(TimeStampSpec, self).write(frame, + data.text.replace(' ', 'T')) + + def validate(self, frame, value): + try: + return ID3TimeStamp(value) + except TypeError: + raise ValueError("Invalid ID3TimeStamp: %r" % value) + + +class ChannelSpec(ByteSpec): + (OTHER, MASTER, FRONTRIGHT, FRONTLEFT, BACKRIGHT, BACKLEFT, FRONTCENTRE, + BACKCENTRE, SUBWOOFER) = xrange(9) + + +class VolumeAdjustmentSpec(Spec): + def read(self, frame, data): + value, = unpack('>h', data[0:2]) + return value / 512.0, data[2:] + + def write(self, frame, value): + number = int(round(value * 512)) + # pack only fails in 2.7, do it manually in 2.6 + if not -32768 <= number <= 32767: + raise SpecError("not in range") + return pack('>h', number) + + def validate(self, frame, value): + if value is not None: + try: + self.write(frame, value) + except SpecError: + raise ValueError("out of range") + return value + + +class VolumePeakSpec(Spec): + def read(self, frame, data): + # http://bugs.xmms.org/attachment.cgi?id=113&action=view + peak = 0 + data_array = bytearray(data) + bits = data_array[0] + vol_bytes = min(4, (bits + 7) >> 3) + # not enough frame data + if vol_bytes + 1 > len(data): + raise SpecError("not enough frame data") + shift = ((8 - (bits & 7)) & 7) + (4 - vol_bytes) * 8 + for i in xrange(1, vol_bytes + 1): + peak *= 256 + peak += data_array[i] + peak *= 2 ** shift + return (float(peak) / (2 ** 31 - 1)), data[1 + vol_bytes:] + + def write(self, frame, value): + number = int(round(value * 32768)) + # pack only fails in 2.7, do it manually in 2.6 + if not 0 <= number <= 65535: + raise SpecError("not in range") + # always write as 16 bits for sanity. + return b"\x10" + pack('>H', number) + + def validate(self, frame, value): + if value is not None: + try: + self.write(frame, value) + except SpecError: + raise ValueError("out of range") + return value + + +class SynchronizedTextSpec(EncodedTextSpec): + def read(self, frame, data): + texts = [] + encoding, term = self._encodings[frame.encoding] + while data: + try: + value, data = decode_terminated(data, encoding) + except ValueError: + raise SpecError("decoding error") + + if len(data) < 4: + raise SpecError("not enough data") + time, = struct.unpack(">I", data[:4]) + + texts.append((value, time)) + data = data[4:] + return texts, b"" + + def write(self, frame, value): + data = [] + encoding, term = self._encodings[frame.encoding] + for text, time in value: + text = text.encode(encoding) + term + data.append(text + struct.pack(">I", time)) + return b"".join(data) + + def validate(self, frame, value): + return value + + +class KeyEventSpec(Spec): + def read(self, frame, data): + events = [] + while len(data) >= 5: + events.append(struct.unpack(">bI", data[:5])) + data = data[5:] + return events, data + + def write(self, frame, value): + return b"".join(struct.pack(">bI", *event) for event in value) + + def validate(self, frame, value): + return value + + +class VolumeAdjustmentsSpec(Spec): + # Not to be confused with VolumeAdjustmentSpec. + def read(self, frame, data): + adjustments = {} + while len(data) >= 4: + freq, adj = struct.unpack(">Hh", data[:4]) + data = data[4:] + freq /= 2.0 + adj /= 512.0 + adjustments[freq] = adj + adjustments = sorted(adjustments.items()) + return adjustments, data + + def write(self, frame, value): + value.sort() + return b"".join(struct.pack(">Hh", int(freq * 2), int(adj * 512)) + for (freq, adj) in value) + + def validate(self, frame, value): + return value + + +class ASPIIndexSpec(Spec): + def read(self, frame, data): + if frame.b == 16: + format = "H" + size = 2 + elif frame.b == 8: + format = "B" + size = 1 + else: + raise SpecError("invalid bit count in ASPI (%d)" % frame.b) + + indexes = data[:frame.N * size] + data = data[frame.N * size:] + try: + return list(struct.unpack(">" + format * frame.N, indexes)), data + except struct.error as e: + raise SpecError(e) + + def write(self, frame, values): + if frame.b == 16: + format = "H" + elif frame.b == 8: + format = "B" + else: + raise SpecError("frame.b must be 8 or 16") + try: + return struct.pack(">" + format * frame.N, *values) + except struct.error as e: + raise SpecError(e) + + def validate(self, frame, values): + return values diff --git a/resources/lib/mutagen/id3/_util.py b/resources/lib/mutagen/id3/_util.py new file mode 100644 index 00000000..29f7241d --- /dev/null +++ b/resources/lib/mutagen/id3/_util.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2005 Michael Urman +# 2013 Christoph Reiter +# 2014 Ben Ockmore +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +from .._compat import long_, integer_types, PY3 +from .._util import MutagenError + + +class error(MutagenError): + pass + + +class ID3NoHeaderError(error, ValueError): + pass + + +class ID3UnsupportedVersionError(error, NotImplementedError): + pass + + +class ID3EncryptionUnsupportedError(error, NotImplementedError): + pass + + +class ID3JunkFrameError(error, ValueError): + pass + + +class unsynch(object): + @staticmethod + def decode(value): + fragments = bytearray(value).split(b'\xff') + if len(fragments) > 1 and not fragments[-1]: + raise ValueError('string ended unsafe') + + for f in fragments[1:]: + if (not f) or (f[0] >= 0xE0): + raise ValueError('invalid sync-safe string') + + if f[0] == 0x00: + del f[0] + + return bytes(bytearray(b'\xff').join(fragments)) + + @staticmethod + def encode(value): + fragments = bytearray(value).split(b'\xff') + for f in fragments[1:]: + if (not f) or (f[0] >= 0xE0) or (f[0] == 0x00): + f.insert(0, 0x00) + return bytes(bytearray(b'\xff').join(fragments)) + + +class _BitPaddedMixin(object): + + def as_str(self, width=4, minwidth=4): + return self.to_str(self, self.bits, self.bigendian, width, minwidth) + + @staticmethod + def to_str(value, bits=7, bigendian=True, width=4, minwidth=4): + mask = (1 << bits) - 1 + + if width != -1: + index = 0 + bytes_ = bytearray(width) + try: + while value: + bytes_[index] = value & mask + value >>= bits + index += 1 + except IndexError: + raise ValueError('Value too wide (>%d bytes)' % width) + else: + # PCNT and POPM use growing integers + # of at least 4 bytes (=minwidth) as counters. + bytes_ = bytearray() + append = bytes_.append + while value: + append(value & mask) + value >>= bits + bytes_ = bytes_.ljust(minwidth, b"\x00") + + if bigendian: + bytes_.reverse() + return bytes(bytes_) + + @staticmethod + def has_valid_padding(value, bits=7): + """Whether the padding bits are all zero""" + + assert bits <= 8 + + mask = (((1 << (8 - bits)) - 1) << bits) + + if isinstance(value, integer_types): + while value: + if value & mask: + return False + value >>= 8 + elif isinstance(value, bytes): + for byte in bytearray(value): + if byte & mask: + return False + else: + raise TypeError + + return True + + +class BitPaddedInt(int, _BitPaddedMixin): + + def __new__(cls, value, bits=7, bigendian=True): + + mask = (1 << (bits)) - 1 + numeric_value = 0 + shift = 0 + + if isinstance(value, integer_types): + while value: + numeric_value += (value & mask) << shift + value >>= 8 + shift += bits + elif isinstance(value, bytes): + if bigendian: + value = reversed(value) + for byte in bytearray(value): + numeric_value += (byte & mask) << shift + shift += bits + else: + raise TypeError + + if isinstance(numeric_value, int): + self = int.__new__(BitPaddedInt, numeric_value) + else: + self = long_.__new__(BitPaddedLong, numeric_value) + + self.bits = bits + self.bigendian = bigendian + return self + +if PY3: + BitPaddedLong = BitPaddedInt +else: + class BitPaddedLong(long_, _BitPaddedMixin): + pass + + +class ID3BadUnsynchData(error, ValueError): + """Deprecated""" + + +class ID3BadCompressedData(error, ValueError): + """Deprecated""" + + +class ID3TagError(error, ValueError): + """Deprecated""" + + +class ID3Warning(error, UserWarning): + """Deprecated""" diff --git a/resources/lib/mutagen/m4a.py b/resources/lib/mutagen/m4a.py new file mode 100644 index 00000000..5730ace3 --- /dev/null +++ b/resources/lib/mutagen/m4a.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +""" +since 1.9: mutagen.m4a is deprecated; use mutagen.mp4 instead. +since 1.31: mutagen.m4a will no longer work; any operation that could fail + will fail now. +""" + +import warnings + +from mutagen import FileType, Metadata, StreamInfo +from ._util import DictProxy, MutagenError + +warnings.warn( + "mutagen.m4a is deprecated; use mutagen.mp4 instead.", + DeprecationWarning) + + +class error(IOError, MutagenError): + pass + + +class M4AMetadataError(error): + pass + + +class M4AStreamInfoError(error): + pass + + +class M4AMetadataValueError(ValueError, M4AMetadataError): + pass + + +__all__ = ['M4A', 'Open', 'delete', 'M4ACover'] + + +class M4ACover(bytes): + + FORMAT_JPEG = 0x0D + FORMAT_PNG = 0x0E + + def __new__(cls, data, imageformat=None): + self = bytes.__new__(cls, data) + if imageformat is None: + imageformat = M4ACover.FORMAT_JPEG + self.imageformat = imageformat + return self + + +class M4ATags(DictProxy, Metadata): + + def load(self, atoms, fileobj): + raise error("deprecated") + + def save(self, filename): + raise error("deprecated") + + def delete(self, filename): + raise error("deprecated") + + def pprint(self): + return u"" + + +class M4AInfo(StreamInfo): + + bitrate = 0 + + def __init__(self, atoms, fileobj): + raise error("deprecated") + + def pprint(self): + return u"" + + +class M4A(FileType): + + _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"] + + def load(self, filename): + raise error("deprecated") + + def add_tags(self): + self.tags = M4ATags() + + @staticmethod + def score(filename, fileobj, header): + return 0 + + +Open = M4A + + +def delete(filename): + raise error("deprecated") diff --git a/resources/lib/mutagen/monkeysaudio.py b/resources/lib/mutagen/monkeysaudio.py new file mode 100644 index 00000000..0e29273f --- /dev/null +++ b/resources/lib/mutagen/monkeysaudio.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Monkey's Audio streams with APEv2 tags. + +Monkey's Audio is a very efficient lossless audio compressor developed +by Matt Ashland. + +For more information, see http://www.monkeysaudio.com/. +""" + +__all__ = ["MonkeysAudio", "Open", "delete"] + +import struct + +from ._compat import endswith +from mutagen import StreamInfo +from mutagen.apev2 import APEv2File, error, delete +from mutagen._util import cdata + + +class MonkeysAudioHeaderError(error): + pass + + +class MonkeysAudioInfo(StreamInfo): + """Monkey's Audio stream information. + + Attributes: + + * channels -- number of audio channels + * length -- file length in seconds, as a float + * sample_rate -- audio sampling rate in Hz + * bits_per_sample -- bits per sample + * version -- Monkey's Audio stream version, as a float (eg: 3.99) + """ + + def __init__(self, fileobj): + header = fileobj.read(76) + if len(header) != 76 or not header.startswith(b"MAC "): + raise MonkeysAudioHeaderError("not a Monkey's Audio file") + self.version = cdata.ushort_le(header[4:6]) + if self.version >= 3980: + (blocks_per_frame, final_frame_blocks, total_frames, + self.bits_per_sample, self.channels, + self.sample_rate) = struct.unpack("= 3950: + blocks_per_frame = 73728 * 4 + elif self.version >= 3900 or (self.version >= 3800 and + compression_level == 4): + blocks_per_frame = 73728 + else: + blocks_per_frame = 9216 + self.version /= 1000.0 + self.length = 0.0 + if (self.sample_rate != 0) and (total_frames > 0): + total_blocks = ((total_frames - 1) * blocks_per_frame + + final_frame_blocks) + self.length = float(total_blocks) / self.sample_rate + + def pprint(self): + return u"Monkey's Audio %.2f, %.2f seconds, %d Hz" % ( + self.version, self.length, self.sample_rate) + + +class MonkeysAudio(APEv2File): + _Info = MonkeysAudioInfo + _mimes = ["audio/ape", "audio/x-ape"] + + @staticmethod + def score(filename, fileobj, header): + return header.startswith(b"MAC ") + endswith(filename.lower(), ".ape") + + +Open = MonkeysAudio diff --git a/resources/lib/mutagen/mp3.py b/resources/lib/mutagen/mp3.py new file mode 100644 index 00000000..afb600cf --- /dev/null +++ b/resources/lib/mutagen/mp3.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of version 2 of the GNU General Public License as +# published by the Free Software Foundation. + +"""MPEG audio stream information and tags.""" + +import os +import struct + +from ._compat import endswith, xrange +from ._mp3util import XingHeader, XingHeaderError, VBRIHeader, VBRIHeaderError +from mutagen import StreamInfo +from mutagen._util import MutagenError, enum +from mutagen.id3 import ID3FileType, BitPaddedInt, delete + +__all__ = ["MP3", "Open", "delete", "MP3"] + + +class error(RuntimeError, MutagenError): + pass + + +class HeaderNotFoundError(error, IOError): + pass + + +class InvalidMPEGHeader(error, IOError): + pass + + +@enum +class BitrateMode(object): + + UNKNOWN = 0 + """Probably a CBR file, but not sure""" + + CBR = 1 + """Constant Bitrate""" + + VBR = 2 + """Variable Bitrate""" + + ABR = 3 + """Average Bitrate (a variant of VBR)""" + + +def _guess_xing_bitrate_mode(xing): + + if xing.lame_header: + lame = xing.lame_header + if lame.vbr_method in (1, 8): + return BitrateMode.CBR + elif lame.vbr_method in (2, 9): + return BitrateMode.ABR + elif lame.vbr_method in (3, 4, 5, 6): + return BitrateMode.VBR + # everything else undefined, continue guessing + + # info tags get only written by lame for cbr files + if xing.is_info: + return BitrateMode.CBR + + # older lame and non-lame with some variant of vbr + if xing.vbr_scale != -1 or xing.lame_version: + return BitrateMode.VBR + + return BitrateMode.UNKNOWN + + +# Mode values. +STEREO, JOINTSTEREO, DUALCHANNEL, MONO = xrange(4) + + +class MPEGInfo(StreamInfo): + """MPEG audio stream information + + Parse information about an MPEG audio file. This also reads the + Xing VBR header format. + + This code was implemented based on the format documentation at + http://mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm. + + Useful attributes: + + * length -- audio length, in seconds + * channels -- number of audio channels + * bitrate -- audio bitrate, in bits per second + * sketchy -- if true, the file may not be valid MPEG audio + * encoder_info -- a string containing encoder name and possibly version. + In case a lame tag is present this will start with + ``"LAME "``, if unknown it is empty, otherwise the + text format is undefined. + * bitrate_mode -- a :class:`BitrateMode` + + * track_gain -- replaygain track gain (89db) or None + * track_peak -- replaygain track peak or None + * album_gain -- replaygain album gain (89db) or None + + Useless attributes: + + * version -- MPEG version (1, 2, 2.5) + * layer -- 1, 2, or 3 + * mode -- One of STEREO, JOINTSTEREO, DUALCHANNEL, or MONO (0-3) + * protected -- whether or not the file is "protected" + * padding -- whether or not audio frames are padded + * sample_rate -- audio sample rate, in Hz + """ + + # Map (version, layer) tuples to bitrates. + __BITRATE = { + (1, 1): [0, 32, 64, 96, 128, 160, 192, 224, + 256, 288, 320, 352, 384, 416, 448], + (1, 2): [0, 32, 48, 56, 64, 80, 96, 112, 128, + 160, 192, 224, 256, 320, 384], + (1, 3): [0, 32, 40, 48, 56, 64, 80, 96, 112, + 128, 160, 192, 224, 256, 320], + (2, 1): [0, 32, 48, 56, 64, 80, 96, 112, 128, + 144, 160, 176, 192, 224, 256], + (2, 2): [0, 8, 16, 24, 32, 40, 48, 56, 64, + 80, 96, 112, 128, 144, 160], + } + + __BITRATE[(2, 3)] = __BITRATE[(2, 2)] + for i in xrange(1, 4): + __BITRATE[(2.5, i)] = __BITRATE[(2, i)] + + # Map version to sample rates. + __RATES = { + 1: [44100, 48000, 32000], + 2: [22050, 24000, 16000], + 2.5: [11025, 12000, 8000] + } + + sketchy = False + encoder_info = u"" + bitrate_mode = BitrateMode.UNKNOWN + track_gain = track_peak = album_gain = album_peak = None + + def __init__(self, fileobj, offset=None): + """Parse MPEG stream information from a file-like object. + + If an offset argument is given, it is used to start looking + for stream information and Xing headers; otherwise, ID3v2 tags + will be skipped automatically. A correct offset can make + loading files significantly faster. + """ + + try: + size = os.path.getsize(fileobj.name) + except (IOError, OSError, AttributeError): + fileobj.seek(0, 2) + size = fileobj.tell() + + # If we don't get an offset, try to skip an ID3v2 tag. + if offset is None: + fileobj.seek(0, 0) + idata = fileobj.read(10) + try: + id3, insize = struct.unpack('>3sxxx4s', idata) + except struct.error: + id3, insize = b'', 0 + insize = BitPaddedInt(insize) + if id3 == b'ID3' and insize > 0: + offset = insize + 10 + else: + offset = 0 + + # Try to find two valid headers (meaning, very likely MPEG data) + # at the given offset, 30% through the file, 60% through the file, + # and 90% through the file. + for i in [offset, 0.3 * size, 0.6 * size, 0.9 * size]: + try: + self.__try(fileobj, int(i), size - offset) + except error: + pass + else: + break + # If we can't find any two consecutive frames, try to find just + # one frame back at the original offset given. + else: + self.__try(fileobj, offset, size - offset, False) + self.sketchy = True + + def __try(self, fileobj, offset, real_size, check_second=True): + # This is going to be one really long function; bear with it, + # because there's not really a sane point to cut it up. + fileobj.seek(offset, 0) + + # We "know" we have an MPEG file if we find two frames that look like + # valid MPEG data. If we can't find them in 32k of reads, something + # is horribly wrong (the longest frame can only be about 4k). This + # is assuming the offset didn't lie. + data = fileobj.read(32768) + + frame_1 = data.find(b"\xff") + while 0 <= frame_1 <= (len(data) - 4): + frame_data = struct.unpack(">I", data[frame_1:frame_1 + 4])[0] + if ((frame_data >> 16) & 0xE0) != 0xE0: + frame_1 = data.find(b"\xff", frame_1 + 2) + else: + version = (frame_data >> 19) & 0x3 + layer = (frame_data >> 17) & 0x3 + protection = (frame_data >> 16) & 0x1 + bitrate = (frame_data >> 12) & 0xF + sample_rate = (frame_data >> 10) & 0x3 + padding = (frame_data >> 9) & 0x1 + # private = (frame_data >> 8) & 0x1 + self.mode = (frame_data >> 6) & 0x3 + # mode_extension = (frame_data >> 4) & 0x3 + # copyright = (frame_data >> 3) & 0x1 + # original = (frame_data >> 2) & 0x1 + # emphasis = (frame_data >> 0) & 0x3 + if (version == 1 or layer == 0 or sample_rate == 0x3 or + bitrate == 0 or bitrate == 0xF): + frame_1 = data.find(b"\xff", frame_1 + 2) + else: + break + else: + raise HeaderNotFoundError("can't sync to an MPEG frame") + + self.channels = 1 if self.mode == MONO else 2 + + # There is a serious problem here, which is that many flags + # in an MPEG header are backwards. + self.version = [2.5, None, 2, 1][version] + self.layer = 4 - layer + self.protected = not protection + self.padding = bool(padding) + + self.bitrate = self.__BITRATE[(self.version, self.layer)][bitrate] + self.bitrate *= 1000 + self.sample_rate = self.__RATES[self.version][sample_rate] + + if self.layer == 1: + frame_length = ( + (12 * self.bitrate // self.sample_rate) + padding) * 4 + frame_size = 384 + elif self.version >= 2 and self.layer == 3: + frame_length = (72 * self.bitrate // self.sample_rate) + padding + frame_size = 576 + else: + frame_length = (144 * self.bitrate // self.sample_rate) + padding + frame_size = 1152 + + if check_second: + possible = int(frame_1 + frame_length) + if possible > len(data) + 4: + raise HeaderNotFoundError("can't sync to second MPEG frame") + try: + frame_data = struct.unpack( + ">H", data[possible:possible + 2])[0] + except struct.error: + raise HeaderNotFoundError("can't sync to second MPEG frame") + if (frame_data & 0xFFE0) != 0xFFE0: + raise HeaderNotFoundError("can't sync to second MPEG frame") + + self.length = 8 * real_size / float(self.bitrate) + + # Try to find/parse the Xing header, which trumps the above length + # and bitrate calculation. + + if self.layer != 3: + return + + # Xing + xing_offset = XingHeader.get_offset(self) + fileobj.seek(offset + frame_1 + xing_offset, 0) + try: + xing = XingHeader(fileobj) + except XingHeaderError: + pass + else: + lame = xing.lame_header + self.sketchy = False + self.bitrate_mode = _guess_xing_bitrate_mode(xing) + if xing.frames != -1: + samples = frame_size * xing.frames + if lame is not None: + samples -= lame.encoder_delay_start + samples -= lame.encoder_padding_end + self.length = float(samples) / self.sample_rate + if xing.bytes != -1 and self.length: + self.bitrate = int((xing.bytes * 8) / self.length) + if xing.lame_version: + self.encoder_info = u"LAME %s" % xing.lame_version + if lame is not None: + self.track_gain = lame.track_gain_adjustment + self.track_peak = lame.track_peak + self.album_gain = lame.album_gain_adjustment + return + + # VBRI + vbri_offset = VBRIHeader.get_offset(self) + fileobj.seek(offset + frame_1 + vbri_offset, 0) + try: + vbri = VBRIHeader(fileobj) + except VBRIHeaderError: + pass + else: + self.bitrate_mode = BitrateMode.VBR + self.encoder_info = u"FhG" + self.sketchy = False + self.length = float(frame_size * vbri.frames) / self.sample_rate + if self.length: + self.bitrate = int((vbri.bytes * 8) / self.length) + + def pprint(self): + info = str(self.bitrate_mode).split(".", 1)[-1] + if self.bitrate_mode == BitrateMode.UNKNOWN: + info = u"CBR?" + if self.encoder_info: + info += ", %s" % self.encoder_info + s = u"MPEG %s layer %d, %d bps (%s), %s Hz, %d chn, %.2f seconds" % ( + self.version, self.layer, self.bitrate, info, + self.sample_rate, self.channels, self.length) + if self.sketchy: + s += u" (sketchy)" + return s + + +class MP3(ID3FileType): + """An MPEG audio (usually MPEG-1 Layer 3) file. + + :ivar info: :class:`MPEGInfo` + :ivar tags: :class:`ID3 ` + """ + + _Info = MPEGInfo + + _mimes = ["audio/mpeg", "audio/mpg", "audio/x-mpeg"] + + @property + def mime(self): + l = self.info.layer + return ["audio/mp%d" % l, "audio/x-mp%d" % l] + super(MP3, self).mime + + @staticmethod + def score(filename, fileobj, header_data): + filename = filename.lower() + + return (header_data.startswith(b"ID3") * 2 + + endswith(filename, b".mp3") + + endswith(filename, b".mp2") + endswith(filename, b".mpg") + + endswith(filename, b".mpeg")) + + +Open = MP3 + + +class EasyMP3(MP3): + """Like MP3, but uses EasyID3 for tags. + + :ivar info: :class:`MPEGInfo` + :ivar tags: :class:`EasyID3 ` + """ + + from mutagen.easyid3 import EasyID3 as ID3 + ID3 = ID3 diff --git a/resources/lib/mutagen/mp4/__init__.py b/resources/lib/mutagen/mp4/__init__.py new file mode 100644 index 00000000..bc242ee8 --- /dev/null +++ b/resources/lib/mutagen/mp4/__init__.py @@ -0,0 +1,1010 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write MPEG-4 audio files with iTunes metadata. + +This module will read MPEG-4 audio information and metadata, +as found in Apple's MP4 (aka M4A, M4B, M4P) files. + +There is no official specification for this format. The source code +for TagLib, FAAD, and various MPEG specifications at + +* http://developer.apple.com/documentation/QuickTime/QTFF/ +* http://www.geocities.com/xhelmboyx/quicktime/formats/mp4-layout.txt +* http://standards.iso.org/ittf/PubliclyAvailableStandards/\ +c041828_ISO_IEC_14496-12_2005(E).zip +* http://wiki.multimedia.cx/index.php?title=Apple_QuickTime + +were all consulted. +""" + +import struct +import sys + +from mutagen import FileType, Metadata, StreamInfo, PaddingInfo +from mutagen._constants import GENRES +from mutagen._util import (cdata, insert_bytes, DictProxy, MutagenError, + hashable, enum, get_size, resize_bytes) +from mutagen._compat import (reraise, PY2, string_types, text_type, chr_, + iteritems, PY3, cBytesIO, izip, xrange) +from ._atom import Atoms, Atom, AtomError +from ._util import parse_full_atom +from ._as_entry import AudioSampleEntry, ASEntryError + + +class error(IOError, MutagenError): + pass + + +class MP4MetadataError(error): + pass + + +class MP4StreamInfoError(error): + pass + + +class MP4MetadataValueError(ValueError, MP4MetadataError): + pass + + +__all__ = ['MP4', 'Open', 'delete', 'MP4Cover', 'MP4FreeForm', 'AtomDataType'] + + +@enum +class AtomDataType(object): + """Enum for `dataformat` attribute of MP4FreeForm. + + .. versionadded:: 1.25 + """ + + IMPLICIT = 0 + """for use with tags for which no type needs to be indicated because + only one type is allowed""" + + UTF8 = 1 + """without any count or null terminator""" + + UTF16 = 2 + """also known as UTF-16BE""" + + SJIS = 3 + """deprecated unless it is needed for special Japanese characters""" + + HTML = 6 + """the HTML file header specifies which HTML version""" + + XML = 7 + """the XML header must identify the DTD or schemas""" + + UUID = 8 + """also known as GUID; stored as 16 bytes in binary (valid as an ID)""" + + ISRC = 9 + """stored as UTF-8 text (valid as an ID)""" + + MI3P = 10 + """stored as UTF-8 text (valid as an ID)""" + + GIF = 12 + """(deprecated) a GIF image""" + + JPEG = 13 + """a JPEG image""" + + PNG = 14 + """PNG image""" + + URL = 15 + """absolute, in UTF-8 characters""" + + DURATION = 16 + """in milliseconds, 32-bit integer""" + + DATETIME = 17 + """in UTC, counting seconds since midnight, January 1, 1904; + 32 or 64-bits""" + + GENRES = 18 + """a list of enumerated values""" + + INTEGER = 21 + """a signed big-endian integer with length one of { 1,2,3,4,8 } bytes""" + + RIAA_PA = 24 + """RIAA parental advisory; { -1=no, 1=yes, 0=unspecified }, + 8-bit ingteger""" + + UPC = 25 + """Universal Product Code, in text UTF-8 format (valid as an ID)""" + + BMP = 27 + """Windows bitmap image""" + + +@hashable +class MP4Cover(bytes): + """A cover artwork. + + Attributes: + + * imageformat -- format of the image (either FORMAT_JPEG or FORMAT_PNG) + """ + + FORMAT_JPEG = AtomDataType.JPEG + FORMAT_PNG = AtomDataType.PNG + + def __new__(cls, data, *args, **kwargs): + return bytes.__new__(cls, data) + + def __init__(self, data, imageformat=FORMAT_JPEG): + self.imageformat = imageformat + + __hash__ = bytes.__hash__ + + def __eq__(self, other): + if not isinstance(other, MP4Cover): + return bytes(self) == other + + return (bytes(self) == bytes(other) and + self.imageformat == other.imageformat) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%r, %r)" % ( + type(self).__name__, bytes(self), + AtomDataType(self.imageformat)) + + +@hashable +class MP4FreeForm(bytes): + """A freeform value. + + Attributes: + + * dataformat -- format of the data (see AtomDataType) + """ + + FORMAT_DATA = AtomDataType.IMPLICIT # deprecated + FORMAT_TEXT = AtomDataType.UTF8 # deprecated + + def __new__(cls, data, *args, **kwargs): + return bytes.__new__(cls, data) + + def __init__(self, data, dataformat=AtomDataType.UTF8, version=0): + self.dataformat = dataformat + self.version = version + + __hash__ = bytes.__hash__ + + def __eq__(self, other): + if not isinstance(other, MP4FreeForm): + return bytes(self) == other + + return (bytes(self) == bytes(other) and + self.dataformat == other.dataformat and + self.version == other.version) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%r, %r)" % ( + type(self).__name__, bytes(self), + AtomDataType(self.dataformat)) + + + +def _name2key(name): + if PY2: + return name + return name.decode("latin-1") + + +def _key2name(key): + if PY2: + return key + return key.encode("latin-1") + + +def _find_padding(atom_path): + # Check for padding "free" atom + # XXX: we only use them if they are adjacent to ilst, and only one. + # and there also is a top level free atom which we could use maybe..? + + meta, ilst = atom_path[-2:] + assert meta.name == b"meta" and ilst.name == b"ilst" + index = meta.children.index(ilst) + try: + prev = meta.children[index - 1] + if prev.name == b"free": + return prev + except IndexError: + pass + + try: + next_ = meta.children[index + 1] + if next_.name == b"free": + return next_ + except IndexError: + pass + + +class MP4Tags(DictProxy, Metadata): + r"""Dictionary containing Apple iTunes metadata list key/values. + + Keys are four byte identifiers, except for freeform ('----') + keys. Values are usually unicode strings, but some atoms have a + special structure: + + Text values (multiple values per key are supported): + + * '\\xa9nam' -- track title + * '\\xa9alb' -- album + * '\\xa9ART' -- artist + * 'aART' -- album artist + * '\\xa9wrt' -- composer + * '\\xa9day' -- year + * '\\xa9cmt' -- comment + * 'desc' -- description (usually used in podcasts) + * 'purd' -- purchase date + * '\\xa9grp' -- grouping + * '\\xa9gen' -- genre + * '\\xa9lyr' -- lyrics + * 'purl' -- podcast URL + * 'egid' -- podcast episode GUID + * 'catg' -- podcast category + * 'keyw' -- podcast keywords + * '\\xa9too' -- encoded by + * 'cprt' -- copyright + * 'soal' -- album sort order + * 'soaa' -- album artist sort order + * 'soar' -- artist sort order + * 'sonm' -- title sort order + * 'soco' -- composer sort order + * 'sosn' -- show sort order + * 'tvsh' -- show name + + Boolean values: + + * 'cpil' -- part of a compilation + * 'pgap' -- part of a gapless album + * 'pcst' -- podcast (iTunes reads this only on import) + + Tuples of ints (multiple values per key are supported): + + * 'trkn' -- track number, total tracks + * 'disk' -- disc number, total discs + + Others: + + * 'tmpo' -- tempo/BPM, 16 bit int + * 'covr' -- cover artwork, list of MP4Cover objects (which are + tagged strs) + * 'gnre' -- ID3v1 genre. Not supported, use '\\xa9gen' instead. + + The freeform '----' frames use a key in the format '----:mean:name' + where 'mean' is usually 'com.apple.iTunes' and 'name' is a unique + identifier for this frame. The value is a str, but is probably + text that can be decoded as UTF-8. Multiple values per key are + supported. + + MP4 tag data cannot exist outside of the structure of an MP4 file, + so this class should not be manually instantiated. + + Unknown non-text tags and tags that failed to parse will be written + back as is. + """ + + def __init__(self, *args, **kwargs): + self._failed_atoms = {} + super(MP4Tags, self).__init__(*args, **kwargs) + + def load(self, atoms, fileobj): + try: + path = atoms.path(b"moov", b"udta", b"meta", b"ilst") + except KeyError as key: + raise MP4MetadataError(key) + + free = _find_padding(path) + self._padding = free.datalength if free is not None else 0 + + ilst = path[-1] + for atom in ilst.children: + ok, data = atom.read(fileobj) + if not ok: + raise MP4MetadataError("Not enough data") + + try: + if atom.name in self.__atoms: + info = self.__atoms[atom.name] + info[0](self, atom, data) + else: + # unknown atom, try as text + self.__parse_text(atom, data, implicit=False) + except MP4MetadataError: + # parsing failed, save them so we can write them back + key = _name2key(atom.name) + self._failed_atoms.setdefault(key, []).append(data) + + def __setitem__(self, key, value): + if not isinstance(key, str): + raise TypeError("key has to be str") + super(MP4Tags, self).__setitem__(key, value) + + @classmethod + def _can_load(cls, atoms): + return b"moov.udta.meta.ilst" in atoms + + @staticmethod + def _key_sort(item): + (key, v) = item + # iTunes always writes the tags in order of "relevance", try + # to copy it as closely as possible. + order = ["\xa9nam", "\xa9ART", "\xa9wrt", "\xa9alb", + "\xa9gen", "gnre", "trkn", "disk", + "\xa9day", "cpil", "pgap", "pcst", "tmpo", + "\xa9too", "----", "covr", "\xa9lyr"] + order = dict(izip(order, xrange(len(order)))) + last = len(order) + # If there's no key-based way to distinguish, order by length. + # If there's still no way, go by string comparison on the + # values, so we at least have something determinstic. + return (order.get(key[:4], last), len(repr(v)), repr(v)) + + def save(self, filename, padding=None): + """Save the metadata to the given filename.""" + + values = [] + items = sorted(self.items(), key=self._key_sort) + for key, value in items: + atom_name = _key2name(key)[:4] + if atom_name in self.__atoms: + render_func = self.__atoms[atom_name][1] + else: + render_func = type(self).__render_text + + try: + values.append(render_func(self, key, value)) + except (TypeError, ValueError) as s: + reraise(MP4MetadataValueError, s, sys.exc_info()[2]) + + for key, failed in iteritems(self._failed_atoms): + # don't write atoms back if we have added a new one with + # the same name, this excludes freeform which can have + # multiple atoms with the same key (most parsers seem to be able + # to handle that) + if key in self: + assert _key2name(key) != b"----" + continue + for data in failed: + values.append(Atom.render(_key2name(key), data)) + + data = Atom.render(b"ilst", b"".join(values)) + + # Find the old atoms. + with open(filename, "rb+") as fileobj: + try: + atoms = Atoms(fileobj) + except AtomError as err: + reraise(error, err, sys.exc_info()[2]) + + self.__save(fileobj, atoms, data, padding) + + def __save(self, fileobj, atoms, data, padding): + try: + path = atoms.path(b"moov", b"udta", b"meta", b"ilst") + except KeyError: + self.__save_new(fileobj, atoms, data, padding) + else: + self.__save_existing(fileobj, atoms, path, data, padding) + + def __pad_ilst(self, data, length=None): + if length is None: + length = ((len(data) + 1023) & ~1023) - len(data) + return Atom.render(b"free", b"\x00" * length) + + def __save_new(self, fileobj, atoms, ilst_data, padding_func): + hdlr = Atom.render(b"hdlr", b"\x00" * 8 + b"mdirappl" + b"\x00" * 9) + meta_data = b"\x00\x00\x00\x00" + hdlr + ilst_data + + try: + path = atoms.path(b"moov", b"udta") + except KeyError: + path = atoms.path(b"moov") + + offset = path[-1]._dataoffset + + # ignoring some atom overhead... but we don't have padding left anyway + # and padding_size is guaranteed to be less than zero + content_size = get_size(fileobj) - offset + padding_size = -len(meta_data) + assert padding_size < 0 + info = PaddingInfo(padding_size, content_size) + new_padding = info._get_padding(padding_func) + new_padding = min(0xFFFFFFFF, new_padding) + + free = Atom.render(b"free", b"\x00" * new_padding) + meta = Atom.render(b"meta", meta_data + free) + if path[-1].name != b"udta": + # moov.udta not found -- create one + data = Atom.render(b"udta", meta) + else: + data = meta + + insert_bytes(fileobj, len(data), offset) + fileobj.seek(offset) + fileobj.write(data) + self.__update_parents(fileobj, path, len(data)) + self.__update_offsets(fileobj, atoms, len(data), offset) + + def __save_existing(self, fileobj, atoms, path, ilst_data, padding_func): + # Replace the old ilst atom. + ilst = path[-1] + offset = ilst.offset + length = ilst.length + + # Use adjacent free atom if there is one + free = _find_padding(path) + if free is not None: + offset = min(offset, free.offset) + length += free.length + + # Always add a padding atom to make things easier + padding_overhead = len(Atom.render(b"free", b"")) + content_size = get_size(fileobj) - (offset + length) + padding_size = length - (len(ilst_data) + padding_overhead) + info = PaddingInfo(padding_size, content_size) + new_padding = info._get_padding(padding_func) + # Limit padding size so we can be sure the free atom overhead is as we + # calculated above (see Atom.render) + new_padding = min(0xFFFFFFFF, new_padding) + + ilst_data += Atom.render(b"free", b"\x00" * new_padding) + + resize_bytes(fileobj, length, len(ilst_data), offset) + delta = len(ilst_data) - length + + fileobj.seek(offset) + fileobj.write(ilst_data) + self.__update_parents(fileobj, path[:-1], delta) + self.__update_offsets(fileobj, atoms, delta, offset) + + def __update_parents(self, fileobj, path, delta): + """Update all parent atoms with the new size.""" + + if delta == 0: + return + + for atom in path: + fileobj.seek(atom.offset) + size = cdata.uint_be(fileobj.read(4)) + if size == 1: # 64bit + # skip name (4B) and read size (8B) + size = cdata.ulonglong_be(fileobj.read(12)[4:]) + fileobj.seek(atom.offset + 8) + fileobj.write(cdata.to_ulonglong_be(size + delta)) + else: # 32bit + fileobj.seek(atom.offset) + fileobj.write(cdata.to_uint_be(size + delta)) + + def __update_offset_table(self, fileobj, fmt, atom, delta, offset): + """Update offset table in the specified atom.""" + if atom.offset > offset: + atom.offset += delta + fileobj.seek(atom.offset + 12) + data = fileobj.read(atom.length - 12) + fmt = fmt % cdata.uint_be(data[:4]) + offsets = struct.unpack(fmt, data[4:]) + offsets = [o + (0, delta)[offset < o] for o in offsets] + fileobj.seek(atom.offset + 16) + fileobj.write(struct.pack(fmt, *offsets)) + + def __update_tfhd(self, fileobj, atom, delta, offset): + if atom.offset > offset: + atom.offset += delta + fileobj.seek(atom.offset + 9) + data = fileobj.read(atom.length - 9) + flags = cdata.uint_be(b"\x00" + data[:3]) + if flags & 1: + o = cdata.ulonglong_be(data[7:15]) + if o > offset: + o += delta + fileobj.seek(atom.offset + 16) + fileobj.write(cdata.to_ulonglong_be(o)) + + def __update_offsets(self, fileobj, atoms, delta, offset): + """Update offset tables in all 'stco' and 'co64' atoms.""" + if delta == 0: + return + moov = atoms[b"moov"] + for atom in moov.findall(b'stco', True): + self.__update_offset_table(fileobj, ">%dI", atom, delta, offset) + for atom in moov.findall(b'co64', True): + self.__update_offset_table(fileobj, ">%dQ", atom, delta, offset) + try: + for atom in atoms[b"moof"].findall(b'tfhd', True): + self.__update_tfhd(fileobj, atom, delta, offset) + except KeyError: + pass + + def __parse_data(self, atom, data): + pos = 0 + while pos < atom.length - 8: + head = data[pos:pos + 12] + if len(head) != 12: + raise MP4MetadataError("truncated atom % r" % atom.name) + length, name = struct.unpack(">I4s", head[:8]) + version = ord(head[8:9]) + flags = struct.unpack(">I", b"\x00" + head[9:12])[0] + if name != b"data": + raise MP4MetadataError( + "unexpected atom %r inside %r" % (name, atom.name)) + + chunk = data[pos + 16:pos + length] + if len(chunk) != length - 16: + raise MP4MetadataError("truncated atom % r" % atom.name) + yield version, flags, chunk + pos += length + + def __add(self, key, value, single=False): + assert isinstance(key, str) + + if single: + self[key] = value + else: + self.setdefault(key, []).extend(value) + + def __render_data(self, key, version, flags, value): + return Atom.render(_key2name(key), b"".join([ + Atom.render( + b"data", struct.pack(">2I", version << 24 | flags, 0) + data) + for data in value])) + + def __parse_freeform(self, atom, data): + length = cdata.uint_be(data[:4]) + mean = data[12:length] + pos = length + length = cdata.uint_be(data[pos:pos + 4]) + name = data[pos + 12:pos + length] + pos += length + value = [] + while pos < atom.length - 8: + length, atom_name = struct.unpack(">I4s", data[pos:pos + 8]) + if atom_name != b"data": + raise MP4MetadataError( + "unexpected atom %r inside %r" % (atom_name, atom.name)) + + version = ord(data[pos + 8:pos + 8 + 1]) + flags = struct.unpack(">I", b"\x00" + data[pos + 9:pos + 12])[0] + value.append(MP4FreeForm(data[pos + 16:pos + length], + dataformat=flags, version=version)) + pos += length + + key = _name2key(atom.name + b":" + mean + b":" + name) + self.__add(key, value) + + def __render_freeform(self, key, value): + if isinstance(value, bytes): + value = [value] + + dummy, mean, name = _key2name(key).split(b":", 2) + mean = struct.pack(">I4sI", len(mean) + 12, b"mean", 0) + mean + name = struct.pack(">I4sI", len(name) + 12, b"name", 0) + name + + data = b"" + for v in value: + flags = AtomDataType.UTF8 + version = 0 + if isinstance(v, MP4FreeForm): + flags = v.dataformat + version = v.version + + data += struct.pack( + ">I4s2I", len(v) + 16, b"data", version << 24 | flags, 0) + data += v + + return Atom.render(b"----", mean + name + data) + + def __parse_pair(self, atom, data): + key = _name2key(atom.name) + values = [struct.unpack(">2H", d[2:6]) for + version, flags, d in self.__parse_data(atom, data)] + self.__add(key, values) + + def __render_pair(self, key, value): + data = [] + for (track, total) in value: + if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: + data.append(struct.pack(">4H", 0, track, total, 0)) + else: + raise MP4MetadataValueError( + "invalid numeric pair %r" % ((track, total),)) + return self.__render_data(key, 0, AtomDataType.IMPLICIT, data) + + def __render_pair_no_trailing(self, key, value): + data = [] + for (track, total) in value: + if 0 <= track < 1 << 16 and 0 <= total < 1 << 16: + data.append(struct.pack(">3H", 0, track, total)) + else: + raise MP4MetadataValueError( + "invalid numeric pair %r" % ((track, total),)) + return self.__render_data(key, 0, AtomDataType.IMPLICIT, data) + + def __parse_genre(self, atom, data): + values = [] + for version, flags, data in self.__parse_data(atom, data): + # version = 0, flags = 0 + if len(data) != 2: + raise MP4MetadataValueError("invalid genre") + genre = cdata.short_be(data) + # Translate to a freeform genre. + try: + genre = GENRES[genre - 1] + except IndexError: + # this will make us write it back at least + raise MP4MetadataValueError("unknown genre") + values.append(genre) + key = _name2key(b"\xa9gen") + self.__add(key, values) + + def __parse_tempo(self, atom, data): + values = [] + for version, flags, data in self.__parse_data(atom, data): + # version = 0, flags = 0 or 21 + if len(data) != 2: + raise MP4MetadataValueError("invalid tempo") + values.append(cdata.ushort_be(data)) + key = _name2key(atom.name) + self.__add(key, values) + + def __render_tempo(self, key, value): + try: + if len(value) == 0: + return self.__render_data(key, 0, AtomDataType.INTEGER, b"") + + if (min(value) < 0) or (max(value) >= 2 ** 16): + raise MP4MetadataValueError( + "invalid 16 bit integers: %r" % value) + except TypeError: + raise MP4MetadataValueError( + "tmpo must be a list of 16 bit integers") + + values = [cdata.to_ushort_be(v) for v in value] + return self.__render_data(key, 0, AtomDataType.INTEGER, values) + + def __parse_bool(self, atom, data): + for version, flags, data in self.__parse_data(atom, data): + if len(data) != 1: + raise MP4MetadataValueError("invalid bool") + + value = bool(ord(data)) + key = _name2key(atom.name) + self.__add(key, value, single=True) + + def __render_bool(self, key, value): + return self.__render_data( + key, 0, AtomDataType.INTEGER, [chr_(bool(value))]) + + def __parse_cover(self, atom, data): + values = [] + pos = 0 + while pos < atom.length - 8: + length, name, imageformat = struct.unpack(">I4sI", + data[pos:pos + 12]) + if name != b"data": + if name == b"name": + pos += length + continue + raise MP4MetadataError( + "unexpected atom %r inside 'covr'" % name) + if imageformat not in (MP4Cover.FORMAT_JPEG, MP4Cover.FORMAT_PNG): + # Sometimes AtomDataType.IMPLICIT or simply wrong. + # In all cases it was jpeg, so default to it + imageformat = MP4Cover.FORMAT_JPEG + cover = MP4Cover(data[pos + 16:pos + length], imageformat) + values.append(cover) + pos += length + + key = _name2key(atom.name) + self.__add(key, values) + + def __render_cover(self, key, value): + atom_data = [] + for cover in value: + try: + imageformat = cover.imageformat + except AttributeError: + imageformat = MP4Cover.FORMAT_JPEG + atom_data.append(Atom.render( + b"data", struct.pack(">2I", imageformat, 0) + cover)) + return Atom.render(_key2name(key), b"".join(atom_data)) + + def __parse_text(self, atom, data, implicit=True): + # implicit = False, for parsing unknown atoms only take utf8 ones. + # For known ones we can assume the implicit are utf8 too. + values = [] + for version, flags, atom_data in self.__parse_data(atom, data): + if implicit: + if flags not in (AtomDataType.IMPLICIT, AtomDataType.UTF8): + raise MP4MetadataError( + "Unknown atom type %r for %r" % (flags, atom.name)) + else: + if flags != AtomDataType.UTF8: + raise MP4MetadataError( + "%r is not text, ignore" % atom.name) + + try: + text = atom_data.decode("utf-8") + except UnicodeDecodeError as e: + raise MP4MetadataError("%s: %s" % (_name2key(atom.name), e)) + + values.append(text) + + key = _name2key(atom.name) + self.__add(key, values) + + def __render_text(self, key, value, flags=AtomDataType.UTF8): + if isinstance(value, string_types): + value = [value] + + encoded = [] + for v in value: + if not isinstance(v, text_type): + if PY3: + raise TypeError("%r not str" % v) + v = v.decode("utf-8") + encoded.append(v.encode("utf-8")) + + return self.__render_data(key, 0, flags, encoded) + + def delete(self, filename): + """Remove the metadata from the given filename.""" + + self._failed_atoms.clear() + self.clear() + self.save(filename, padding=lambda x: 0) + + __atoms = { + b"----": (__parse_freeform, __render_freeform), + b"trkn": (__parse_pair, __render_pair), + b"disk": (__parse_pair, __render_pair_no_trailing), + b"gnre": (__parse_genre, None), + b"tmpo": (__parse_tempo, __render_tempo), + b"cpil": (__parse_bool, __render_bool), + b"pgap": (__parse_bool, __render_bool), + b"pcst": (__parse_bool, __render_bool), + b"covr": (__parse_cover, __render_cover), + b"purl": (__parse_text, __render_text), + b"egid": (__parse_text, __render_text), + } + + # these allow implicit flags and parse as text + for name in [b"\xa9nam", b"\xa9alb", b"\xa9ART", b"aART", b"\xa9wrt", + b"\xa9day", b"\xa9cmt", b"desc", b"purd", b"\xa9grp", + b"\xa9gen", b"\xa9lyr", b"catg", b"keyw", b"\xa9too", + b"cprt", b"soal", b"soaa", b"soar", b"sonm", b"soco", + b"sosn", b"tvsh"]: + __atoms[name] = (__parse_text, __render_text) + + def pprint(self): + + def to_line(key, value): + assert isinstance(key, text_type) + if isinstance(value, text_type): + return u"%s=%s" % (key, value) + return u"%s=%r" % (key, value) + + values = [] + for key, value in sorted(iteritems(self)): + if not isinstance(key, text_type): + key = key.decode("latin-1") + if key == "covr": + values.append(u"%s=%s" % (key, u", ".join( + [u"[%d bytes of data]" % len(data) for data in value]))) + elif isinstance(value, list): + for v in value: + values.append(to_line(key, v)) + else: + values.append(to_line(key, value)) + return u"\n".join(values) + + +class MP4Info(StreamInfo): + """MPEG-4 stream information. + + Attributes: + + * bitrate -- bitrate in bits per second, as an int + * length -- file length in seconds, as a float + * channels -- number of audio channels + * sample_rate -- audio sampling rate in Hz + * bits_per_sample -- bits per sample + * codec (string): + * if starting with ``"mp4a"`` uses an mp4a audio codec + (see the codec parameter in rfc6381 for details e.g. ``"mp4a.40.2"``) + * for everything else see a list of possible values at + http://www.mp4ra.org/codecs.html + + e.g. ``"mp4a"``, ``"alac"``, ``"mp4a.40.2"``, ``"ac-3"`` etc. + * codec_description (string): + Name of the codec used (ALAC, AAC LC, AC-3...). Values might change in + the future, use for display purposes only. + """ + + bitrate = 0 + channels = 0 + sample_rate = 0 + bits_per_sample = 0 + codec = u"" + codec_name = u"" + + def __init__(self, atoms, fileobj): + try: + moov = atoms[b"moov"] + except KeyError: + raise MP4StreamInfoError("not a MP4 file") + + for trak in moov.findall(b"trak"): + hdlr = trak[b"mdia", b"hdlr"] + ok, data = hdlr.read(fileobj) + if not ok: + raise MP4StreamInfoError("Not enough data") + if data[8:12] == b"soun": + break + else: + raise MP4StreamInfoError("track has no audio data") + + mdhd = trak[b"mdia", b"mdhd"] + ok, data = mdhd.read(fileobj) + if not ok: + raise MP4StreamInfoError("Not enough data") + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise MP4StreamInfoError(e) + + if version == 0: + offset = 8 + fmt = ">2I" + elif version == 1: + offset = 16 + fmt = ">IQ" + else: + raise MP4StreamInfoError("Unknown mdhd version %d" % version) + + end = offset + struct.calcsize(fmt) + unit, length = struct.unpack(fmt, data[offset:end]) + try: + self.length = float(length) / unit + except ZeroDivisionError: + self.length = 0 + + try: + atom = trak[b"mdia", b"minf", b"stbl", b"stsd"] + except KeyError: + pass + else: + self._parse_stsd(atom, fileobj) + + def _parse_stsd(self, atom, fileobj): + """Sets channels, bits_per_sample, sample_rate and optionally bitrate. + + Can raise MP4StreamInfoError. + """ + + assert atom.name == b"stsd" + + ok, data = atom.read(fileobj) + if not ok: + raise MP4StreamInfoError("Invalid stsd") + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise MP4StreamInfoError(e) + + if version != 0: + raise MP4StreamInfoError("Unsupported stsd version") + + try: + num_entries, offset = cdata.uint32_be_from(data, 0) + except cdata.error as e: + raise MP4StreamInfoError(e) + + if num_entries == 0: + return + + # look at the first entry if there is one + entry_fileobj = cBytesIO(data[offset:]) + try: + entry_atom = Atom(entry_fileobj) + except AtomError as e: + raise MP4StreamInfoError(e) + + try: + entry = AudioSampleEntry(entry_atom, entry_fileobj) + except ASEntryError as e: + raise MP4StreamInfoError(e) + else: + self.channels = entry.channels + self.bits_per_sample = entry.sample_size + self.sample_rate = entry.sample_rate + self.bitrate = entry.bitrate + self.codec = entry.codec + self.codec_description = entry.codec_description + + def pprint(self): + return "MPEG-4 audio (%s), %.2f seconds, %d bps" % ( + self.codec_description, self.length, self.bitrate) + + +class MP4(FileType): + """An MPEG-4 audio file, probably containing AAC. + + If more than one track is present in the file, the first is used. + Only audio ('soun') tracks will be read. + + :ivar info: :class:`MP4Info` + :ivar tags: :class:`MP4Tags` + """ + + MP4Tags = MP4Tags + + _mimes = ["audio/mp4", "audio/x-m4a", "audio/mpeg4", "audio/aac"] + + def load(self, filename): + self.filename = filename + with open(filename, "rb") as fileobj: + try: + atoms = Atoms(fileobj) + except AtomError as err: + reraise(error, err, sys.exc_info()[2]) + + try: + self.info = MP4Info(atoms, fileobj) + except error: + raise + except Exception as err: + reraise(MP4StreamInfoError, err, sys.exc_info()[2]) + + if not MP4Tags._can_load(atoms): + self.tags = None + self._padding = 0 + else: + try: + self.tags = self.MP4Tags(atoms, fileobj) + except error: + raise + except Exception as err: + reraise(MP4MetadataError, err, sys.exc_info()[2]) + else: + self._padding = self.tags._padding + + def add_tags(self): + if self.tags is None: + self.tags = self.MP4Tags() + else: + raise error("an MP4 tag already exists") + + @staticmethod + def score(filename, fileobj, header_data): + return (b"ftyp" in header_data) + (b"mp4" in header_data) + + +Open = MP4 + + +def delete(filename): + """Remove tags from a file.""" + + MP4(filename).delete() diff --git a/resources/lib/mutagen/mp4/__pycache__/__init__.cpython-35.pyc b/resources/lib/mutagen/mp4/__pycache__/__init__.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de968da940fc35ac7e446528711bd4b0f1e18e24 GIT binary patch literal 31145 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 literal 0 HcmV?d00001 diff --git a/resources/lib/mutagen/mp4/__pycache__/_as_entry.cpython-35.pyc b/resources/lib/mutagen/mp4/__pycache__/_as_entry.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..31483c463fa273b25f89cbb8fab1e7d451fa97b9 GIT binary patch literal 14269 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 16 + except BitReaderError as e: + raise ASEntryError(e) + + assert r.is_aligned() + + try: + extra = Atom(fileobj) + except AtomError as e: + raise ASEntryError(e) + + self.codec = atom.name.decode("latin-1") + self.codec_description = None + + if atom.name == b"mp4a" and extra.name == b"esds": + self._parse_esds(extra, fileobj) + elif atom.name == b"alac" and extra.name == b"alac": + self._parse_alac(extra, fileobj) + elif atom.name == b"ac-3" and extra.name == b"dac3": + self._parse_dac3(extra, fileobj) + + if self.codec_description is None: + self.codec_description = self.codec.upper() + + def _parse_dac3(self, atom, fileobj): + # ETSI TS 102 366 + + assert atom.name == b"dac3" + + ok, data = atom.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % atom.name) + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + # sample_rate in AudioSampleEntry covers values in + # fscod2 and not just fscod, so ignore fscod here. + try: + r.skip(2 + 5 + 3) # fscod, bsid, bsmod + acmod = r.bits(3) + lfeon = r.bits(1) + bit_rate_code = r.bits(5) + r.skip(5) # reserved + except BitReaderError as e: + raise ASEntryError(e) + + self.channels = [2, 1, 2, 3, 3, 4, 4, 5][acmod] + lfeon + + try: + self.bitrate = [ + 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, + 224, 256, 320, 384, 448, 512, 576, 640][bit_rate_code] * 1000 + except IndexError: + pass + + def _parse_alac(self, atom, fileobj): + # https://alac.macosforge.org/trac/browser/trunk/ + # ALACMagicCookieDescription.txt + + assert atom.name == b"alac" + + ok, data = atom.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % atom.name) + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise ASEntryError(e) + + if version != 0: + raise ASEntryError("Unsupported version %d" % version) + + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + try: + # for some files the AudioSampleEntry values default to 44100/2chan + # and the real info is in the alac cookie, so prefer it + r.skip(32) # frameLength + compatibleVersion = r.bits(8) + if compatibleVersion != 0: + return + self.sample_size = r.bits(8) + r.skip(8 + 8 + 8) + self.channels = r.bits(8) + r.skip(16 + 32) + self.bitrate = r.bits(32) + self.sample_rate = r.bits(32) + except BitReaderError as e: + raise ASEntryError(e) + + def _parse_esds(self, esds, fileobj): + assert esds.name == b"esds" + + ok, data = esds.read(fileobj) + if not ok: + raise ASEntryError("truncated %s atom" % esds.name) + + try: + version, flags, data = parse_full_atom(data) + except ValueError as e: + raise ASEntryError(e) + + if version != 0: + raise ASEntryError("Unsupported version %d" % version) + + fileobj = cBytesIO(data) + r = BitReader(fileobj) + + try: + tag = r.bits(8) + if tag != ES_Descriptor.TAG: + raise ASEntryError("unexpected descriptor: %d" % tag) + assert r.is_aligned() + except BitReaderError as e: + raise ASEntryError(e) + + try: + decSpecificInfo = ES_Descriptor.parse(fileobj) + except DescriptorError as e: + raise ASEntryError(e) + dec_conf_desc = decSpecificInfo.decConfigDescr + + self.bitrate = dec_conf_desc.avgBitrate + self.codec += dec_conf_desc.codec_param + self.codec_description = dec_conf_desc.codec_desc + + decSpecificInfo = dec_conf_desc.decSpecificInfo + if decSpecificInfo is not None: + if decSpecificInfo.channels != 0: + self.channels = decSpecificInfo.channels + + if decSpecificInfo.sample_rate != 0: + self.sample_rate = decSpecificInfo.sample_rate + + +class DescriptorError(Exception): + pass + + +class BaseDescriptor(object): + + TAG = None + + @classmethod + def _parse_desc_length_file(cls, fileobj): + """May raise ValueError""" + + value = 0 + for i in xrange(4): + try: + b = cdata.uint8(fileobj.read(1)) + except cdata.error as e: + raise ValueError(e) + value = (value << 7) | (b & 0x7f) + if not b >> 7: + break + else: + raise ValueError("invalid descriptor length") + + return value + + @classmethod + def parse(cls, fileobj): + """Returns a parsed instance of the called type. + The file position is right after the descriptor after this returns. + + Raises DescriptorError + """ + + try: + length = cls._parse_desc_length_file(fileobj) + except ValueError as e: + raise DescriptorError(e) + pos = fileobj.tell() + instance = cls(fileobj, length) + left = length - (fileobj.tell() - pos) + if left < 0: + raise DescriptorError("descriptor parsing read too much data") + fileobj.seek(left, 1) + return instance + + +class ES_Descriptor(BaseDescriptor): + + TAG = 0x3 + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + try: + self.ES_ID = r.bits(16) + self.streamDependenceFlag = r.bits(1) + self.URL_Flag = r.bits(1) + self.OCRstreamFlag = r.bits(1) + self.streamPriority = r.bits(5) + if self.streamDependenceFlag: + self.dependsOn_ES_ID = r.bits(16) + if self.URL_Flag: + URLlength = r.bits(8) + self.URLstring = r.bytes(URLlength) + if self.OCRstreamFlag: + self.OCR_ES_Id = r.bits(16) + + tag = r.bits(8) + except BitReaderError as e: + raise DescriptorError(e) + + if tag != DecoderConfigDescriptor.TAG: + raise DescriptorError("unexpected DecoderConfigDescrTag %d" % tag) + + assert r.is_aligned() + self.decConfigDescr = DecoderConfigDescriptor.parse(fileobj) + + +class DecoderConfigDescriptor(BaseDescriptor): + + TAG = 0x4 + + decSpecificInfo = None + """A DecoderSpecificInfo, optional""" + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + + try: + self.objectTypeIndication = r.bits(8) + self.streamType = r.bits(6) + self.upStream = r.bits(1) + self.reserved = r.bits(1) + self.bufferSizeDB = r.bits(24) + self.maxBitrate = r.bits(32) + self.avgBitrate = r.bits(32) + + if (self.objectTypeIndication, self.streamType) != (0x40, 0x5): + return + + # all from here is optional + if length * 8 == r.get_position(): + return + + tag = r.bits(8) + except BitReaderError as e: + raise DescriptorError(e) + + if tag == DecoderSpecificInfo.TAG: + assert r.is_aligned() + self.decSpecificInfo = DecoderSpecificInfo.parse(fileobj) + + @property + def codec_param(self): + """string""" + + param = u".%X" % self.objectTypeIndication + info = self.decSpecificInfo + if info is not None: + param += u".%d" % info.audioObjectType + return param + + @property + def codec_desc(self): + """string or None""" + + info = self.decSpecificInfo + desc = None + if info is not None: + desc = info.description + return desc + + +class DecoderSpecificInfo(BaseDescriptor): + + TAG = 0x5 + + _TYPE_NAMES = [ + None, "AAC MAIN", "AAC LC", "AAC SSR", "AAC LTP", "SBR", + "AAC scalable", "TwinVQ", "CELP", "HVXC", None, None, "TTSI", + "Main synthetic", "Wavetable synthesis", "General MIDI", + "Algorithmic Synthesis and Audio FX", "ER AAC LC", None, "ER AAC LTP", + "ER AAC scalable", "ER Twin VQ", "ER BSAC", "ER AAC LD", "ER CELP", + "ER HVXC", "ER HILN", "ER Parametric", "SSC", "PS", "MPEG Surround", + None, "Layer-1", "Layer-2", "Layer-3", "DST", "ALS", "SLS", + "SLS non-core", "ER AAC ELD", "SMR Simple", "SMR Main", "USAC", + "SAOC", "LD MPEG Surround", "USAC" + ] + + _FREQS = [ + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, + 12000, 11025, 8000, 7350, + ] + + @property + def description(self): + """string or None if unknown""" + + name = None + try: + name = self._TYPE_NAMES[self.audioObjectType] + except IndexError: + pass + if name is None: + return + if self.sbrPresentFlag == 1: + name += "+SBR" + if self.psPresentFlag == 1: + name += "+PS" + return text_type(name) + + @property + def sample_rate(self): + """0 means unknown""" + + if self.sbrPresentFlag == 1: + return self.extensionSamplingFrequency + elif self.sbrPresentFlag == 0: + return self.samplingFrequency + else: + # these are all types that support SBR + aot_can_sbr = (1, 2, 3, 4, 6, 17, 19, 20, 22) + if self.audioObjectType not in aot_can_sbr: + return self.samplingFrequency + # there shouldn't be SBR for > 48KHz + if self.samplingFrequency > 24000: + return self.samplingFrequency + # either samplingFrequency or samplingFrequency * 2 + return 0 + + @property + def channels(self): + """channel count or 0 for unknown""" + + # from ProgramConfigElement() + if hasattr(self, "pce_channels"): + return self.pce_channels + + conf = getattr( + self, "extensionChannelConfiguration", self.channelConfiguration) + + if conf == 1: + if self.psPresentFlag == -1: + return 0 + elif self.psPresentFlag == 1: + return 2 + else: + return 1 + elif conf == 7: + return 8 + elif conf > 7: + return 0 + else: + return conf + + def _get_audio_object_type(self, r): + """Raises BitReaderError""" + + audioObjectType = r.bits(5) + if audioObjectType == 31: + audioObjectTypeExt = r.bits(6) + audioObjectType = 32 + audioObjectTypeExt + return audioObjectType + + def _get_sampling_freq(self, r): + """Raises BitReaderError""" + + samplingFrequencyIndex = r.bits(4) + if samplingFrequencyIndex == 0xf: + samplingFrequency = r.bits(24) + else: + try: + samplingFrequency = self._FREQS[samplingFrequencyIndex] + except IndexError: + samplingFrequency = 0 + return samplingFrequency + + def __init__(self, fileobj, length): + """Raises DescriptorError""" + + r = BitReader(fileobj) + try: + self._parse(r, length) + except BitReaderError as e: + raise DescriptorError(e) + + def _parse(self, r, length): + """Raises BitReaderError""" + + def bits_left(): + return length * 8 - r.get_position() + + self.audioObjectType = self._get_audio_object_type(r) + self.samplingFrequency = self._get_sampling_freq(r) + self.channelConfiguration = r.bits(4) + + self.sbrPresentFlag = -1 + self.psPresentFlag = -1 + if self.audioObjectType in (5, 29): + self.extensionAudioObjectType = 5 + self.sbrPresentFlag = 1 + if self.audioObjectType == 29: + self.psPresentFlag = 1 + self.extensionSamplingFrequency = self._get_sampling_freq(r) + self.audioObjectType = self._get_audio_object_type(r) + if self.audioObjectType == 22: + self.extensionChannelConfiguration = r.bits(4) + else: + self.extensionAudioObjectType = 0 + + if self.audioObjectType in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23): + try: + GASpecificConfig(r, self) + except NotImplementedError: + # unsupported, (warn?) + return + else: + # unsupported + return + + if self.audioObjectType in ( + 17, 19, 20, 21, 22, 23, 24, 25, 26, 27, 39): + epConfig = r.bits(2) + if epConfig in (2, 3): + # unsupported + return + + if self.extensionAudioObjectType != 5 and bits_left() >= 16: + syncExtensionType = r.bits(11) + if syncExtensionType == 0x2b7: + self.extensionAudioObjectType = self._get_audio_object_type(r) + + if self.extensionAudioObjectType == 5: + self.sbrPresentFlag = r.bits(1) + if self.sbrPresentFlag == 1: + self.extensionSamplingFrequency = \ + self._get_sampling_freq(r) + if bits_left() >= 12: + syncExtensionType = r.bits(11) + if syncExtensionType == 0x548: + self.psPresentFlag = r.bits(1) + + if self.extensionAudioObjectType == 22: + self.sbrPresentFlag = r.bits(1) + if self.sbrPresentFlag == 1: + self.extensionSamplingFrequency = \ + self._get_sampling_freq(r) + self.extensionChannelConfiguration = r.bits(4) + + +def GASpecificConfig(r, info): + """Reads GASpecificConfig which is needed to get the data after that + (there is no length defined to skip it) and to read program_config_element + which can contain channel counts. + + May raise BitReaderError on error or + NotImplementedError if some reserved data was set. + """ + + assert isinstance(info, DecoderSpecificInfo) + + r.skip(1) # frameLengthFlag + dependsOnCoreCoder = r.bits(1) + if dependsOnCoreCoder: + r.skip(14) + extensionFlag = r.bits(1) + if not info.channelConfiguration: + pce = ProgramConfigElement(r) + info.pce_channels = pce.channels + if info.audioObjectType == 6 or info.audioObjectType == 20: + r.skip(3) + if extensionFlag: + if info.audioObjectType == 22: + r.skip(5 + 11) + if info.audioObjectType in (17, 19, 20, 23): + r.skip(1 + 1 + 1) + extensionFlag3 = r.bits(1) + if extensionFlag3 != 0: + raise NotImplementedError("extensionFlag3 set") diff --git a/resources/lib/mutagen/mp4/_atom.py b/resources/lib/mutagen/mp4/_atom.py new file mode 100644 index 00000000..f73eb556 --- /dev/null +++ b/resources/lib/mutagen/mp4/_atom.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +import struct + +from mutagen._compat import PY2 + +# This is not an exhaustive list of container atoms, but just the +# ones this module needs to peek inside. +_CONTAINERS = [b"moov", b"udta", b"trak", b"mdia", b"meta", b"ilst", + b"stbl", b"minf", b"moof", b"traf"] +_SKIP_SIZE = {b"meta": 4} + + +class AtomError(Exception): + pass + + +class Atom(object): + """An individual atom. + + Attributes: + children -- list child atoms (or None for non-container atoms) + length -- length of this atom, including length and name + datalength = -- length of this atom without length, name + name -- four byte name of the atom, as a str + offset -- location in the constructor-given fileobj of this atom + + This structure should only be used internally by Mutagen. + """ + + children = None + + def __init__(self, fileobj, level=0): + """May raise AtomError""" + + self.offset = fileobj.tell() + try: + self.length, self.name = struct.unpack(">I4s", fileobj.read(8)) + except struct.error: + raise AtomError("truncated data") + self._dataoffset = self.offset + 8 + if self.length == 1: + try: + self.length, = struct.unpack(">Q", fileobj.read(8)) + except struct.error: + raise AtomError("truncated data") + self._dataoffset += 8 + if self.length < 16: + raise AtomError( + "64 bit atom length can only be 16 and higher") + elif self.length == 0: + if level != 0: + raise AtomError( + "only a top-level atom can have zero length") + # Only the last atom is supposed to have a zero-length, meaning it + # extends to the end of file. + fileobj.seek(0, 2) + self.length = fileobj.tell() - self.offset + fileobj.seek(self.offset + 8, 0) + elif self.length < 8: + raise AtomError( + "atom length can only be 0, 1 or 8 and higher") + + if self.name in _CONTAINERS: + self.children = [] + fileobj.seek(_SKIP_SIZE.get(self.name, 0), 1) + while fileobj.tell() < self.offset + self.length: + self.children.append(Atom(fileobj, level + 1)) + else: + fileobj.seek(self.offset + self.length, 0) + + @property + def datalength(self): + return self.length - (self._dataoffset - self.offset) + + def read(self, fileobj): + """Return if all data could be read and the atom payload""" + + fileobj.seek(self._dataoffset, 0) + data = fileobj.read(self.datalength) + return len(data) == self.datalength, data + + @staticmethod + def render(name, data): + """Render raw atom data.""" + # this raises OverflowError if Py_ssize_t can't handle the atom data + size = len(data) + 8 + if size <= 0xFFFFFFFF: + return struct.pack(">I4s", size, name) + data + else: + return struct.pack(">I4sQ", 1, name, size + 8) + data + + def findall(self, name, recursive=False): + """Recursively find all child atoms by specified name.""" + if self.children is not None: + for child in self.children: + if child.name == name: + yield child + if recursive: + for atom in child.findall(name, True): + yield atom + + def __getitem__(self, remaining): + """Look up a child atom, potentially recursively. + + e.g. atom['udta', 'meta'] => + """ + if not remaining: + return self + elif self.children is None: + raise KeyError("%r is not a container" % self.name) + for child in self.children: + if child.name == remaining[0]: + return child[remaining[1:]] + else: + raise KeyError("%r not found" % remaining[0]) + + def __repr__(self): + cls = self.__class__.__name__ + if self.children is None: + return "<%s name=%r length=%r offset=%r>" % ( + cls, self.name, self.length, self.offset) + else: + children = "\n".join([" " + line for child in self.children + for line in repr(child).splitlines()]) + return "<%s name=%r length=%r offset=%r\n%s>" % ( + cls, self.name, self.length, self.offset, children) + + +class Atoms(object): + """Root atoms in a given file. + + Attributes: + atoms -- a list of top-level atoms as Atom objects + + This structure should only be used internally by Mutagen. + """ + + def __init__(self, fileobj): + self.atoms = [] + fileobj.seek(0, 2) + end = fileobj.tell() + fileobj.seek(0) + while fileobj.tell() + 8 <= end: + self.atoms.append(Atom(fileobj)) + + def path(self, *names): + """Look up and return the complete path of an atom. + + For example, atoms.path('moov', 'udta', 'meta') will return a + list of three atoms, corresponding to the moov, udta, and meta + atoms. + """ + + path = [self] + for name in names: + path.append(path[-1][name, ]) + return path[1:] + + def __contains__(self, names): + try: + self[names] + except KeyError: + return False + return True + + def __getitem__(self, names): + """Look up a child atom. + + 'names' may be a list of atoms (['moov', 'udta']) or a string + specifying the complete path ('moov.udta'). + """ + + if PY2: + if isinstance(names, basestring): + names = names.split(b".") + else: + if isinstance(names, bytes): + names = names.split(b".") + + for child in self.atoms: + if child.name == names[0]: + return child[names[1:]] + else: + raise KeyError("%r not found" % names[0]) + + def __repr__(self): + return "\n".join([repr(child) for child in self.atoms]) diff --git a/resources/lib/mutagen/mp4/_util.py b/resources/lib/mutagen/mp4/_util.py new file mode 100644 index 00000000..9583334a --- /dev/null +++ b/resources/lib/mutagen/mp4/_util.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +from mutagen._util import cdata + + +def parse_full_atom(data): + """Some atoms are versioned. Split them up in (version, flags, payload). + Can raise ValueError. + """ + + if len(data) < 4: + raise ValueError("not enough data") + + version = ord(data[0:1]) + flags = cdata.uint_be(b"\x00" + data[1:4]) + return version, flags, data[4:] diff --git a/resources/lib/mutagen/musepack.py b/resources/lib/mutagen/musepack.py new file mode 100644 index 00000000..7880958b --- /dev/null +++ b/resources/lib/mutagen/musepack.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky +# Copyright (C) 2012 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Musepack audio streams with APEv2 tags. + +Musepack is an audio format originally based on the MPEG-1 Layer-2 +algorithms. Stream versions 4 through 7 are supported. + +For more information, see http://www.musepack.net/. +""" + +__all__ = ["Musepack", "Open", "delete"] + +import struct + +from ._compat import endswith, xrange +from mutagen import StreamInfo +from mutagen.apev2 import APEv2File, error, delete +from mutagen.id3 import BitPaddedInt +from mutagen._util import cdata + + +class MusepackHeaderError(error): + pass + + +RATES = [44100, 48000, 37800, 32000] + + +def _parse_sv8_int(fileobj, limit=9): + """Reads (max limit) bytes from fileobj until the MSB is zero. + All 7 LSB will be merged to a big endian uint. + + Raises ValueError in case not MSB is zero, or EOFError in + case the file ended before limit is reached. + + Returns (parsed number, number of bytes read) + """ + + num = 0 + for i in xrange(limit): + c = fileobj.read(1) + if len(c) != 1: + raise EOFError + c = bytearray(c) + num = (num << 7) | (c[0] & 0x7F) + if not c[0] & 0x80: + return num, i + 1 + if limit > 0: + raise ValueError + return 0, 0 + + +def _calc_sv8_gain(gain): + # 64.82 taken from mpcdec + return 64.82 - gain / 256.0 + + +def _calc_sv8_peak(peak): + return (10 ** (peak / (256.0 * 20.0)) / 65535.0) + + +class MusepackInfo(StreamInfo): + """Musepack stream information. + + Attributes: + + * channels -- number of audio channels + * length -- file length in seconds, as a float + * sample_rate -- audio sampling rate in Hz + * bitrate -- audio bitrate, in bits per second + * version -- Musepack stream version + + Optional Attributes: + + * title_gain, title_peak -- Replay Gain and peak data for this song + * album_gain, album_peak -- Replay Gain and peak data for this album + + These attributes are only available in stream version 7/8. The + gains are a float, +/- some dB. The peaks are a percentage [0..1] of + the maximum amplitude. This means to get a number comparable to + VorbisGain, you must multiply the peak by 2. + """ + + def __init__(self, fileobj): + header = fileobj.read(4) + if len(header) != 4: + raise MusepackHeaderError("not a Musepack file") + + # Skip ID3v2 tags + if header[:3] == b"ID3": + header = fileobj.read(6) + if len(header) != 6: + raise MusepackHeaderError("not a Musepack file") + size = 10 + BitPaddedInt(header[2:6]) + fileobj.seek(size) + header = fileobj.read(4) + if len(header) != 4: + raise MusepackHeaderError("not a Musepack file") + + if header.startswith(b"MPCK"): + self.__parse_sv8(fileobj) + else: + self.__parse_sv467(fileobj) + + if not self.bitrate and self.length != 0: + fileobj.seek(0, 2) + self.bitrate = int(round(fileobj.tell() * 8 / self.length)) + + def __parse_sv8(self, fileobj): + # SV8 http://trac.musepack.net/trac/wiki/SV8Specification + + key_size = 2 + mandatory_packets = [b"SH", b"RG"] + + def check_frame_key(key): + if ((len(frame_type) != key_size) or + (not b'AA' <= frame_type <= b'ZZ')): + raise MusepackHeaderError("Invalid frame key.") + + frame_type = fileobj.read(key_size) + check_frame_key(frame_type) + + while frame_type not in (b"AP", b"SE") and mandatory_packets: + try: + frame_size, slen = _parse_sv8_int(fileobj) + except (EOFError, ValueError): + raise MusepackHeaderError("Invalid packet size.") + data_size = frame_size - key_size - slen + # packets can be at maximum data_size big and are padded with zeros + + if frame_type == b"SH": + mandatory_packets.remove(frame_type) + self.__parse_stream_header(fileobj, data_size) + elif frame_type == b"RG": + mandatory_packets.remove(frame_type) + self.__parse_replaygain_packet(fileobj, data_size) + else: + fileobj.seek(data_size, 1) + + frame_type = fileobj.read(key_size) + check_frame_key(frame_type) + + if mandatory_packets: + raise MusepackHeaderError("Missing mandatory packets: %s." % + ", ".join(map(repr, mandatory_packets))) + + self.length = float(self.samples) / self.sample_rate + self.bitrate = 0 + + def __parse_stream_header(self, fileobj, data_size): + # skip CRC + fileobj.seek(4, 1) + remaining_size = data_size - 4 + + try: + self.version = bytearray(fileobj.read(1))[0] + except TypeError: + raise MusepackHeaderError("SH packet ended unexpectedly.") + + remaining_size -= 1 + + try: + samples, l1 = _parse_sv8_int(fileobj) + samples_skip, l2 = _parse_sv8_int(fileobj) + except (EOFError, ValueError): + raise MusepackHeaderError( + "SH packet: Invalid sample counts.") + + self.samples = samples - samples_skip + remaining_size -= l1 + l2 + + data = fileobj.read(remaining_size) + if len(data) != remaining_size: + raise MusepackHeaderError("SH packet ended unexpectedly.") + self.sample_rate = RATES[bytearray(data)[0] >> 5] + self.channels = (bytearray(data)[1] >> 4) + 1 + + def __parse_replaygain_packet(self, fileobj, data_size): + data = fileobj.read(data_size) + if data_size < 9: + raise MusepackHeaderError("Invalid RG packet size.") + if len(data) != data_size: + raise MusepackHeaderError("RG packet ended unexpectedly.") + title_gain = cdata.short_be(data[1:3]) + title_peak = cdata.short_be(data[3:5]) + album_gain = cdata.short_be(data[5:7]) + album_peak = cdata.short_be(data[7:9]) + if title_gain: + self.title_gain = _calc_sv8_gain(title_gain) + if title_peak: + self.title_peak = _calc_sv8_peak(title_peak) + if album_gain: + self.album_gain = _calc_sv8_gain(album_gain) + if album_peak: + self.album_peak = _calc_sv8_peak(album_peak) + + def __parse_sv467(self, fileobj): + fileobj.seek(-4, 1) + header = fileobj.read(32) + if len(header) != 32: + raise MusepackHeaderError("not a Musepack file") + + # SV7 + if header.startswith(b"MP+"): + self.version = bytearray(header)[3] & 0xF + if self.version < 7: + raise MusepackHeaderError("not a Musepack file") + frames = cdata.uint_le(header[4:8]) + flags = cdata.uint_le(header[8:12]) + + self.title_peak, self.title_gain = struct.unpack( + "> 16) & 0x0003] + self.bitrate = 0 + # SV4-SV6 + else: + header_dword = cdata.uint_le(header[0:4]) + self.version = (header_dword >> 11) & 0x03FF + if self.version < 4 or self.version > 6: + raise MusepackHeaderError("not a Musepack file") + self.bitrate = (header_dword >> 23) & 0x01FF + self.sample_rate = 44100 + if self.version >= 5: + frames = cdata.uint_le(header[4:8]) + else: + frames = cdata.ushort_le(header[6:8]) + if self.version < 6: + frames -= 1 + self.channels = 2 + self.length = float(frames * 1152 - 576) / self.sample_rate + + def pprint(self): + rg_data = [] + if hasattr(self, "title_gain"): + rg_data.append(u"%+0.2f (title)" % self.title_gain) + if hasattr(self, "album_gain"): + rg_data.append(u"%+0.2f (album)" % self.album_gain) + rg_data = (rg_data and ", Gain: " + ", ".join(rg_data)) or "" + + return u"Musepack SV%d, %.2f seconds, %d Hz, %d bps%s" % ( + self.version, self.length, self.sample_rate, self.bitrate, rg_data) + + +class Musepack(APEv2File): + _Info = MusepackInfo + _mimes = ["audio/x-musepack", "audio/x-mpc"] + + @staticmethod + def score(filename, fileobj, header): + filename = filename.lower() + + return (header.startswith(b"MP+") + header.startswith(b"MPCK") + + endswith(filename, b".mpc")) + + +Open = Musepack diff --git a/resources/lib/mutagen/ogg.py b/resources/lib/mutagen/ogg.py new file mode 100644 index 00000000..9961a966 --- /dev/null +++ b/resources/lib/mutagen/ogg.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg bitstreams and pages. + +This module reads and writes a subset of the Ogg bitstream format +version 0. It does *not* read or write Ogg Vorbis files! For that, +you should use mutagen.oggvorbis. + +This implementation is based on the RFC 3533 standard found at +http://www.xiph.org/ogg/doc/rfc3533.txt. +""" + +import struct +import sys +import zlib + +from mutagen import FileType +from mutagen._util import cdata, resize_bytes, MutagenError +from ._compat import cBytesIO, reraise, chr_, izip, xrange + + +class error(IOError, MutagenError): + """Ogg stream parsing errors.""" + + pass + + +class OggPage(object): + """A single Ogg page (not necessarily a single encoded packet). + + A page is a header of 26 bytes, followed by the length of the + data, followed by the data. + + The constructor is givin a file-like object pointing to the start + of an Ogg page. After the constructor is finished it is pointing + to the start of the next page. + + Attributes: + + * version -- stream structure version (currently always 0) + * position -- absolute stream position (default -1) + * serial -- logical stream serial number (default 0) + * sequence -- page sequence number within logical stream (default 0) + * offset -- offset this page was read from (default None) + * complete -- if the last packet on this page is complete (default True) + * packets -- list of raw packet data (default []) + + Note that if 'complete' is false, the next page's 'continued' + property must be true (so set both when constructing pages). + + If a file-like object is supplied to the constructor, the above + attributes will be filled in based on it. + """ + + version = 0 + __type_flags = 0 + position = 0 + serial = 0 + sequence = 0 + offset = None + complete = True + + def __init__(self, fileobj=None): + self.packets = [] + + if fileobj is None: + return + + self.offset = fileobj.tell() + + header = fileobj.read(27) + if len(header) == 0: + raise EOFError + + try: + (oggs, self.version, self.__type_flags, + self.position, self.serial, self.sequence, + crc, segments) = struct.unpack("<4sBBqIIiB", header) + except struct.error: + raise error("unable to read full header; got %r" % header) + + if oggs != b"OggS": + raise error("read %r, expected %r, at 0x%x" % ( + oggs, b"OggS", fileobj.tell() - 27)) + + if self.version != 0: + raise error("version %r unsupported" % self.version) + + total = 0 + lacings = [] + lacing_bytes = fileobj.read(segments) + if len(lacing_bytes) != segments: + raise error("unable to read %r lacing bytes" % segments) + for c in bytearray(lacing_bytes): + total += c + if c < 255: + lacings.append(total) + total = 0 + if total: + lacings.append(total) + self.complete = False + + self.packets = [fileobj.read(l) for l in lacings] + if [len(p) for p in self.packets] != lacings: + raise error("unable to read full data") + + def __eq__(self, other): + """Two Ogg pages are the same if they write the same data.""" + try: + return (self.write() == other.write()) + except AttributeError: + return False + + __hash__ = object.__hash__ + + def __repr__(self): + attrs = ['version', 'position', 'serial', 'sequence', 'offset', + 'complete', 'continued', 'first', 'last'] + values = ["%s=%r" % (attr, getattr(self, attr)) for attr in attrs] + return "<%s %s, %d bytes in %d packets>" % ( + type(self).__name__, " ".join(values), sum(map(len, self.packets)), + len(self.packets)) + + def write(self): + """Return a string encoding of the page header and data. + + A ValueError is raised if the data is too big to fit in a + single page. + """ + + data = [ + struct.pack("<4sBBqIIi", b"OggS", self.version, self.__type_flags, + self.position, self.serial, self.sequence, 0) + ] + + lacing_data = [] + for datum in self.packets: + quot, rem = divmod(len(datum), 255) + lacing_data.append(b"\xff" * quot + chr_(rem)) + lacing_data = b"".join(lacing_data) + if not self.complete and lacing_data.endswith(b"\x00"): + lacing_data = lacing_data[:-1] + data.append(chr_(len(lacing_data))) + data.append(lacing_data) + data.extend(self.packets) + data = b"".join(data) + + # Python's CRC is swapped relative to Ogg's needs. + # crc32 returns uint prior to py2.6 on some platforms, so force uint + crc = (~zlib.crc32(data.translate(cdata.bitswap), -1)) & 0xffffffff + # Although we're using to_uint_be, this actually makes the CRC + # a proper le integer, since Python's CRC is byteswapped. + crc = cdata.to_uint_be(crc).translate(cdata.bitswap) + data = data[:22] + crc + data[26:] + return data + + @property + def size(self): + """Total frame size.""" + + size = 27 # Initial header size + for datum in self.packets: + quot, rem = divmod(len(datum), 255) + size += quot + 1 + if not self.complete and rem == 0: + # Packet contains a multiple of 255 bytes and is not + # terminated, so we don't have a \x00 at the end. + size -= 1 + size += sum(map(len, self.packets)) + return size + + def __set_flag(self, bit, val): + mask = 1 << bit + if val: + self.__type_flags |= mask + else: + self.__type_flags &= ~mask + + continued = property( + lambda self: cdata.test_bit(self.__type_flags, 0), + lambda self, v: self.__set_flag(0, v), + doc="The first packet is continued from the previous page.") + + first = property( + lambda self: cdata.test_bit(self.__type_flags, 1), + lambda self, v: self.__set_flag(1, v), + doc="This is the first page of a logical bitstream.") + + last = property( + lambda self: cdata.test_bit(self.__type_flags, 2), + lambda self, v: self.__set_flag(2, v), + doc="This is the last page of a logical bitstream.") + + @staticmethod + def renumber(fileobj, serial, start): + """Renumber pages belonging to a specified logical stream. + + fileobj must be opened with mode r+b or w+b. + + Starting at page number 'start', renumber all pages belonging + to logical stream 'serial'. Other pages will be ignored. + + fileobj must point to the start of a valid Ogg page; any + occuring after it and part of the specified logical stream + will be numbered. No adjustment will be made to the data in + the pages nor the granule position; only the page number, and + so also the CRC. + + If an error occurs (e.g. non-Ogg data is found), fileobj will + be left pointing to the place in the stream the error occured, + but the invalid data will be left intact (since this function + does not change the total file size). + """ + + number = start + while True: + try: + page = OggPage(fileobj) + except EOFError: + break + else: + if page.serial != serial: + # Wrong stream, skip this page. + continue + # Changing the number can't change the page size, + # so seeking back based on the current size is safe. + fileobj.seek(-page.size, 1) + page.sequence = number + fileobj.write(page.write()) + fileobj.seek(page.offset + page.size, 0) + number += 1 + + @staticmethod + def to_packets(pages, strict=False): + """Construct a list of packet data from a list of Ogg pages. + + If strict is true, the first page must start a new packet, + and the last page must end the last packet. + """ + + serial = pages[0].serial + sequence = pages[0].sequence + packets = [] + + if strict: + if pages[0].continued: + raise ValueError("first packet is continued") + if not pages[-1].complete: + raise ValueError("last packet does not complete") + elif pages and pages[0].continued: + packets.append([b""]) + + for page in pages: + if serial != page.serial: + raise ValueError("invalid serial number in %r" % page) + elif sequence != page.sequence: + raise ValueError("bad sequence number in %r" % page) + else: + sequence += 1 + + if page.continued: + packets[-1].append(page.packets[0]) + else: + packets.append([page.packets[0]]) + packets.extend([p] for p in page.packets[1:]) + + return [b"".join(p) for p in packets] + + @classmethod + def _from_packets_try_preserve(cls, packets, old_pages): + """Like from_packets but in case the size and number of the packets + is the same as in the given pages the layout of the pages will + be copied (the page size and number will match). + + If the packets don't match this behaves like:: + + OggPage.from_packets(packets, sequence=old_pages[0].sequence) + """ + + old_packets = cls.to_packets(old_pages) + + if [len(p) for p in packets] != [len(p) for p in old_packets]: + # doesn't match, fall back + return cls.from_packets(packets, old_pages[0].sequence) + + new_data = b"".join(packets) + new_pages = [] + for old in old_pages: + new = OggPage() + new.sequence = old.sequence + new.complete = old.complete + new.continued = old.continued + new.position = old.position + for p in old.packets: + data, new_data = new_data[:len(p)], new_data[len(p):] + new.packets.append(data) + new_pages.append(new) + assert not new_data + + return new_pages + + @staticmethod + def from_packets(packets, sequence=0, default_size=4096, + wiggle_room=2048): + """Construct a list of Ogg pages from a list of packet data. + + The algorithm will generate pages of approximately + default_size in size (rounded down to the nearest multiple of + 255). However, it will also allow pages to increase to + approximately default_size + wiggle_room if allowing the + wiggle room would finish a packet (only one packet will be + finished in this way per page; if the next packet would fit + into the wiggle room, it still starts on a new page). + + This method reduces packet fragmentation when packet sizes are + slightly larger than the default page size, while still + ensuring most pages are of the average size. + + Pages are numbered started at 'sequence'; other information is + uninitialized. + """ + + chunk_size = (default_size // 255) * 255 + + pages = [] + + page = OggPage() + page.sequence = sequence + + for packet in packets: + page.packets.append(b"") + while packet: + data, packet = packet[:chunk_size], packet[chunk_size:] + if page.size < default_size and len(page.packets) < 255: + page.packets[-1] += data + else: + # If we've put any packet data into this page yet, + # we need to mark it incomplete. However, we can + # also have just started this packet on an already + # full page, in which case, just start the new + # page with this packet. + if page.packets[-1]: + page.complete = False + if len(page.packets) == 1: + page.position = -1 + else: + page.packets.pop(-1) + pages.append(page) + page = OggPage() + page.continued = not pages[-1].complete + page.sequence = pages[-1].sequence + 1 + page.packets.append(data) + + if len(packet) < wiggle_room: + page.packets[-1] += packet + packet = b"" + + if page.packets: + pages.append(page) + + return pages + + @classmethod + def replace(cls, fileobj, old_pages, new_pages): + """Replace old_pages with new_pages within fileobj. + + old_pages must have come from reading fileobj originally. + new_pages are assumed to have the 'same' data as old_pages, + and so the serial and sequence numbers will be copied, as will + the flags for the first and last pages. + + fileobj will be resized and pages renumbered as necessary. As + such, it must be opened r+b or w+b. + """ + + if not len(old_pages) or not len(new_pages): + raise ValueError("empty pages list not allowed") + + # Number the new pages starting from the first old page. + first = old_pages[0].sequence + for page, seq in izip(new_pages, + xrange(first, first + len(new_pages))): + page.sequence = seq + page.serial = old_pages[0].serial + + new_pages[0].first = old_pages[0].first + new_pages[0].last = old_pages[0].last + new_pages[0].continued = old_pages[0].continued + + new_pages[-1].first = old_pages[-1].first + new_pages[-1].last = old_pages[-1].last + new_pages[-1].complete = old_pages[-1].complete + if not new_pages[-1].complete and len(new_pages[-1].packets) == 1: + new_pages[-1].position = -1 + + new_data = [cls.write(p) for p in new_pages] + + # Add dummy data or merge the remaining data together so multiple + # new pages replace an old one + pages_diff = len(old_pages) - len(new_data) + if pages_diff > 0: + new_data.extend([b""] * pages_diff) + elif pages_diff < 0: + new_data[pages_diff - 1:] = [b"".join(new_data[pages_diff - 1:])] + + # Replace pages one by one. If the sizes match no resize happens. + offset_adjust = 0 + new_data_end = None + assert len(old_pages) == len(new_data) + for old_page, data in izip(old_pages, new_data): + offset = old_page.offset + offset_adjust + data_size = len(data) + resize_bytes(fileobj, old_page.size, data_size, offset) + fileobj.seek(offset, 0) + fileobj.write(data) + new_data_end = offset + data_size + offset_adjust += (data_size - old_page.size) + + # Finally, if there's any discrepency in length, we need to + # renumber the pages for the logical stream. + if len(old_pages) != len(new_pages): + fileobj.seek(new_data_end, 0) + serial = new_pages[-1].serial + sequence = new_pages[-1].sequence + 1 + cls.renumber(fileobj, serial, sequence) + + @staticmethod + def find_last(fileobj, serial): + """Find the last page of the stream 'serial'. + + If the file is not multiplexed this function is fast. If it is, + it must read the whole the stream. + + This finds the last page in the actual file object, or the last + page in the stream (with eos set), whichever comes first. + """ + + # For non-muxed streams, look at the last page. + try: + fileobj.seek(-256 * 256, 2) + except IOError: + # The file is less than 64k in length. + fileobj.seek(0) + data = fileobj.read() + try: + index = data.rindex(b"OggS") + except ValueError: + raise error("unable to find final Ogg header") + bytesobj = cBytesIO(data[index:]) + best_page = None + try: + page = OggPage(bytesobj) + except error: + pass + else: + if page.serial == serial: + if page.last: + return page + else: + best_page = page + else: + best_page = None + + # The stream is muxed, so use the slow way. + fileobj.seek(0) + try: + page = OggPage(fileobj) + while not page.last: + page = OggPage(fileobj) + while page.serial != serial: + page = OggPage(fileobj) + best_page = page + return page + except error: + return best_page + except EOFError: + return best_page + + +class OggFileType(FileType): + """An generic Ogg file.""" + + _Info = None + _Tags = None + _Error = None + _mimes = ["application/ogg", "application/x-ogg"] + + def load(self, filename): + """Load file information from a filename.""" + + self.filename = filename + with open(filename, "rb") as fileobj: + try: + self.info = self._Info(fileobj) + self.tags = self._Tags(fileobj, self.info) + self.info._post_tags(fileobj) + except error as e: + reraise(self._Error, e, sys.exc_info()[2]) + except EOFError: + raise self._Error("no appropriate stream found") + + def delete(self, filename=None): + """Remove tags from a file. + + If no filename is given, the one most recently loaded is used. + """ + + if filename is None: + filename = self.filename + + self.tags.clear() + # TODO: we should delegate the deletion to the subclass and not through + # _inject. + with open(filename, "rb+") as fileobj: + try: + self.tags._inject(fileobj, lambda x: 0) + except error as e: + reraise(self._Error, e, sys.exc_info()[2]) + except EOFError: + raise self._Error("no appropriate stream found") + + def add_tags(self): + raise self._Error + + def save(self, filename=None, padding=None): + """Save a tag to a file. + + If no filename is given, the one most recently loaded is used. + """ + + if filename is None: + filename = self.filename + fileobj = open(filename, "rb+") + try: + try: + self.tags._inject(fileobj, padding) + except error as e: + reraise(self._Error, e, sys.exc_info()[2]) + except EOFError: + raise self._Error("no appropriate stream found") + finally: + fileobj.close() diff --git a/resources/lib/mutagen/oggflac.py b/resources/lib/mutagen/oggflac.py new file mode 100644 index 00000000..b86226ca --- /dev/null +++ b/resources/lib/mutagen/oggflac.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg FLAC comments. + +This module handles FLAC files wrapped in an Ogg bitstream. The first +FLAC stream found is used. For 'naked' FLACs, see mutagen.flac. + +This module is based off the specification at +http://flac.sourceforge.net/ogg_mapping.html. +""" + +__all__ = ["OggFLAC", "Open", "delete"] + +import struct + +from ._compat import cBytesIO + +from mutagen import StreamInfo +from mutagen.flac import StreamInfo as FLACStreamInfo, error as FLACError +from mutagen._vorbis import VCommentDict +from mutagen.ogg import OggPage, OggFileType, error as OggError + + +class error(OggError): + pass + + +class OggFLACHeaderError(error): + pass + + +class OggFLACStreamInfo(StreamInfo): + """Ogg FLAC stream info.""" + + length = 0 + """File length in seconds, as a float""" + + channels = 0 + """Number of channels""" + + sample_rate = 0 + """Sample rate in Hz""" + + def __init__(self, fileobj): + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x7FFLAC"): + page = OggPage(fileobj) + major, minor, self.packets, flac = struct.unpack( + ">BBH4s", page.packets[0][5:13]) + if flac != b"fLaC": + raise OggFLACHeaderError("invalid FLAC marker (%r)" % flac) + elif (major, minor) != (1, 0): + raise OggFLACHeaderError( + "unknown mapping version: %d.%d" % (major, minor)) + self.serial = page.serial + + # Skip over the block header. + stringobj = cBytesIO(page.packets[0][17:]) + + try: + flac_info = FLACStreamInfo(stringobj) + except FLACError as e: + raise OggFLACHeaderError(e) + + for attr in ["min_blocksize", "max_blocksize", "sample_rate", + "channels", "bits_per_sample", "total_samples", "length"]: + setattr(self, attr, getattr(flac_info, attr)) + + def _post_tags(self, fileobj): + if self.length: + return + page = OggPage.find_last(fileobj, self.serial) + self.length = page.position / float(self.sample_rate) + + def pprint(self): + return u"Ogg FLAC, %.2f seconds, %d Hz" % ( + self.length, self.sample_rate) + + +class OggFLACVComment(VCommentDict): + + def __init__(self, fileobj, info): + # data should be pointing at the start of an Ogg page, after + # the first FLAC page. + pages = [] + complete = False + while not complete: + page = OggPage(fileobj) + if page.serial == info.serial: + pages.append(page) + complete = page.complete or (len(page.packets) > 1) + comment = cBytesIO(OggPage.to_packets(pages)[0][4:]) + super(OggFLACVComment, self).__init__(comment, framing=False) + + def _inject(self, fileobj, padding_func): + """Write tag data into the FLAC Vorbis comment packet/page.""" + + # Ogg FLAC has no convenient data marker like Vorbis, but the + # second packet - and second page - must be the comment data. + fileobj.seek(0) + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x7FFLAC"): + page = OggPage(fileobj) + + first_page = page + while not (page.sequence == 1 and page.serial == first_page.serial): + page = OggPage(fileobj) + + old_pages = [page] + while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1): + page = OggPage(fileobj) + if page.serial == first_page.serial: + old_pages.append(page) + + packets = OggPage.to_packets(old_pages, strict=False) + + # Set the new comment block. + data = self.write(framing=False) + data = packets[0][:1] + struct.pack(">I", len(data))[-3:] + data + packets[0] = data + + new_pages = OggPage.from_packets(packets, old_pages[0].sequence) + OggPage.replace(fileobj, old_pages, new_pages) + + +class OggFLAC(OggFileType): + """An Ogg FLAC file.""" + + _Info = OggFLACStreamInfo + _Tags = OggFLACVComment + _Error = OggFLACHeaderError + _mimes = ["audio/x-oggflac"] + + info = None + """A `OggFLACStreamInfo`""" + + tags = None + """A `VCommentDict`""" + + def save(self, filename=None): + return super(OggFLAC, self).save(filename) + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"OggS") * ( + (b"FLAC" in header) + (b"fLaC" in header))) + + +Open = OggFLAC + + +def delete(filename): + """Remove tags from a file.""" + + OggFLAC(filename).delete() diff --git a/resources/lib/mutagen/oggopus.py b/resources/lib/mutagen/oggopus.py new file mode 100644 index 00000000..7154e479 --- /dev/null +++ b/resources/lib/mutagen/oggopus.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2012, 2013 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg Opus comments. + +This module handles Opus files wrapped in an Ogg bitstream. The +first Opus stream found is used. + +Based on http://tools.ietf.org/html/draft-terriberry-oggopus-01 +""" + +__all__ = ["OggOpus", "Open", "delete"] + +import struct + +from mutagen import StreamInfo +from mutagen._compat import BytesIO +from mutagen._util import get_size +from mutagen._tags import PaddingInfo +from mutagen._vorbis import VCommentDict +from mutagen.ogg import OggPage, OggFileType, error as OggError + + +class error(OggError): + pass + + +class OggOpusHeaderError(error): + pass + + +class OggOpusInfo(StreamInfo): + """Ogg Opus stream information.""" + + length = 0 + """File length in seconds, as a float""" + + channels = 0 + """Number of channels""" + + def __init__(self, fileobj): + page = OggPage(fileobj) + while not page.packets[0].startswith(b"OpusHead"): + page = OggPage(fileobj) + + self.serial = page.serial + + if not page.first: + raise OggOpusHeaderError( + "page has ID header, but doesn't start a stream") + + (version, self.channels, pre_skip, orig_sample_rate, output_gain, + channel_map) = struct.unpack("> 4 + if major != 0: + raise OggOpusHeaderError("version %r unsupported" % major) + + def _post_tags(self, fileobj): + page = OggPage.find_last(fileobj, self.serial) + self.length = (page.position - self.__pre_skip) / float(48000) + + def pprint(self): + return u"Ogg Opus, %.2f seconds" % (self.length) + + +class OggOpusVComment(VCommentDict): + """Opus comments embedded in an Ogg bitstream.""" + + def __get_comment_pages(self, fileobj, info): + # find the first tags page with the right serial + page = OggPage(fileobj) + while ((info.serial != page.serial) or + not page.packets[0].startswith(b"OpusTags")): + page = OggPage(fileobj) + + # get all comment pages + pages = [page] + while not (pages[-1].complete or len(pages[-1].packets) > 1): + page = OggPage(fileobj) + if page.serial == pages[0].serial: + pages.append(page) + + return pages + + def __init__(self, fileobj, info): + pages = self.__get_comment_pages(fileobj, info) + data = OggPage.to_packets(pages)[0][8:] # Strip OpusTags + fileobj = BytesIO(data) + super(OggOpusVComment, self).__init__(fileobj, framing=False) + self._padding = len(data) - self._size + + # in case the LSB of the first byte after v-comment is 1, preserve the + # following data + padding_flag = fileobj.read(1) + if padding_flag and ord(padding_flag) & 0x1: + self._pad_data = padding_flag + fileobj.read() + self._padding = 0 # we have to preserve, so no padding + else: + self._pad_data = b"" + + def _inject(self, fileobj, padding_func): + fileobj.seek(0) + info = OggOpusInfo(fileobj) + old_pages = self.__get_comment_pages(fileobj, info) + + packets = OggPage.to_packets(old_pages) + vcomment_data = b"OpusTags" + self.write(framing=False) + + if self._pad_data: + # if we have padding data to preserver we can't add more padding + # as long as we don't know the structure of what follows + packets[0] = vcomment_data + self._pad_data + else: + content_size = get_size(fileobj) - len(packets[0]) # approx + padding_left = len(packets[0]) - len(vcomment_data) + info = PaddingInfo(padding_left, content_size) + new_padding = info._get_padding(padding_func) + packets[0] = vcomment_data + b"\x00" * new_padding + + new_pages = OggPage._from_packets_try_preserve(packets, old_pages) + OggPage.replace(fileobj, old_pages, new_pages) + + +class OggOpus(OggFileType): + """An Ogg Opus file.""" + + _Info = OggOpusInfo + _Tags = OggOpusVComment + _Error = OggOpusHeaderError + _mimes = ["audio/ogg", "audio/ogg; codecs=opus"] + + info = None + """A `OggOpusInfo`""" + + tags = None + """A `VCommentDict`""" + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"OggS") * (b"OpusHead" in header)) + + +Open = OggOpus + + +def delete(filename): + """Remove tags from a file.""" + + OggOpus(filename).delete() diff --git a/resources/lib/mutagen/oggspeex.py b/resources/lib/mutagen/oggspeex.py new file mode 100644 index 00000000..9b16930b --- /dev/null +++ b/resources/lib/mutagen/oggspeex.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +# Copyright 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg Speex comments. + +This module handles Speex files wrapped in an Ogg bitstream. The +first Speex stream found is used. + +Read more about Ogg Speex at http://www.speex.org/. This module is +based on the specification at http://www.speex.org/manual2/node7.html +and clarifications after personal communication with Jean-Marc, +http://lists.xiph.org/pipermail/speex-dev/2006-July/004676.html. +""" + +__all__ = ["OggSpeex", "Open", "delete"] + +from mutagen import StreamInfo +from mutagen._vorbis import VCommentDict +from mutagen.ogg import OggPage, OggFileType, error as OggError +from mutagen._util import cdata, get_size +from mutagen._tags import PaddingInfo + + +class error(OggError): + pass + + +class OggSpeexHeaderError(error): + pass + + +class OggSpeexInfo(StreamInfo): + """Ogg Speex stream information.""" + + length = 0 + """file length in seconds, as a float""" + + channels = 0 + """number of channels""" + + bitrate = 0 + """nominal bitrate in bits per second. + + The reference encoder does not set the bitrate; in this case, + the bitrate will be 0. + """ + + def __init__(self, fileobj): + page = OggPage(fileobj) + while not page.packets[0].startswith(b"Speex "): + page = OggPage(fileobj) + if not page.first: + raise OggSpeexHeaderError( + "page has ID header, but doesn't start a stream") + self.sample_rate = cdata.uint_le(page.packets[0][36:40]) + self.channels = cdata.uint_le(page.packets[0][48:52]) + self.bitrate = max(0, cdata.int_le(page.packets[0][52:56])) + self.serial = page.serial + + def _post_tags(self, fileobj): + page = OggPage.find_last(fileobj, self.serial) + self.length = page.position / float(self.sample_rate) + + def pprint(self): + return u"Ogg Speex, %.2f seconds" % self.length + + +class OggSpeexVComment(VCommentDict): + """Speex comments embedded in an Ogg bitstream.""" + + def __init__(self, fileobj, info): + pages = [] + complete = False + while not complete: + page = OggPage(fileobj) + if page.serial == info.serial: + pages.append(page) + complete = page.complete or (len(page.packets) > 1) + data = OggPage.to_packets(pages)[0] + super(OggSpeexVComment, self).__init__(data, framing=False) + self._padding = len(data) - self._size + + def _inject(self, fileobj, padding_func): + """Write tag data into the Speex comment packet/page.""" + + fileobj.seek(0) + + # Find the first header page, with the stream info. + # Use it to get the serial number. + page = OggPage(fileobj) + while not page.packets[0].startswith(b"Speex "): + page = OggPage(fileobj) + + # Look for the next page with that serial number, it'll start + # the comment packet. + serial = page.serial + page = OggPage(fileobj) + while page.serial != serial: + page = OggPage(fileobj) + + # Then find all the pages with the comment packet. + old_pages = [page] + while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1): + page = OggPage(fileobj) + if page.serial == old_pages[0].serial: + old_pages.append(page) + + packets = OggPage.to_packets(old_pages, strict=False) + + content_size = get_size(fileobj) - len(packets[0]) # approx + vcomment_data = self.write(framing=False) + padding_left = len(packets[0]) - len(vcomment_data) + + info = PaddingInfo(padding_left, content_size) + new_padding = info._get_padding(padding_func) + + # Set the new comment packet. + packets[0] = vcomment_data + b"\x00" * new_padding + + new_pages = OggPage._from_packets_try_preserve(packets, old_pages) + OggPage.replace(fileobj, old_pages, new_pages) + + +class OggSpeex(OggFileType): + """An Ogg Speex file.""" + + _Info = OggSpeexInfo + _Tags = OggSpeexVComment + _Error = OggSpeexHeaderError + _mimes = ["audio/x-speex"] + + info = None + """A `OggSpeexInfo`""" + + tags = None + """A `VCommentDict`""" + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"OggS") * (b"Speex " in header)) + + +Open = OggSpeex + + +def delete(filename): + """Remove tags from a file.""" + + OggSpeex(filename).delete() diff --git a/resources/lib/mutagen/oggtheora.py b/resources/lib/mutagen/oggtheora.py new file mode 100644 index 00000000..122e7d4b --- /dev/null +++ b/resources/lib/mutagen/oggtheora.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- + +# Copyright 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg Theora comments. + +This module handles Theora files wrapped in an Ogg bitstream. The +first Theora stream found is used. + +Based on the specification at http://theora.org/doc/Theora_I_spec.pdf. +""" + +__all__ = ["OggTheora", "Open", "delete"] + +import struct + +from mutagen import StreamInfo +from mutagen._vorbis import VCommentDict +from mutagen._util import cdata, get_size +from mutagen._tags import PaddingInfo +from mutagen.ogg import OggPage, OggFileType, error as OggError + + +class error(OggError): + pass + + +class OggTheoraHeaderError(error): + pass + + +class OggTheoraInfo(StreamInfo): + """Ogg Theora stream information.""" + + length = 0 + """File length in seconds, as a float""" + + fps = 0 + """Video frames per second, as a float""" + + bitrate = 0 + """Bitrate in bps (int)""" + + def __init__(self, fileobj): + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x80theora"): + page = OggPage(fileobj) + if not page.first: + raise OggTheoraHeaderError( + "page has ID header, but doesn't start a stream") + data = page.packets[0] + vmaj, vmin = struct.unpack("2B", data[7:9]) + if (vmaj, vmin) != (3, 2): + raise OggTheoraHeaderError( + "found Theora version %d.%d != 3.2" % (vmaj, vmin)) + fps_num, fps_den = struct.unpack(">2I", data[22:30]) + self.fps = fps_num / float(fps_den) + self.bitrate = cdata.uint_be(b"\x00" + data[37:40]) + self.granule_shift = (cdata.ushort_be(data[40:42]) >> 5) & 0x1F + self.serial = page.serial + + def _post_tags(self, fileobj): + page = OggPage.find_last(fileobj, self.serial) + position = page.position + mask = (1 << self.granule_shift) - 1 + frames = (position >> self.granule_shift) + (position & mask) + self.length = frames / float(self.fps) + + def pprint(self): + return u"Ogg Theora, %.2f seconds, %d bps" % (self.length, + self.bitrate) + + +class OggTheoraCommentDict(VCommentDict): + """Theora comments embedded in an Ogg bitstream.""" + + def __init__(self, fileobj, info): + pages = [] + complete = False + while not complete: + page = OggPage(fileobj) + if page.serial == info.serial: + pages.append(page) + complete = page.complete or (len(page.packets) > 1) + data = OggPage.to_packets(pages)[0][7:] + super(OggTheoraCommentDict, self).__init__(data, framing=False) + self._padding = len(data) - self._size + + def _inject(self, fileobj, padding_func): + """Write tag data into the Theora comment packet/page.""" + + fileobj.seek(0) + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x81theora"): + page = OggPage(fileobj) + + old_pages = [page] + while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1): + page = OggPage(fileobj) + if page.serial == old_pages[0].serial: + old_pages.append(page) + + packets = OggPage.to_packets(old_pages, strict=False) + + content_size = get_size(fileobj) - len(packets[0]) # approx + vcomment_data = b"\x81theora" + self.write(framing=False) + padding_left = len(packets[0]) - len(vcomment_data) + + info = PaddingInfo(padding_left, content_size) + new_padding = info._get_padding(padding_func) + + packets[0] = vcomment_data + b"\x00" * new_padding + + new_pages = OggPage._from_packets_try_preserve(packets, old_pages) + OggPage.replace(fileobj, old_pages, new_pages) + + +class OggTheora(OggFileType): + """An Ogg Theora file.""" + + _Info = OggTheoraInfo + _Tags = OggTheoraCommentDict + _Error = OggTheoraHeaderError + _mimes = ["video/x-theora"] + + info = None + """A `OggTheoraInfo`""" + + tags = None + """A `VCommentDict`""" + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"OggS") * + ((b"\x80theora" in header) + (b"\x81theora" in header)) * 2) + + +Open = OggTheora + + +def delete(filename): + """Remove tags from a file.""" + + OggTheora(filename).delete() diff --git a/resources/lib/mutagen/oggvorbis.py b/resources/lib/mutagen/oggvorbis.py new file mode 100644 index 00000000..b058a0c1 --- /dev/null +++ b/resources/lib/mutagen/oggvorbis.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- + +# Copyright 2006 Joe Wreschnig +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""Read and write Ogg Vorbis comments. + +This module handles Vorbis files wrapped in an Ogg bitstream. The +first Vorbis stream found is used. + +Read more about Ogg Vorbis at http://vorbis.com/. This module is based +on the specification at http://www.xiph.org/vorbis/doc/Vorbis_I_spec.html. +""" + +__all__ = ["OggVorbis", "Open", "delete"] + +import struct + +from mutagen import StreamInfo +from mutagen._vorbis import VCommentDict +from mutagen._util import get_size +from mutagen._tags import PaddingInfo +from mutagen.ogg import OggPage, OggFileType, error as OggError + + +class error(OggError): + pass + + +class OggVorbisHeaderError(error): + pass + + +class OggVorbisInfo(StreamInfo): + """Ogg Vorbis stream information.""" + + length = 0 + """File length in seconds, as a float""" + + channels = 0 + """Number of channels""" + + bitrate = 0 + """Nominal ('average') bitrate in bits per second, as an int""" + + sample_rate = 0 + """Sample rate in Hz""" + + def __init__(self, fileobj): + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x01vorbis"): + page = OggPage(fileobj) + if not page.first: + raise OggVorbisHeaderError( + "page has ID header, but doesn't start a stream") + (self.channels, self.sample_rate, max_bitrate, nominal_bitrate, + min_bitrate) = struct.unpack(" nominal_bitrate: + self.bitrate = min_bitrate + else: + self.bitrate = nominal_bitrate + + def _post_tags(self, fileobj): + page = OggPage.find_last(fileobj, self.serial) + self.length = page.position / float(self.sample_rate) + + def pprint(self): + return u"Ogg Vorbis, %.2f seconds, %d bps" % ( + self.length, self.bitrate) + + +class OggVCommentDict(VCommentDict): + """Vorbis comments embedded in an Ogg bitstream.""" + + def __init__(self, fileobj, info): + pages = [] + complete = False + while not complete: + page = OggPage(fileobj) + if page.serial == info.serial: + pages.append(page) + complete = page.complete or (len(page.packets) > 1) + data = OggPage.to_packets(pages)[0][7:] # Strip off "\x03vorbis". + super(OggVCommentDict, self).__init__(data) + self._padding = len(data) - self._size + + def _inject(self, fileobj, padding_func): + """Write tag data into the Vorbis comment packet/page.""" + + # Find the old pages in the file; we'll need to remove them, + # plus grab any stray setup packet data out of them. + fileobj.seek(0) + page = OggPage(fileobj) + while not page.packets[0].startswith(b"\x03vorbis"): + page = OggPage(fileobj) + + old_pages = [page] + while not (old_pages[-1].complete or len(old_pages[-1].packets) > 1): + page = OggPage(fileobj) + if page.serial == old_pages[0].serial: + old_pages.append(page) + + packets = OggPage.to_packets(old_pages, strict=False) + + content_size = get_size(fileobj) - len(packets[0]) # approx + vcomment_data = b"\x03vorbis" + self.write() + padding_left = len(packets[0]) - len(vcomment_data) + + info = PaddingInfo(padding_left, content_size) + new_padding = info._get_padding(padding_func) + + # Set the new comment packet. + packets[0] = vcomment_data + b"\x00" * new_padding + + new_pages = OggPage._from_packets_try_preserve(packets, old_pages) + OggPage.replace(fileobj, old_pages, new_pages) + + +class OggVorbis(OggFileType): + """An Ogg Vorbis file.""" + + _Info = OggVorbisInfo + _Tags = OggVCommentDict + _Error = OggVorbisHeaderError + _mimes = ["audio/vorbis", "audio/x-vorbis"] + + info = None + """A `OggVorbisInfo`""" + + tags = None + """A `VCommentDict`""" + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"OggS") * (b"\x01vorbis" in header)) + + +Open = OggVorbis + + +def delete(filename): + """Remove tags from a file.""" + + OggVorbis(filename).delete() diff --git a/resources/lib/mutagen/optimfrog.py b/resources/lib/mutagen/optimfrog.py new file mode 100644 index 00000000..0d85a818 --- /dev/null +++ b/resources/lib/mutagen/optimfrog.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2006 Lukas Lalinsky +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""OptimFROG audio streams with APEv2 tags. + +OptimFROG is a lossless audio compression program. Its main goal is to +reduce at maximum the size of audio files, while permitting bit +identical restoration for all input. It is similar with the ZIP +compression, but it is highly specialized to compress audio data. + +Only versions 4.5 and higher are supported. + +For more information, see http://www.losslessaudio.org/ +""" + +__all__ = ["OptimFROG", "Open", "delete"] + +import struct + +from ._compat import endswith +from mutagen import StreamInfo +from mutagen.apev2 import APEv2File, error, delete + + +class OptimFROGHeaderError(error): + pass + + +class OptimFROGInfo(StreamInfo): + """OptimFROG stream information. + + Attributes: + + * channels - number of audio channels + * length - file length in seconds, as a float + * sample_rate - audio sampling rate in Hz + """ + + def __init__(self, fileobj): + header = fileobj.read(76) + if (len(header) != 76 or not header.startswith(b"OFR ") or + struct.unpack("` + """ + + _Info = TrueAudioInfo + _mimes = ["audio/x-tta"] + + @staticmethod + def score(filename, fileobj, header): + return (header.startswith(b"ID3") + header.startswith(b"TTA") + + endswith(filename.lower(), b".tta") * 2) + + +Open = TrueAudio + + +class EasyTrueAudio(TrueAudio): + """Like MP3, but uses EasyID3 for tags. + + :ivar info: :class:`TrueAudioInfo` + :ivar tags: :class:`EasyID3 ` + """ + + from mutagen.easyid3 import EasyID3 as ID3 + ID3 = ID3 diff --git a/resources/lib/mutagen/wavpack.py b/resources/lib/mutagen/wavpack.py new file mode 100644 index 00000000..80710f6d --- /dev/null +++ b/resources/lib/mutagen/wavpack.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- + +# Copyright 2006 Joe Wreschnig +# 2014 Christoph Reiter +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. + +"""WavPack reading and writing. + +WavPack is a lossless format that uses APEv2 tags. Read + +* http://www.wavpack.com/ +* http://www.wavpack.com/file_format.txt + +for more information. +""" + +__all__ = ["WavPack", "Open", "delete"] + +from mutagen import StreamInfo +from mutagen.apev2 import APEv2File, error, delete +from mutagen._util import cdata + + +class WavPackHeaderError(error): + pass + +RATES = [6000, 8000, 9600, 11025, 12000, 16000, 22050, 24000, 32000, 44100, + 48000, 64000, 88200, 96000, 192000] + + +class _WavPackHeader(object): + + def __init__(self, block_size, version, track_no, index_no, total_samples, + block_index, block_samples, flags, crc): + + self.block_size = block_size + self.version = version + self.track_no = track_no + self.index_no = index_no + self.total_samples = total_samples + self.block_index = block_index + self.block_samples = block_samples + self.flags = flags + self.crc = crc + + @classmethod + def from_fileobj(cls, fileobj): + """A new _WavPackHeader or raises WavPackHeaderError""" + + header = fileobj.read(32) + if len(header) != 32 or not header.startswith(b"wvpk"): + raise WavPackHeaderError("not a WavPack header: %r" % header) + + block_size = cdata.uint_le(header[4:8]) + version = cdata.ushort_le(header[8:10]) + track_no = ord(header[10:11]) + index_no = ord(header[11:12]) + samples = cdata.uint_le(header[12:16]) + if samples == 2 ** 32 - 1: + samples = -1 + block_index = cdata.uint_le(header[16:20]) + block_samples = cdata.uint_le(header[20:24]) + flags = cdata.uint_le(header[24:28]) + crc = cdata.uint_le(header[28:32]) + + return _WavPackHeader(block_size, version, track_no, index_no, + samples, block_index, block_samples, flags, crc) + + +class WavPackInfo(StreamInfo): + """WavPack stream information. + + Attributes: + + * channels - number of audio channels (1 or 2) + * length - file length in seconds, as a float + * sample_rate - audio sampling rate in Hz + * version - WavPack stream version + """ + + def __init__(self, fileobj): + try: + header = _WavPackHeader.from_fileobj(fileobj) + except WavPackHeaderError: + raise WavPackHeaderError("not a WavPack file") + + self.version = header.version + self.channels = bool(header.flags & 4) or 2 + self.sample_rate = RATES[(header.flags >> 23) & 0xF] + + if header.total_samples == -1 or header.block_index != 0: + # TODO: we could make this faster by using the tag size + # and search backwards for the last block, then do + # last.block_index + last.block_samples - initial.block_index + samples = header.block_samples + while 1: + fileobj.seek(header.block_size - 32 + 8, 1) + try: + header = _WavPackHeader.from_fileobj(fileobj) + except WavPackHeaderError: + break + samples += header.block_samples + else: + samples = header.total_samples + + self.length = float(samples) / self.sample_rate + + def pprint(self): + return u"WavPack, %.2f seconds, %d Hz" % (self.length, + self.sample_rate) + + +class WavPack(APEv2File): + _Info = WavPackInfo + _mimes = ["audio/x-wavpack"] + + @staticmethod + def score(filename, fileobj, header): + return header.startswith(b"wvpk") * 2 + + +Open = WavPack diff --git a/resources/lib/utils.py b/resources/lib/utils.py index aca6b790..956f63bb 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -62,7 +62,7 @@ def settings(setting, value=None): def language(stringid): # Central string retrieval addon = xbmcaddon.Addon(id='plugin.video.emby') - string = addon.getLocalizedString(stringid) + string = addon.getLocalizedString(stringid).decode("utf-8") return string From b7f0f869eb15c442e1f9fd06cc95c9593b7c5a46 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Mon, 11 Jan 2016 19:26:38 +0100 Subject: [PATCH 02/12] fix null pointer --- resources/lib/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/lib/api.py b/resources/lib/api.py index ea651a6e..8bf77b5f 100644 --- a/resources/lib/api.py +++ b/resources/lib/api.py @@ -25,6 +25,7 @@ class API(): def getUserData(self): # Default favorite = False + likes = None playcount = None played = False lastPlayedDate = None @@ -403,4 +404,4 @@ class API(): url = "{server}/emby/Users/{UserId}/Items/%s/Rating?format=json" % itemid doUtils.downloadUrl(url, type="DELETE") - self.logMsg( "updateUserRating on embyserver for embyId: %s - like: %s - favourite: %s - deletelike: %s" %(itemid, like, favourite, deletelike), 0) + self.logMsg( "updateUserRating on embyserver for embyId: %s - like: %s - favourite: %s - deletelike: %s" %(itemid, like, favourite, deletelike)) From ee9f08080e57590b684542388103bfa5b3ee2980 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Mon, 11 Jan 2016 22:20:34 +0100 Subject: [PATCH 03/12] work in progress - move home videos to plugin listing --- contextmenu.py | 2 +- default.py | 4 + resources/lib/api.py | 34 ++-- resources/lib/entrypoint.py | 94 +++++++++++ resources/lib/itemtypes.py | 262 +----------------------------- resources/lib/kodidb_functions.py | 8 +- resources/lib/librarysync.py | 99 +---------- resources/lib/musicutils.py | 8 +- resources/lib/videonodes.py | 59 +++++-- 9 files changed, 172 insertions(+), 398 deletions(-) diff --git a/contextmenu.py b/contextmenu.py index 48fc2c87..6346a4c5 100644 --- a/contextmenu.py +++ b/contextmenu.py @@ -41,7 +41,7 @@ if __name__ == '__main__': if not itemtype and xbmc.getCondVisibility("Container.Content(artists)"): itemtype = "artist" if not itemtype and xbmc.getCondVisibility("Container.Content(songs)"): itemtype = "song" - logMsg("Contextmenu opened for itemid: %s - itemtype: %s" %(itemid,itemtype),0) + logMsg("Contextmenu opened for itemid: %s - itemtype: %s" %(itemid,itemtype)) userid = utils.window('emby_currUser') server = utils.window('emby_server%s' % userid) diff --git a/default.py b/default.py index b3f8190a..c482c918 100644 --- a/default.py +++ b/default.py @@ -58,6 +58,7 @@ class Main: 'thememedia': entrypoint.getThemeMedia, 'channels': entrypoint.BrowseChannels, 'channelsfolder': entrypoint.BrowseChannels, + 'browsecontent': entrypoint.BrowseContent, 'nextup': entrypoint.getNextUpEpisodes, 'inprogressepisodes': entrypoint.getInProgressEpisodes, 'recentepisodes': entrypoint.getRecentEpisodes, @@ -79,6 +80,9 @@ class Main: elif mode == "channels": modes[mode](itemid) + + elif mode == "browsecontent": + modes[mode]( itemid, params.get('type',[""])[0], params.get('folderid',[""])[0], params.get('filter',[""])[0] ) elif mode == "channelsfolder": folderid = params['folderid'][0] diff --git a/resources/lib/api.py b/resources/lib/api.py index 8bf77b5f..552c55b9 100644 --- a/resources/lib/api.py +++ b/resources/lib/api.py @@ -122,6 +122,7 @@ class API(): media_streams = item['MediaSources'][0]['MediaStreams'] except KeyError: + if not item.get("MediaStreams"): return None media_streams = item['MediaStreams'] for media_stream in media_streams: @@ -134,11 +135,11 @@ class API(): # Height, Width, Codec, AspectRatio, AspectFloat, 3D track = { - 'videocodec': codec, + 'codec': codec, 'height': media_stream.get('Height'), 'width': media_stream.get('Width'), 'video3DFormat': item.get('Video3DFormat'), - 'aspectratio': 1.85 + 'aspect': 1.85 } try: @@ -148,47 +149,50 @@ class API(): # Sort codec vs container/profile if "msmpeg4" in codec: - track['videocodec'] = "divx" + track['codec'] = "divx" elif "mpeg4" in codec: if "simple profile" in profile or not profile: - track['videocodec'] = "xvid" + track['codec'] = "xvid" elif "h264" in codec: if container in ("mp4", "mov", "m4v"): - track['videocodec'] = "avc1" + track['codec'] = "avc1" # Aspect ratio if item.get('AspectRatio'): # Metadata AR - aspectratio = item['AspectRatio'] + aspect = item['AspectRatio'] else: # File AR - aspectratio = media_stream.get('AspectRatio', "0") + aspect = media_stream.get('AspectRatio', "0") try: - aspectwidth, aspectheight = aspectratio.split(':') - track['aspectratio'] = round(float(aspectwidth) / float(aspectheight), 6) + aspectwidth, aspectheight = aspect.split(':') + track['aspect'] = round(float(aspectwidth) / float(aspectheight), 6) except (ValueError, ZeroDivisionError): width = track.get('width') height = track.get('height') if width and height: - track['aspectratio'] = round(float(width / height), 6) + track['aspect'] = round(float(width / height), 6) else: - track['aspectratio'] = 1.85 - + track['aspect'] = 1.85 + + if item.get("RunTimeTicks"): + track['duration'] = item.get("RunTimeTicks") / 10000000.0 + videotracks.append(track) elif stream_type == "Audio": # Codec, Channels, language track = { - 'audiocodec': codec, + 'codec': codec, 'channels': media_stream.get('Channels'), - 'audiolanguage': media_stream.get('Language') + 'language': media_stream.get('Language') } if "dca" in codec and "dts-hd ma" in profile: - track['audiocodec'] = "dtshd_ma" + track['codec'] = "dtshd_ma" audiotracks.append(track) diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 08d5ff63..4026c935 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -68,6 +68,7 @@ def doMainListing(): path = utils.window('Emby.nodes.%s.content' % i) label = utils.window('Emby.nodes.%s.title' % i) if path: + print path addDirectoryItem(label, path) # some extra entries for settings and stuff. TODO --> localize the labels @@ -400,6 +401,99 @@ def refreshPlaylist(): time=1000, sound=False) +##### BROWSE EMBY HOMEVIDEOS AND PICTURES ##### +def BrowseContent(viewname, type="", folderid=None, filter=None): + + _addon_id = int(sys.argv[1]) + _addon_url = sys.argv[0] + doUtils = downloadutils.DownloadUtils() + emby = embyserver.Read_EmbyServer() + art = artwork.Artwork() + utils.logMsg("BrowseHomeVideos","viewname: %s - type: %s - folderid: %s - filter: %s" %(viewname, type, folderid, filter),0) + + if type.lower() == "homevideos": + xbmcplugin.setContent(int(sys.argv[1]), 'episodes') + itemtype = "Video" + elif type.lower() == "photos": + xbmcplugin.setContent(int(sys.argv[1]), 'pictures') + itemtype = "Photo" + else: + itemtype = "" + + if not folderid: + views = emby.getViews(type) + for view in views: + if view.get("name") == viewname: + folderid = view.get("id") + print view + + if folderid: + listing = emby.getSection(folderid).get("Items",[]) + for item in listing: + if item.get("Type") == itemtype or item.get("IsFolder") == True: + API = api.API(item) + itemid = item['Id'] + title = item.get('Name') + li = xbmcgui.ListItem(title) + + premieredate = API.getPremiereDate() + genre = API.getGenres() + overlay = 0 + userdata = API.getUserData() + seektime = userdata['Resume'] + if seektime: + li.setProperty("resumetime", seektime) + li.setProperty("totaltime", item.get("RunTimeTicks")/ 10000000.0) + + played = userdata['Played'] + if played: overlay = 7 + else: overlay = 6 + + favorite = userdata['Favorite'] + if favorite: overlay = 5 + + playcount = userdata['PlayCount'] + if playcount is None: + playcount = 0 + + rating = item.get('CommunityRating') + if not rating: rating = userdata['UserRating'] + + # Populate the extradata list and artwork + pbutils.PlaybackUtils(item).setArtwork(li) + extradata = { + + 'id': itemid, + 'rating': rating, + 'year': item.get('ProductionYear'), + 'premieredate': premieredate, + 'genre': genre, + 'playcount': str(playcount), + 'title': title, + 'plot': API.getOverview(), + 'Overlay': str(overlay), + } + li.setInfo('video', infoLabels=extradata) + li.setThumbnailImage(art.getAllArtwork(item)['Primary']) + li.setIconImage('DefaultTVShows.png') + + if item.get("IsFolder") == True: + path = "%s?id=%s&mode=browsecontent&type=%s&folderid=%s" % (_addon_url, viewname, type, itemid) + xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li, isFolder=True) + else: + path = "%s?id=%s&mode=play" % (_addon_url, itemid) + li.setProperty('IsPlayable', 'true') + + mediastreams = API.getMediaStreams() + if mediastreams: + for key, value in mediastreams.iteritems(): + if value: li.addStreamInfo(key, value[0]) + + xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li) + + + xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + ##### BROWSE EMBY CHANNELS ##### def BrowseChannels(itemid, folderid=None): diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index 3a6f9060..e08080a3 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -61,15 +61,13 @@ class Items(object): 'Movie': Movies, 'BoxSet': Movies, - 'MusicVideo': MusicVideos, 'Series': TVShows, 'Season': TVShows, 'Episode': TVShows, 'MusicAlbum': Music, 'MusicArtist': Music, 'AlbumArtist': Music, - 'Audio': Music, - 'Video': HomeVideos + 'Audio': Music } total = 0 @@ -169,13 +167,6 @@ class Items(object): 'userdata': items_process.updateUserdata, 'remove': items_process.remove } - elif itemtype == "Video": - actions = { - 'added': items_process.added, - 'update': items_process.add_update, - 'userdata': items_process.updateUserdata, - 'remove': items_process.remove - } else: self.logMsg("Unsupported itemtype: %s." % itemtype, 1) actions = {} @@ -624,257 +615,6 @@ class Movies(Items): self.logMsg("Deleted %s %s from kodi database" % (mediatype, itemid), 1) -class HomeVideos(Items): - - - def __init__(self, embycursor, kodicursor): - Items.__init__(self, embycursor, kodicursor) - - def added(self, items, pdialog): - - total = len(items) - count = 0 - for hvideo in items: - - title = hvideo['Name'] - if pdialog: - percentage = int((float(count) / float(total))*100) - pdialog.update(percentage, message=title) - count += 1 - self.add_update(hvideo) - if not pdialog and self.contentmsg: - self.contentPop(title) - - - def add_update(self, item, viewtag=None, viewid=None): - # Process single movie - kodicursor = self.kodicursor - emby_db = self.emby_db - kodi_db = self.kodi_db - artwork = self.artwork - API = api.API(item) - - # If the item already exist in the local Kodi DB we'll perform a full item update - # If the item doesn't exist, we'll add it to the database - update_item = True - itemid = item['Id'] - emby_dbitem = emby_db.getItem_byId(itemid) - try: - hmovieid = emby_dbitem[0] - fileid = emby_dbitem[1] - pathid = emby_dbitem[2] - self.logMsg("hmovieid: %s fileid: %s pathid: %s" % (hmovieid, fileid, pathid), 1) - - except TypeError: - update_item = False - self.logMsg("hmovieid: %s not found." % itemid, 2) - # movieid - kodicursor.execute("select coalesce(max(idMovie),0) from movie") - hmovieid = kodicursor.fetchone()[0] + 1 - - if not viewtag or not viewid: - # Get view tag from emby - viewtag, viewid, mediatype = self.emby.getView_embyId(itemid) - self.logMsg("View tag found: %s" % viewtag, 2) - - # fileId information - checksum = API.getChecksum() - dateadded = API.getDateCreated() - userdata = API.getUserData() - playcount = userdata['PlayCount'] - dateplayed = userdata['LastPlayedDate'] - - # item details - people = API.getPeople() - title = item['Name'] - year = item.get('ProductionYear') - sorttitle = item['SortName'] - runtime = API.getRuntime() - genre = "HomeVideos" - - - ##### GET THE FILE AND PATH ##### - playurl = API.getFilePath() - - if "\\" in playurl: - # Local path - filename = playurl.rsplit("\\", 1)[1] - else: # Network share - filename = playurl.rsplit("/", 1)[1] - - if self.directpath: - # Direct paths is set the Kodi way - if utils.window('emby_pathverified') != "true" and not xbmcvfs.exists(playurl): - # Validate the path is correct with user intervention - utils.window('emby_directPath', clear=True) - resp = xbmcgui.Dialog().yesno( - heading="Can't validate path", - line1=( - "Kodi can't locate file: %s. Verify the path. " - "You may to verify your network credentials in the " - "add-on settings or use the emby path substitution " - "to format your path correctly. Stop syncing?" - % playurl)) - if resp: - utils.window('emby_shouldStop', value="true") - return False - - path = playurl.replace(filename, "") - utils.window('emby_pathverified', value="true") - else: - # Set plugin path and media flags using real filename - path = "plugin://plugin.video.emby.movies/" - params = { - - 'filename': filename.encode('utf-8'), - 'id': itemid, - 'dbid': hmovieid, - 'mode': "play" - } - filename = "%s?%s" % (path, urllib.urlencode(params)) - - - ##### UPDATE THE MOVIE ##### - if update_item: - self.logMsg("UPDATE homemovie itemid: %s - Title: %s" % (itemid, title), 1) - - # Update the movie entry - query = ' '.join(( - - "UPDATE movie", - "SET c00 = ?, c07 = ?, c10 = ?, c11 = ?, c14 = ?, c16 = ?", - "WHERE idMovie = ?" - )) - kodicursor.execute(query, (title, year, sorttitle, runtime, genre, title, hmovieid)) - - # Update the checksum in emby table - emby_db.updateReference(itemid, checksum) - - ##### OR ADD THE MOVIE ##### - else: - self.logMsg("ADD homemovie itemid: %s - Title: %s" % (itemid, title), 1) - - # Add path - pathid = kodi_db.addPath(path) - # Add the file - fileid = kodi_db.addFile(filename, pathid) - - # Create the movie entry - query = ( - ''' - INSERT INTO movie( - idMovie, idFile, c00, c07, c10, c11, c14, c16) - - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''' - ) - kodicursor.execute(query, (hmovieid, fileid, title, year, sorttitle, runtime, - genre, title)) - - # Create the reference in emby table - emby_db.addReference(itemid, hmovieid, "Video", "movie", fileid, pathid, - None, checksum, viewid) - - # Update the path - query = ' '.join(( - - "UPDATE path", - "SET strPath = ?, strContent = ?, strScraper = ?, noUpdate = ?", - "WHERE idPath = ?" - )) - kodicursor.execute(query, (path, "movies", "metadata.local", 1, pathid)) - - # Update the file - query = ' '.join(( - - "UPDATE files", - "SET idPath = ?, strFilename = ?, dateAdded = ?", - "WHERE idFile = ?" - )) - kodicursor.execute(query, (pathid, filename, dateadded, fileid)) - - - # Process artwork - artwork.addArtwork(artwork.getAllArtwork(item), hmovieid, "movie", kodicursor) - # Process stream details - streams = API.getMediaStreams() - kodi_db.addStreams(fileid, streams, runtime) - # Process tags: view, emby tags - tags = [viewtag] - tags.extend(item['Tags']) - if userdata['Favorite']: - tags.append("Favorite homemovies") - kodi_db.addTags(hmovieid, tags, "movie") - # Process playstates - resume = API.adjustResume(userdata['Resume']) - total = round(float(runtime), 6) - kodi_db.addPlaystate(fileid, resume, total, playcount, dateplayed) - - def updateUserdata(self, item): - # This updates: Favorite, LastPlayedDate, Playcount, PlaybackPositionTicks - # Poster with progress bar - emby_db = self.emby_db - kodi_db = self.kodi_db - API = api.API(item) - - # Get emby information - itemid = item['Id'] - checksum = API.getChecksum() - userdata = API.getUserData() - runtime = API.getRuntime() - - # Get Kodi information - emby_dbitem = emby_db.getItem_byId(itemid) - try: - movieid = emby_dbitem[0] - fileid = emby_dbitem[1] - self.logMsg( - "Update playstate for homemovie: %s fileid: %s" - % (item['Name'], fileid), 1) - except TypeError: - return - - # Process favorite tags - if userdata['Favorite']: - kodi_db.addTag(movieid, "Favorite homemovies", "movie") - else: - kodi_db.removeTag(movieid, "Favorite homemovies", "movie") - - # Process playstates - playcount = userdata['PlayCount'] - dateplayed = userdata['LastPlayedDate'] - resume = API.adjustResume(userdata['Resume']) - total = round(float(runtime), 6) - - kodi_db.addPlaystate(fileid, resume, total, playcount, dateplayed) - emby_db.updateReference(itemid, checksum) - - def remove(self, itemid): - # Remove movieid, fileid, emby reference - emby_db = self.emby_db - kodicursor = self.kodicursor - artwork = self.artwork - - emby_dbitem = emby_db.getItem_byId(itemid) - try: - hmovieid = emby_dbitem[0] - fileid = emby_dbitem[1] - self.logMsg("Removing hmovieid: %s fileid: %s" % (hmovieid, fileid), 1) - except TypeError: - return - - # Remove artwork - artwork.deleteArtwork(hmovieid, "movie", kodicursor) - - # Delete kodi movie and file - kodicursor.execute("DELETE FROM movie WHERE idMovie = ?", (hmovieid,)) - kodicursor.execute("DELETE FROM files WHERE idFile = ?", (fileid,)) - - # Remove the emby reference - emby_db.removeItem(itemid) - - self.logMsg("Deleted homemovie %s from kodi database" % itemid, 1) - class MusicVideos(Items): diff --git a/resources/lib/kodidb_functions.py b/resources/lib/kodidb_functions.py index 47713e25..686ac676 100644 --- a/resources/lib/kodidb_functions.py +++ b/resources/lib/kodidb_functions.py @@ -640,8 +640,8 @@ class Kodidb_Functions(): VALUES (?, ?, ?, ?, ?, ?, ?, ?) ''' ) - cursor.execute(query, (fileid, 0, videotrack['videocodec'], - videotrack['aspectratio'], videotrack['width'], videotrack['height'], + cursor.execute(query, (fileid, 0, videotrack['codec'], + videotrack['aspect'], videotrack['width'], videotrack['height'], runtime ,videotrack['video3DFormat'])) # Audio details @@ -654,8 +654,8 @@ class Kodidb_Functions(): VALUES (?, ?, ?, ?, ?) ''' ) - cursor.execute(query, (fileid, 1, audiotrack['audiocodec'], - audiotrack['channels'], audiotrack['audiolanguage'])) + cursor.execute(query, (fileid, 1, audiotrack['codec'], + audiotrack['channels'], audiotrack['language'])) # Subtitles details for subtitletrack in streamdetails['subtitle']: diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index 617aef5c..b193b1d4 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -230,8 +230,7 @@ class LibrarySync(threading.Thread): 'movies': self.movies, 'musicvideos': self.musicvideos, - 'tvshows': self.tvshows, - 'homevideos': self.homevideos + 'tvshows': self.tvshows } for itemtype in process: startTime = datetime.now() @@ -343,7 +342,7 @@ class LibrarySync(threading.Thread): totalnodes = 0 # Set views for supported media type - mediatypes = ['movies', 'tvshows', 'musicvideos', 'homevideos', 'music'] + mediatypes = ['movies', 'tvshows', 'musicvideos', 'homevideos', 'music', 'photos'] for mediatype in mediatypes: # Get media folders from server @@ -447,7 +446,6 @@ class LibrarySync(threading.Thread): # Save total utils.window('Emby.nodes.total', str(totalnodes)) - def movies(self, embycursor, kodicursor, pdialog, compare=False): # Get movies from emby emby = self.emby @@ -709,99 +707,6 @@ class LibrarySync(threading.Thread): return True - def homevideos(self, embycursor, kodicursor, pdialog, compare=False): - # Get homevideos from emby - emby = self.emby - emby_db = embydb.Embydb_Functions(embycursor) - hvideos = itemtypes.HomeVideos(embycursor, kodicursor) - - views = emby_db.getView_byType('homevideos') - self.logMsg("Media folders: %s" % views, 1) - - if compare: - # Pull the list of homevideos in Kodi - try: - all_kodihvideos = dict(emby_db.getChecksum('Video')) - except ValueError: - all_kodihvideos = {} - - all_embyhvideosIds = set() - updatelist = [] - - for view in views: - - if self.shouldStop(): - return False - - # Get items per view - viewId = view['id'] - viewName = view['name'] - - if pdialog: - pdialog.update( - heading="Emby for Kodi", - message="Gathering homevideos from view: %s..." % viewName) - - all_embyhvideos = emby.getHomeVideos(viewId) - - if compare: - # Manual sync - if pdialog: - pdialog.update( - heading="Emby for Kodi", - message="Comparing homevideos from view: %s..." % viewName) - - for embyhvideo in all_embyhvideos['Items']: - - if self.shouldStop(): - return False - - API = api.API(embyhvideo) - itemid = embyhvideo['Id'] - all_embyhvideosIds.add(itemid) - - - if all_kodihvideos.get(itemid) != API.getChecksum(): - # Only update if homemovie is not in Kodi or checksum is different - updatelist.append(itemid) - - self.logMsg("HomeVideos to update for %s: %s" % (viewName, updatelist), 1) - embyhvideos = emby.getFullItems(updatelist) - total = len(updatelist) - del updatelist[:] - else: - total = all_embyhvideos['TotalRecordCount'] - embyhvideos = all_embyhvideos['Items'] - - if pdialog: - pdialog.update(heading="Processing %s / %s items" % (viewName, total)) - - count = 0 - for embyhvideo in embyhvideos: - # Process individual homemovies - if self.shouldStop(): - return False - - title = embyhvideo['Name'] - if pdialog: - percentage = int((float(count) / float(total))*100) - pdialog.update(percentage, message=title) - count += 1 - hvideos.add_update(embyhvideo, viewName, viewId) - else: - self.logMsg("HomeVideos finished.", 2) - - ##### PROCESS DELETES ##### - if compare: - # Manual sync, process deletes - for kodihvideo in all_kodihvideos: - if kodihvideo not in all_embyhvideosIds: - hvideos.remove(kodihvideo) - else: - self.logMsg("HomeVideos compare finished.", 1) - - return True - def tvshows(self, embycursor, kodicursor, pdialog, compare=False): # Get shows from emby emby = self.emby diff --git a/resources/lib/musicutils.py b/resources/lib/musicutils.py index a9add080..d9715749 100644 --- a/resources/lib/musicutils.py +++ b/resources/lib/musicutils.py @@ -61,7 +61,7 @@ def getSongTags(file): comment = "" isTemp,filename = getRealFileName(file) - logMsg( "getting song ID3 tags for " + filename, 0) + logMsg( "getting song ID3 tags for " + filename) try: if filename.lower().endswith(".flac"): @@ -82,7 +82,7 @@ def getSongTags(file): #POPM rating is 0-255 and needs to be converted to 0-5 range if rating > 5: rating = (rating / 255) * 5 else: - logMsg( "Not supported fileformat or unable to access file: %s" %(filename), 0) + logMsg( "Not supported fileformat or unable to access file: %s" %(filename)) rating = int(round(rating,0)) except Exception as e: @@ -98,7 +98,7 @@ def updateRatingToFile(rating, file): #update the rating from Emby to the file isTemp,filename = getRealFileName(file) - logMsg( "setting song rating: %s for filename: %s" %(rating,filename), 0) + logMsg( "setting song rating: %s for filename: %s" %(rating,filename)) if not filename: return @@ -115,7 +115,7 @@ def updateRatingToFile(rating, file): audio.add(id3.POPM(email="Windows Media Player 9 Series", rating=calcrating, count=1)) audio.save() else: - logMsg( "Not supported fileformat: %s" %(filename), 0) + logMsg( "Not supported fileformat: %s" %(filename)) #remove tempfile if needed.... if isTemp: diff --git a/resources/lib/videonodes.py b/resources/lib/videonodes.py index 53af40b5..84564e7a 100644 --- a/resources/lib/videonodes.py +++ b/resources/lib/videonodes.py @@ -56,10 +56,6 @@ class VideoNodes(object): kodiversion = self.kodiversion - if mediatype == "homevideos": - # Treat homevideos as movies - mediatype = "movies" - cleantagname = utils.normalize_nodes(tagname.encode('utf-8')) if viewtype == "mixed": dirname = "%s - %s" % (cleantagname, mediatype) @@ -78,7 +74,7 @@ class VideoNodes(object): xbmcvfs.exists(path) # Create the node directory - if not xbmcvfs.exists(nodepath): + if not xbmcvfs.exists(nodepath) and not mediatype=="photos": # We need to copy over the default items xbmcvfs.mkdirs(nodepath) else: @@ -99,14 +95,18 @@ class VideoNodes(object): if utils.window('Emby.nodes.%s.index' % i) == path: return + if mediatype=="photos": + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=photos&filter=index" % tagname + utils.window('Emby.nodes.%s.index' % indexnumber, value=path) + # Root - root = self.commonRoot(order=0, label=tagname, tagname=tagname, roottype=0) - try: - utils.indent(root) - except: pass - etree.ElementTree(root).write(nodeXML) - + if not mediatype=="photos": + root = self.commonRoot(order=0, label=tagname, tagname=tagname, roottype=0) + try: + utils.indent(root) + except: pass + etree.ElementTree(root).write(nodeXML) nodetypes = { @@ -144,6 +144,14 @@ class VideoNodes(object): '9': 135, '10': 30229, '11': 30230}, + + 'homevideos': { + '1': tagname, + '2': 30170}, + + 'photos': { + '1': tagname, + '2': 30170}, } nodes = mediatypes[mediatype] @@ -161,7 +169,19 @@ class VideoNodes(object): label = stringid # Set window properties - if nodetype == "nextepisodes": + if mediatype == "homevideos" and nodetype == "all": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=homevideos" % tagname + elif mediatype == "homevideos" and nodetype == "recent": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=homevideos&filter=recent" % tagname + elif mediatype == "photos" and nodetype == "all": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=photos" % tagname + elif mediatype == "photos" and nodetype == "recent": + # Custom query + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=photos&filter=recent" % tagname + elif nodetype == "nextepisodes": # Custom query path = "plugin://plugin.video.emby/?id=%s&mode=nextup&limit=25" % tagname elif kodiversion == 14 and nodetype == "recentepisodes": @@ -172,7 +192,11 @@ class VideoNodes(object): path = "plugin://plugin.video.emby/?id=%s&mode=inprogressepisodes&limit=25"% tagname else: path = "library://video/Emby - %s/%s_%s.xml" % (dirname, cleantagname, nodetype) - windowpath = "ActivateWindow(Video,%s,return)" % path + + if mediatype == "photos": + windowpath = "ActivateWindow(Pictures,%s,return)" % path + else: + windowpath = "ActivateWindow(Video,%s,return)" % path if nodetype == "all": @@ -192,14 +216,17 @@ class VideoNodes(object): utils.window('%s.path' % embynode, value=windowpath) utils.window('%s.content' % embynode, value=path) + if mediatype=="photos": + #for photos we do not create a node in videos but we do want the window props to be created + #todo: add our photos nodes to kodi picture sources somehow + continue + if xbmcvfs.exists(nodeXML): # Don't recreate xml if already exists continue - # Create the root - if nodetype == "nextepisodes" or (kodiversion == 14 and - nodetype in ('recentepisodes', 'inprogressepisodes')): + if nodetype == "nextepisodes" or (kodiversion == 14 and nodetype in ('recentepisodes', 'inprogressepisodes')) or mediatype=="homevideos": # Folder type with plugin path root = self.commonRoot(order=node, label=label, tagname=tagname, roottype=2) etree.SubElement(root, 'path').text = path From b24e881f181505e5532124c07053f0bb532975b4 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Wed, 13 Jan 2016 01:03:35 +0100 Subject: [PATCH 04/12] finished homevideos and photos --- addon.xml | 2 +- contextmenu.py | 39 +++-- resources/language/English/strings.xml | 5 + resources/lib/entrypoint.py | 214 +++++++++++++++---------- resources/lib/read_embyserver.py | 23 +++ resources/lib/videonodes.py | 43 ++--- 6 files changed, 203 insertions(+), 123 deletions(-) diff --git a/addon.xml b/addon.xml index 45aa64a7..d0f562d3 100644 --- a/addon.xml +++ b/addon.xml @@ -20,7 +20,7 @@ Settings for the Emby Server - !IsEmpty(ListItem.DBID) + [!IsEmpty(ListItem.DBID) + !IsEmpty(ListItem.DBTYPE)] | !IsEmpty(ListItem.Property(embyid)) diff --git a/contextmenu.py b/contextmenu.py index 6346a4c5..619ba149 100644 --- a/contextmenu.py +++ b/contextmenu.py @@ -33,35 +33,30 @@ def logMsg(msg, lvl=1): #Kodi contextmenu item to configure the emby settings #for now used to set ratings but can later be used to sync individual items etc. if __name__ == '__main__': - - itemid = xbmc.getInfoLabel("ListItem.DBID").decode("utf-8") itemtype = xbmc.getInfoLabel("ListItem.DBTYPE").decode("utf-8") + + emby = embyserver.Read_EmbyServer() + + embyid = "" if not itemtype and xbmc.getCondVisibility("Container.Content(albums)"): itemtype = "album" if not itemtype and xbmc.getCondVisibility("Container.Content(artists)"): itemtype = "artist" if not itemtype and xbmc.getCondVisibility("Container.Content(songs)"): itemtype = "song" + if not itemtype and xbmc.getCondVisibility("Container.Content(pictures)"): itemtype = "picture" - logMsg("Contextmenu opened for itemid: %s - itemtype: %s" %(itemid,itemtype)) + if (not itemid or itemid == "-1") and xbmc.getInfoLabel("ListItem.Property(embyid)"): + embyid = xbmc.getInfoLabel("ListItem.Property(embyid)") + else: + embyconn = utils.kodiSQL('emby') + embycursor = embyconn.cursor() + emby_db = embydb.Embydb_Functions(embycursor) + item = emby_db.getItem_byKodiId(itemid, itemtype) + if item: embyid = item[0] - userid = utils.window('emby_currUser') - server = utils.window('emby_server%s' % userid) - embyconn = utils.kodiSQL('emby') - embycursor = embyconn.cursor() - kodiconn = utils.kodiSQL('music') - kodicursor = kodiconn.cursor() - - emby = embyserver.Read_EmbyServer() - emby_db = embydb.Embydb_Functions(embycursor) - kodi_db = kodidb.Kodidb_Functions(kodicursor) - - item = emby_db.getItem_byKodiId(itemid, itemtype) - if item: - embyid = item[0] - + logMsg("Contextmenu opened for embyid: %s - itemtype: %s" %(embyid,itemtype)) + + if embyid: item = emby.getItem(embyid) - - print item - API = api.API(item) userdata = API.getUserData() likes = userdata['Likes'] @@ -105,6 +100,8 @@ if __name__ == '__main__': if options[ret] == utils.language(30406): API.updateUserRating(embyid, favourite=False) if options[ret] == utils.language(30407): + kodiconn = utils.kodiSQL('music') + kodicursor = kodiconn.cursor() query = ' '.join(("SELECT rating", "FROM song", "WHERE idSong = ?" )) kodicursor.execute(query, (itemid,)) currentvalue = int(round(float(kodicursor.fetchone()[0]),0)) diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index 303c36fc..dc3c60c2 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -242,6 +242,11 @@ Enable server connection message on start-up Use local paths instead of addon redirect for playback + Recently added Home Videos + Recently added Photos + Favourite Home Videos + Favourite Photos + Favourite Albums Active diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 4026c935..80d623ff 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -25,6 +25,7 @@ import playbackutils as pbutils import playutils import api + ################################################################################################# @@ -56,7 +57,6 @@ def addDirectoryItem(label, path, folder=True): xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=li, isFolder=folder) def doMainListing(): - xbmcplugin.setContent(int(sys.argv[1]), 'files') # Get emby nodes from the window props embyprops = utils.window('Emby.nodes.total') @@ -67,8 +67,8 @@ def doMainListing(): if not path: path = utils.window('Emby.nodes.%s.content' % i) label = utils.window('Emby.nodes.%s.title' % i) - if path: - print path + type = utils.window('Emby.nodes.%s.type' % i) + if path and ((xbmc.getCondVisibility("Window.IsActive(Pictures)") and type=="photos") or (xbmc.getCondVisibility("Window.IsActive(VideoLibrary)") and type != "photos")): addDirectoryItem(label, path) # some extra entries for settings and stuff. TODO --> localize the labels @@ -402,98 +402,148 @@ def refreshPlaylist(): sound=False) ##### BROWSE EMBY HOMEVIDEOS AND PICTURES ##### -def BrowseContent(viewname, type="", folderid=None, filter=None): +def BrowseContent(viewname, type="", folderid=None, filter=""): - _addon_id = int(sys.argv[1]) - _addon_url = sys.argv[0] - doUtils = downloadutils.DownloadUtils() emby = embyserver.Read_EmbyServer() - art = artwork.Artwork() - utils.logMsg("BrowseHomeVideos","viewname: %s - type: %s - folderid: %s - filter: %s" %(viewname, type, folderid, filter),0) - - if type.lower() == "homevideos": - xbmcplugin.setContent(int(sys.argv[1]), 'episodes') - itemtype = "Video" - elif type.lower() == "photos": - xbmcplugin.setContent(int(sys.argv[1]), 'pictures') - itemtype = "Photo" - else: - itemtype = "" - + utils.logMsg("BrowseHomeVideos","viewname: %s - type: %s - folderid: %s - filter: %s" %(viewname, type, folderid, filter)) + xbmcplugin.setPluginCategory(int(sys.argv[1]), viewname) + #get views for root level if not folderid: views = emby.getViews(type) for view in views: if view.get("name") == viewname: folderid = view.get("id") - print view - + + #set the correct params for the content type + #only proceed if we have a folderid if folderid: - listing = emby.getSection(folderid).get("Items",[]) + if type.lower() == "homevideos": + xbmcplugin.setContent(int(sys.argv[1]), 'episodes') + itemtype = "Video,Folder,PhotoAlbum" + elif type.lower() == "photos": + xbmcplugin.setContent(int(sys.argv[1]), 'files') + itemtype = "Photo,PhotoAlbum" + else: + itemtype = "" + + #get the actual listing + if filter == "recent": + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="DateCreated", recursive=True, limit=25, sortorder="Descending").get("Items",[]) + elif filter == "random": + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="Random", recursive=True, limit=150, sortorder="Descending").get("Items",[]) + elif filter == "recommended": + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite").get("Items",[]) + elif filter == "sets": + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[1], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite").get("Items",[]) + else: + listing = emby.getFilteredSection(folderid, itemtype=itemtype, recursive=False).get("Items",[]) + + #process the listing for item in listing: - if item.get("Type") == itemtype or item.get("IsFolder") == True: - API = api.API(item) - itemid = item['Id'] - title = item.get('Name') - li = xbmcgui.ListItem(title) - - premieredate = API.getPremiereDate() - genre = API.getGenres() - overlay = 0 - userdata = API.getUserData() - seektime = userdata['Resume'] - if seektime: - li.setProperty("resumetime", seektime) - li.setProperty("totaltime", item.get("RunTimeTicks")/ 10000000.0) - - played = userdata['Played'] - if played: overlay = 7 - else: overlay = 6 - - favorite = userdata['Favorite'] - if favorite: overlay = 5 - - playcount = userdata['PlayCount'] - if playcount is None: - playcount = 0 - - rating = item.get('CommunityRating') - if not rating: rating = userdata['UserRating'] - - # Populate the extradata list and artwork - pbutils.PlaybackUtils(item).setArtwork(li) - extradata = { - - 'id': itemid, - 'rating': rating, - 'year': item.get('ProductionYear'), - 'premieredate': premieredate, - 'genre': genre, - 'playcount': str(playcount), - 'title': title, - 'plot': API.getOverview(), - 'Overlay': str(overlay), - } - li.setInfo('video', infoLabels=extradata) - li.setThumbnailImage(art.getAllArtwork(item)['Primary']) - li.setIconImage('DefaultTVShows.png') - - if item.get("IsFolder") == True: - path = "%s?id=%s&mode=browsecontent&type=%s&folderid=%s" % (_addon_url, viewname, type, itemid) - xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li, isFolder=True) - else: - path = "%s?id=%s&mode=play" % (_addon_url, itemid) - li.setProperty('IsPlayable', 'true') - - mediastreams = API.getMediaStreams() - if mediastreams: - for key, value in mediastreams.iteritems(): - if value: li.addStreamInfo(key, value[0]) - - xbmcplugin.addDirectoryItem(handle=_addon_id, url=path, listitem=li) + li = createListItemFromEmbyItem(item) + if item.get("IsFolder") == True: + #for folders we add an additional browse request, passing the folderId + path = "%s?id=%s&mode=browsecontent&type=%s&folderid=%s" % (sys.argv[0], viewname, type, item.get("Id")) + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=li, isFolder=True) + else: + #playable item, set plugin path and mediastreams + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=li.getProperty("path"), listitem=li) xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) + if filter == "recent": + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_DATE) + else: + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_VIDEO_TITLE) + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_DATE) + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_VIDEO_RATING) + xbmcplugin.addSortMethod(int(sys.argv[1]), xbmcplugin.SORT_METHOD_VIDEO_RUNTIME) + +##### CREATE LISTITEM FROM EMBY METADATA ##### +def createListItemFromEmbyItem(item): + API = api.API(item) + art = artwork.Artwork() + doUtils = downloadutils.DownloadUtils() + itemid = item['Id'] + + title = item.get('Name') + li = xbmcgui.ListItem(title) + + premieredate = item.get('PremiereDate',"") + if not premieredate: premieredate = item.get('DateCreated',"") + if premieredate: + premieredatelst = premieredate.split('T')[0].split("-") + premieredate = "%s.%s.%s" %(premieredatelst[2],premieredatelst[1],premieredatelst[0]) + + li.setProperty("embyid",itemid) + + allart = art.getAllArtwork(item) + + if item["Type"] in ["Photo","PhotoAlbum"]: + #listitem setup for pictures... + img_path = allart.get('Primary') + li.setProperty("path",img_path) + picture = doUtils.downloadUrl("{server}/Items/%s/Images" %itemid)[0] + if picture.get("Width") > picture.get("Height"): + li.setArt( {"fanart": img_path}) #add image as fanart for use with skinhelper auto thumb/backgrund creation + li.setInfo('pictures', infoLabels={ "picturepath": img_path, "date": premieredate, "size": picture.get("Size"), "exif:width": str(picture.get("Width")), "exif:height": str(picture.get("Height")), "title": "blaat" }) + li.setThumbnailImage(img_path) + li.setIconImage('DefaultPicture.png') + else: + #normal video items + li.setProperty('IsPlayable', 'true') + path = "%s?id=%s&mode=play" % (sys.argv[0], item.get("Id")) + li.setProperty("path",path) + genre = API.getGenres() + overlay = 0 + userdata = API.getUserData() + seektime = userdata['Resume'] + if seektime: + li.setProperty("resumetime", seektime) + li.setProperty("totaltime", item.get("RunTimeTicks")/ 10000000.0) + + played = userdata['Played'] + if played: overlay = 7 + else: overlay = 6 + + favorite = userdata['Favorite'] + if favorite: overlay = 5 + + playcount = userdata['PlayCount'] + if playcount is None: + playcount = 0 + rating = item.get('CommunityRating') + if not rating: rating = userdata['UserRating'] + + # Populate the extradata list and artwork + extradata = { + 'id': itemid, + 'rating': rating, + 'year': item.get('ProductionYear'), + 'premieredate': premieredate, + 'date': premieredate, + 'genre': genre, + 'playcount': str(playcount), + 'title': title, + 'plot': API.getOverview(), + 'Overlay': str(overlay), + } + li.setInfo('video', infoLabels=extradata) + li.setThumbnailImage(allart.get('Primary')) + li.setIconImage('DefaultTVShows.png') + if not allart.get('Background'): #add image as fanart for use with skinhelper auto thumb/backgrund creation + li.setArt( {"fanart": allart.get('Primary') } ) + else: + pbutils.PlaybackUtils(item).setArtwork(li) + + mediastreams = API.getMediaStreams() + if mediastreams: + for key, value in mediastreams.iteritems(): + if value: li.addStreamInfo(key, value[0]) + + return li + ##### BROWSE EMBY CHANNELS ##### def BrowseChannels(itemid, folderid=None): diff --git a/resources/lib/read_embyserver.py b/resources/lib/read_embyserver.py index ee2f4427..182c33fb 100644 --- a/resources/lib/read_embyserver.py +++ b/resources/lib/read_embyserver.py @@ -124,7 +124,30 @@ class Read_EmbyServer(): cursor_emby.close() return [viewName, viewId, mediatype] + + def getFilteredSection(self, parentid, itemtype=None, sortby="SortName", recursive=True, limit=None, sortorder="Ascending", filter=""): + doUtils = self.doUtils + url = "{server}/emby/Users/{UserId}/Items?format=json" + params = { + 'ParentId': parentid, + 'IncludeItemTypes': itemtype, + 'CollapseBoxSetItems': False, + 'IsVirtualUnaired': False, + 'IsMissing': False, + 'Recursive': recursive, + 'Limit': limit, + 'SortBy': sortby, + 'SortOrder': sortorder, + 'Filters': filter, + 'Fields': ( "Path,Genres,SortName,Studios,Writer,ProductionYear,Taglines," + "CommunityRating,OfficialRating,CumulativeRunTimeTicks," + "Metascore,AirTime,DateCreated,MediaStreams,People,Overview," + "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations," + "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers") + } + return doUtils.downloadUrl(url, parameters=params) + def getSection(self, parentid, itemtype=None, sortby="SortName", basic=False): doUtils = self.doUtils diff --git a/resources/lib/videonodes.py b/resources/lib/videonodes.py index 84564e7a..c1d42df1 100644 --- a/resources/lib/videonodes.py +++ b/resources/lib/videonodes.py @@ -120,11 +120,12 @@ class VideoNodes(object): '8': "sets", '9': "genres", '10': "random", - '11': "recommended" + '11': "recommended", } mediatypes = { # label according to nodetype per mediatype - 'movies': { + 'movies': + { '1': tagname, '2': 30174, '4': 30177, @@ -132,9 +133,11 @@ class VideoNodes(object): '8': 20434, '9': 135, '10': 30229, - '11': 30230}, + '11': 30230 + }, - 'tvshows': { + 'tvshows': + { '1': tagname, '2': 30170, '3': 30175, @@ -143,15 +146,23 @@ class VideoNodes(object): '7': 30179, '9': 135, '10': 30229, - '11': 30230}, + '11': 30230 + }, - 'homevideos': { + 'homevideos': + { '1': tagname, - '2': 30170}, + '2': 30251, + '11': 30253 + }, - 'photos': { + 'photos': + { '1': tagname, - '2': 30170}, + '2': 30252, + '8': 30255, + '11': 30254 + }, } nodes = mediatypes[mediatype] @@ -169,18 +180,12 @@ class VideoNodes(object): label = stringid # Set window properties - if mediatype == "homevideos" and nodetype == "all": + if (mediatype == "homevideos" or mediatype == "photos") and nodetype == "all": # Custom query - path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=homevideos" % tagname - elif mediatype == "homevideos" and nodetype == "recent": + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=%s" %(tagname,mediatype) + elif (mediatype == "homevideos" or mediatype == "photos"): # Custom query - path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=homevideos&filter=recent" % tagname - elif mediatype == "photos" and nodetype == "all": - # Custom query - path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=photos" % tagname - elif mediatype == "photos" and nodetype == "recent": - # Custom query - path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=photos&filter=recent" % tagname + path = "plugin://plugin.video.emby/?id=%s&mode=browsecontent&type=%s&filter=%s" %(tagname,mediatype,nodetype) elif nodetype == "nextepisodes": # Custom query path = "plugin://plugin.video.emby/?id=%s&mode=nextup&limit=25" % tagname From 8e2cae3b13d7c2ce155cf74fb1a80b3ce5fadc3a Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Wed, 13 Jan 2016 01:26:07 +0100 Subject: [PATCH 05/12] fix small typos add delete option to context menu --- contextmenu.py | 15 +++++++++- resources/language/English/strings.xml | 1 + resources/lib/entrypoint.py | 41 ++++++++++++++------------ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/contextmenu.py b/contextmenu.py index 619ba149..e7bee464 100644 --- a/contextmenu.py +++ b/contextmenu.py @@ -82,6 +82,9 @@ if __name__ == '__main__': #Set custom song rating options.append(utils.language(30407)) + #delete item + options.append(utils.language(30409)) + #addon settings options.append(utils.language(30408)) @@ -119,4 +122,14 @@ if __name__ == '__main__': if options[ret] == utils.language(30408): #Open addon settings xbmc.executebuiltin("Addon.OpenSettings(plugin.video.emby)") - + + if options[ret] == utils.language(30409): + #delete item from the server + if xbmcgui.Dialog().yesno("Do you really want to delete this item ?", "This will delete the item from the server and the file(s) from disk!"): + import downloadutils + doUtils = downloadutils.DownloadUtils() + url = "{server}/emby/Items/%s?format=json" % embyid + doUtils.downloadUrl(url, type="DELETE") + + xbmc.sleep(500) + xbmc.executebuiltin("Container.Update") \ No newline at end of file diff --git a/resources/language/English/strings.xml b/resources/language/English/strings.xml index dc3c60c2..abcf07a5 100644 --- a/resources/language/English/strings.xml +++ b/resources/language/English/strings.xml @@ -272,6 +272,7 @@ Remove from Emby favorites Set custom song rating Emby addon settings + Delete item from the server diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 80d623ff..15994d1e 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -428,26 +428,27 @@ def BrowseContent(viewname, type="", folderid=None, filter=""): #get the actual listing if filter == "recent": - listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="DateCreated", recursive=True, limit=25, sortorder="Descending").get("Items",[]) + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="DateCreated", recursive=True, limit=25, sortorder="Descending") elif filter == "random": - listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="Random", recursive=True, limit=150, sortorder="Descending").get("Items",[]) + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="Random", recursive=True, limit=150, sortorder="Descending") elif filter == "recommended": - listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite").get("Items",[]) + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[0], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite") elif filter == "sets": - listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[1], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite").get("Items",[]) + listing = emby.getFilteredSection("", itemtype=itemtype.split(",")[1], sortby="SortName", recursive=True, limit=25, sortorder="Ascending", filter="IsFavorite") else: - listing = emby.getFilteredSection(folderid, itemtype=itemtype, recursive=False).get("Items",[]) + listing = emby.getFilteredSection(folderid, itemtype=itemtype, recursive=False) #process the listing - for item in listing: - li = createListItemFromEmbyItem(item) - if item.get("IsFolder") == True: - #for folders we add an additional browse request, passing the folderId - path = "%s?id=%s&mode=browsecontent&type=%s&folderid=%s" % (sys.argv[0], viewname, type, item.get("Id")) - xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=li, isFolder=True) - else: - #playable item, set plugin path and mediastreams - xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=li.getProperty("path"), listitem=li) + if listing: + for item in listing.get("Items"): + li = createListItemFromEmbyItem(item) + if item.get("IsFolder") == True: + #for folders we add an additional browse request, passing the folderId + path = "%s?id=%s&mode=browsecontent&type=%s&folderid=%s" % (sys.argv[0], viewname, type, item.get("Id")) + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=path, listitem=li, isFolder=True) + else: + #playable item, set plugin path and mediastreams + xbmcplugin.addDirectoryItem(handle=int(sys.argv[1]), url=li.getProperty("path"), listitem=li) xbmcplugin.endOfDirectory(handle=int(sys.argv[1])) @@ -479,14 +480,16 @@ def createListItemFromEmbyItem(item): allart = art.getAllArtwork(item) - if item["Type"] in ["Photo","PhotoAlbum"]: + if item["Type"] is "Photo": #listitem setup for pictures... img_path = allart.get('Primary') li.setProperty("path",img_path) - picture = doUtils.downloadUrl("{server}/Items/%s/Images" %itemid)[0] - if picture.get("Width") > picture.get("Height"): - li.setArt( {"fanart": img_path}) #add image as fanart for use with skinhelper auto thumb/backgrund creation - li.setInfo('pictures', infoLabels={ "picturepath": img_path, "date": premieredate, "size": picture.get("Size"), "exif:width": str(picture.get("Width")), "exif:height": str(picture.get("Height")), "title": "blaat" }) + picture = doUtils.downloadUrl("{server}/Items/%s/Images" %itemid) + if picture: + picture = picture[0] + if picture.get("Width") > picture.get("Height"): + li.setArt( {"fanart": img_path}) #add image as fanart for use with skinhelper auto thumb/backgrund creation + li.setInfo('pictures', infoLabels={ "picturepath": img_path, "date": premieredate, "size": picture.get("Size"), "exif:width": str(picture.get("Width")), "exif:height": str(picture.get("Height")), "title": "blaat" }) li.setThumbnailImage(img_path) li.setIconImage('DefaultPicture.png') else: From b9ba092e12609869c38d10daee4ef57cb6b73480 Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Tue, 12 Jan 2016 19:38:18 -0600 Subject: [PATCH 06/12] H265 rectification --- resources/lib/playutils.py | 16 ++++++++-------- resources/settings.xml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py index 22e30b7f..81bd5c42 100644 --- a/resources/lib/playutils.py +++ b/resources/lib/playutils.py @@ -91,10 +91,10 @@ class PlayUtils(): self.logMsg("Can't direct play, play from HTTP enabled.", 1) return False - if (utils.settings('transcodeHEVC') == "true" and - item['MediaSources'][0]['Name'].startswith("1080P/HEVC")): - # Avoid HEVC(H265) 1080p - self.logMsg("Option to transcode 1080P/HEVC enabled.", 1) + if (utils.settings('transcodeH265') == "true" and + item['MediaSources'][0]['Name'].startswith(("1080P/HEVC","1080P/H265"))): + # Avoid H265 1080p + self.logMsg("Option to transcode 1080P/H265 enabled.", 1) return False canDirectPlay = item['MediaSources'][0]['SupportsDirectPlay'] @@ -191,10 +191,10 @@ class PlayUtils(): item = self.item - if (utils.settings('transcodeHEVC') == "true" and - item['MediaSources'][0]['Name'].startswith("1080P/HEVC")): - # Avoid HEVC(H265) 1080p - self.logMsg("Option to transcode 1080P/HEVC enabled.", 1) + if (utils.settings('transcodeH265') == "true" and + item['MediaSources'][0]['Name'].startswith(("1080P/HEVC","1080P/H265"))): + # Avoid H265 1080p + self.logMsg("Option to transcode 1080P/H265 enabled.", 1) return False # Requirement: BitRate, supported encoding diff --git a/resources/settings.xml b/resources/settings.xml index f5a093fc..53063d1a 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -47,7 +47,7 @@ - + From 993ef282e7ee17a73fd2967b07cda383410b92ac Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Tue, 12 Jan 2016 22:52:49 -0600 Subject: [PATCH 07/12] Fix reset Fixes if there's a crash, unable to reset --- resources/lib/librarysync.py | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/lib/librarysync.py b/resources/lib/librarysync.py index b193b1d4..4ceb468e 100644 --- a/resources/lib/librarysync.py +++ b/resources/lib/librarysync.py @@ -1122,6 +1122,7 @@ class LibrarySync(threading.Thread): try: self.run_internal() except Exception as e: + utils.window('emby_dbScan', clear=True) xbmcgui.Dialog().ok( heading="Emby for Kodi", line1=( From 10606f733defc194d6e0c9609adb33df304af77c Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Tue, 12 Jan 2016 23:09:55 -0600 Subject: [PATCH 08/12] Fix crash during initial sync for songs --- resources/lib/itemtypes.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index e08080a3..a6d5b6e6 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -2209,22 +2209,31 @@ class Music(Items): emby_db.updateReference(itemid, checksum) def getSongRatingAndComment(self, embyid, emby_rating, API): + + kodicursor = self.kodicursor + previous_values = None filename = API.getFilePath() rating = 0 - emby_rating = int(round(emby_rating,0)) + emby_rating = int(round(emby_rating, 0)) file_rating, comment = musicutils.getSongTags(filename) - currentvalue = None - kodiid = self.emby_db.getItem_byId(embyid)[0] - query = ' '.join(("SELECT rating", "FROM song", "WHERE idSong = ?" )) - self.kodicursor.execute(query, (kodiid,)) - currentvalue = int(round(float(self.kodicursor.fetchone()[0]),0)) + + emby_dbitem = self.emby_db.getItem_byId(embyid) + try: + kodiid = emby_dbitem[0] + except TypeError: + # Item is not in database. + currentvalue = None + else: + query = "SELECT rating FROM song WHERE idSong = ?" + kodicursor.execute(query, (kodiid,)) + currentvalue = int(round(float(kodicursor.fetchone()[0]),0)) - #only proceed if we actually have a rating from the file - if file_rating == None and currentvalue: + # Only proceed if we actually have a rating from the file + if file_rating is None and currentvalue: return (currentvalue, comment) - elif file_rating == None and not currentvalue: + elif file_rating is None and currentvalue is None: return (emby_rating, comment) file_rating = int(round(file_rating,0)) From 0bc17ec277d8b0d30229d43f2ea6e8dabf31b6a6 Mon Sep 17 00:00:00 2001 From: marcelveldt Date: Wed, 13 Jan 2016 13:24:26 +0100 Subject: [PATCH 09/12] small fix for photo support --- resources/lib/entrypoint.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 15994d1e..9afd5f41 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -480,7 +480,7 @@ def createListItemFromEmbyItem(item): allart = art.getAllArtwork(item) - if item["Type"] is "Photo": + if item["Type"] == "Photo": #listitem setup for pictures... img_path = allart.get('Primary') li.setProperty("path",img_path) @@ -489,8 +489,9 @@ def createListItemFromEmbyItem(item): picture = picture[0] if picture.get("Width") > picture.get("Height"): li.setArt( {"fanart": img_path}) #add image as fanart for use with skinhelper auto thumb/backgrund creation - li.setInfo('pictures', infoLabels={ "picturepath": img_path, "date": premieredate, "size": picture.get("Size"), "exif:width": str(picture.get("Width")), "exif:height": str(picture.get("Height")), "title": "blaat" }) + li.setInfo('pictures', infoLabels={ "picturepath": img_path, "date": premieredate, "size": picture.get("Size"), "exif:width": str(picture.get("Width")), "exif:height": str(picture.get("Height")), "title": title}) li.setThumbnailImage(img_path) + li.setProperty("plot",API.getOverview()) li.setIconImage('DefaultPicture.png') else: #normal video items @@ -507,11 +508,7 @@ def createListItemFromEmbyItem(item): played = userdata['Played'] if played: overlay = 7 - else: overlay = 6 - - favorite = userdata['Favorite'] - if favorite: overlay = 5 - + else: overlay = 6 playcount = userdata['PlayCount'] if playcount is None: playcount = 0 From 5c9e57193697c13c0ba8cf72961cc7025993a188 Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Wed, 13 Jan 2016 15:47:31 -0600 Subject: [PATCH 10/12] Remove boxset forced sync. Manual sync is available for that purpose. --- resources/lib/itemtypes.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/resources/lib/itemtypes.py b/resources/lib/itemtypes.py index a6d5b6e6..0da05345 100644 --- a/resources/lib/itemtypes.py +++ b/resources/lib/itemtypes.py @@ -195,11 +195,6 @@ class Items(object): count += 1 actions[process](item) - else: - if itemtype == "Movie" and process == "update": - # Refresh boxsets - boxsets = self.emby.getBoxset() - items_process.added_boxset(boxsets['Items'], pdialog) if musicconn is not None: @@ -237,9 +232,6 @@ class Movies(Items): self.add_update(movie) if not pdialog and self.contentmsg: self.contentPop(title) - # Refresh boxsets - boxsets = self.emby.getBoxset() - self.added_boxset(boxsets['Items'], pdialog) def added_boxset(self, items, pdialog): From 5112500f4d64e85638d6b0365c94ac7c99012d2e Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Thu, 14 Jan 2016 02:59:02 -0600 Subject: [PATCH 11/12] Fix missing mediasources Also add a throttle to avoid crash if server connection times out. --- resources/lib/read_embyserver.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/resources/lib/read_embyserver.py b/resources/lib/read_embyserver.py index 182c33fb..d0158b0e 100644 --- a/resources/lib/read_embyserver.py +++ b/resources/lib/read_embyserver.py @@ -80,8 +80,9 @@ class Read_EmbyServer(): "Path,Genres,SortName,Studios,Writer,ProductionYear,Taglines," "CommunityRating,OfficialRating,CumulativeRunTimeTicks," "Metascore,AirTime,DateCreated,MediaStreams,People,Overview," - "CriticRating,CriticRatingSummary,Etag,ProductionLocations," - "Tags,ProviderIds,RemoteTrailers,SpecialEpisodeNumbers" + "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations," + "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers," + "MediaSources" ) } result = self.doUtils.downloadUrl(url, parameters=params) @@ -205,12 +206,19 @@ class Read_EmbyServer(): "CommunityRating,OfficialRating,CumulativeRunTimeTicks," "Metascore,AirTime,DateCreated,MediaStreams,People,Overview," "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations," - "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers" + "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers," + "MediaSources" ) result = doUtils.downloadUrl(url, parameters=params) - items['Items'].extend(result['Items']) - - index += jump + try: + items['Items'].extend(result['Items']) + except TypeError: + # Connection timed out, reduce the number + jump -= 50 + self.limitindex = jump + self.logMsg("New throttle for items requested: %s" % jump, 1) + else: + index += jump return items @@ -389,9 +397,15 @@ class Read_EmbyServer(): ) } result = doUtils.downloadUrl(url, parameters=params) - items['Items'].extend(result['Items']) - - index += jump + try: + items['Items'].extend(result['Items']) + except TypeError: + # Connection timed out, reduce the number + jump -= 50 + self.limitindex = jump + self.logMsg("New throttle for items requested: %s" % jump, 1) + else: + index += jump return items From fa826c2791e19e712defde94c59da20cacfcb216 Mon Sep 17 00:00:00 2001 From: angelblue05 Date: Fri, 15 Jan 2016 01:26:54 -0600 Subject: [PATCH 12/12] Fix websocket not restarting Didn't realise the method and class variable had the same name. --- resources/lib/websocket_client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index df5a7658..da59da83 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -27,7 +27,7 @@ class WebSocket_Client(threading.Thread): _shared_state = {} client = None - stopClient = False + stopWebsocket = False def __init__(self): @@ -302,8 +302,7 @@ class WebSocket_Client(threading.Thread): while not monitor.abortRequested(): self.client.run_forever() - - if self.stopClient: + if self.stopWebsocket: break if monitor.waitForAbort(5): @@ -314,6 +313,6 @@ class WebSocket_Client(threading.Thread): def stopClient(self): - self.stopClient = True + self.stopWebsocket = True self.client.close() self.logMsg("Stopping thread.") \ No newline at end of file