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)
This commit is contained in:
marcelveldt 2016-01-11 16:53:41 +01:00
parent 2e4e983acd
commit 0817085ada
88 changed files with 15314 additions and 27 deletions

View file

@ -16,6 +16,13 @@
</extension>
<extension point="xbmc.service" library="service.py" start="login">
</extension>
<extension point="kodi.context.item" library="contextmenu.py">
<item>
<label>30401</label>
<description>Settings for the Emby Server</description>
<visible>!IsEmpty(ListItem.DBID)</visible>
</item>
</extension>
<extension point="xbmc.addon.metadata">
<platform>all</platform>
<language>en</language>

125
contextmenu.py Normal file
View file

@ -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)")

View file

@ -258,8 +258,15 @@
<string id="30311">Music Tracks</string>
<string id="30312">Channels</string>
<!-- contextmenu -->
<string id="30401">Emby options</string>
<string id="30402">Clear like for this item</string>
<string id="30403">Like this item</string>
<string id="30404">Dislike this item</string>
<string id="30405">Add to Emby favorites</string>
<string id="30406">Remove from Emby favorites</string>
<string id="30407">Set custom song rating</string>
<string id="30408">Emby addon settings</string>
</strings>

View file

@ -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
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)

View file

@ -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,))
self.embycursor.execute(query, (embyid,))

View file

@ -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

131
resources/lib/musicutils.py Normal file
View file

@ -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)

View file

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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

View file

@ -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."""

View file

@ -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 <mp3.EasyMP3>` instead
of :class:`MP3 <mp3.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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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()])

View file

@ -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"]

View file

@ -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

View file

@ -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"))

View file

@ -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

View file

@ -0,0 +1,438 @@
# -*- 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.
import sys
import struct
from mutagen._compat import swap_to_string, text_type, PY2, reraise
from mutagen._util import total_ordering
from ._util import ASFError
class ASFBaseAttribute(object):
"""Generic attribute."""
TYPE = None
_TYPES = {}
value = None
"""The Python value of this attribute (type depends on the class)"""
language = None
"""Language"""
stream = None
"""Stream"""
def __init__(self, value=None, data=None, language=None,
stream=None, **kwargs):
self.language = language
self.stream = stream
if data:
self.value = self.parse(data, **kwargs)
else:
if value is None:
# we used to support not passing any args and instead assign
# them later, keep that working..
self.value = None
else:
self.value = self._validate(value)
@classmethod
def _register(cls, other):
cls._TYPES[other.TYPE] = other
return other
@classmethod
def _get_type(cls, type_):
"""Raises KeyError"""
return cls._TYPES[type_]
def _validate(self, value):
"""Raises TypeError or ValueError in case the user supplied value
isn't valid.
"""
return value
def data_size(self):
raise NotImplementedError
def __repr__(self):
name = "%s(%r" % (type(self).__name__, self.value)
if self.language:
name += ", language=%d" % self.language
if self.stream:
name += ", stream=%d" % self.stream
name += ")"
return name
def render(self, name):
name = name.encode("utf-16-le") + b"\x00\x00"
data = self._render()
return (struct.pack("<H", len(name)) + name +
struct.pack("<HH", self.TYPE, len(data)) + data)
def render_m(self, name):
name = name.encode("utf-16-le") + b"\x00\x00"
if self.TYPE == 2:
data = self._render(dword=False)
else:
data = self._render()
return (struct.pack("<HHHHI", 0, self.stream or 0, len(name),
self.TYPE, len(data)) + name + data)
def render_ml(self, name):
name = name.encode("utf-16-le") + b"\x00\x00"
if self.TYPE == 2:
data = self._render(dword=False)
else:
data = self._render()
return (struct.pack("<HHHHI", self.language or 0, self.stream or 0,
len(name), self.TYPE, len(data)) + name + data)
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFUnicodeAttribute(ASFBaseAttribute):
"""Unicode string attribute.
::
ASFUnicodeAttribute(u'some text')
"""
TYPE = 0x0000
def parse(self, data):
try:
return data.decode("utf-16-le").strip("\x00")
except UnicodeDecodeError as e:
reraise(ASFError, e, sys.exc_info()[2])
def _validate(self, value):
if not isinstance(value, text_type):
if PY2:
return value.decode("utf-8")
else:
raise TypeError("%r not str" % value)
return value
def _render(self):
return self.value.encode("utf-16-le") + b"\x00\x00"
def data_size(self):
return len(self._render())
def __bytes__(self):
return self.value.encode("utf-16-le")
def __str__(self):
return self.value
def __eq__(self, other):
return text_type(self) == other
def __lt__(self, other):
return text_type(self) < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFByteArrayAttribute(ASFBaseAttribute):
"""Byte array attribute.
::
ASFByteArrayAttribute(b'1234')
"""
TYPE = 0x0001
def parse(self, data):
assert isinstance(data, bytes)
return data
def _render(self):
assert isinstance(self.value, bytes)
return self.value
def _validate(self, value):
if not isinstance(value, bytes):
raise TypeError("must be bytes/str: %r" % value)
return value
def data_size(self):
return len(self.value)
def __bytes__(self):
return self.value
def __str__(self):
return "[binary data (%d bytes)]" % len(self.value)
def __eq__(self, other):
return self.value == other
def __lt__(self, other):
return self.value < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFBoolAttribute(ASFBaseAttribute):
"""Bool attribute.
::
ASFBoolAttribute(True)
"""
TYPE = 0x0002
def parse(self, data, dword=True):
if dword:
return struct.unpack("<I", data)[0] == 1
else:
return struct.unpack("<H", data)[0] == 1
def _render(self, dword=True):
if dword:
return struct.pack("<I", bool(self.value))
else:
return struct.pack("<H", bool(self.value))
def _validate(self, value):
return bool(value)
def data_size(self):
return 4
def __bool__(self):
return bool(self.value)
def __bytes__(self):
return text_type(self.value).encode('utf-8')
def __str__(self):
return text_type(self.value)
def __eq__(self, other):
return bool(self.value) == other
def __lt__(self, other):
return bool(self.value) < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFDWordAttribute(ASFBaseAttribute):
"""DWORD attribute.
::
ASFDWordAttribute(42)
"""
TYPE = 0x0003
def parse(self, data):
return struct.unpack("<L", data)[0]
def _render(self):
return struct.pack("<L", self.value)
def _validate(self, value):
value = int(value)
if not 0 <= value <= 2 ** 32 - 1:
raise ValueError("Out of range")
return value
def data_size(self):
return 4
def __int__(self):
return self.value
def __bytes__(self):
return text_type(self.value).encode('utf-8')
def __str__(self):
return text_type(self.value)
def __eq__(self, other):
return int(self.value) == other
def __lt__(self, other):
return int(self.value) < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFQWordAttribute(ASFBaseAttribute):
"""QWORD attribute.
::
ASFQWordAttribute(42)
"""
TYPE = 0x0004
def parse(self, data):
return struct.unpack("<Q", data)[0]
def _render(self):
return struct.pack("<Q", self.value)
def _validate(self, value):
value = int(value)
if not 0 <= value <= 2 ** 64 - 1:
raise ValueError("Out of range")
return value
def data_size(self):
return 8
def __int__(self):
return self.value
def __bytes__(self):
return text_type(self.value).encode('utf-8')
def __str__(self):
return text_type(self.value)
def __eq__(self, other):
return int(self.value) == other
def __lt__(self, other):
return int(self.value) < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFWordAttribute(ASFBaseAttribute):
"""WORD attribute.
::
ASFWordAttribute(42)
"""
TYPE = 0x0005
def parse(self, data):
return struct.unpack("<H", data)[0]
def _render(self):
return struct.pack("<H", self.value)
def _validate(self, value):
value = int(value)
if not 0 <= value <= 2 ** 16 - 1:
raise ValueError("Out of range")
return value
def data_size(self):
return 2
def __int__(self):
return self.value
def __bytes__(self):
return text_type(self.value).encode('utf-8')
def __str__(self):
return text_type(self.value)
def __eq__(self, other):
return int(self.value) == other
def __lt__(self, other):
return int(self.value) < other
__hash__ = ASFBaseAttribute.__hash__
@ASFBaseAttribute._register
@swap_to_string
@total_ordering
class ASFGUIDAttribute(ASFBaseAttribute):
"""GUID attribute."""
TYPE = 0x0006
def parse(self, data):
assert isinstance(data, bytes)
return data
def _render(self):
assert isinstance(self.value, bytes)
return self.value
def _validate(self, value):
if not isinstance(value, bytes):
raise TypeError("must be bytes/str: %r" % value)
return value
def data_size(self):
return len(self.value)
def __bytes__(self):
return self.value
def __str__(self):
return repr(self.value)
def __eq__(self, other):
return self.value == other
def __lt__(self, other):
return self.value < other
__hash__ = ASFBaseAttribute.__hash__
def ASFValue(value, kind, **kwargs):
"""Create a tag value of a specific kind.
::
ASFValue(u"My Value", UNICODE)
:rtype: ASFBaseAttribute
:raises TypeError: in case a wrong type was passed
:raises ValueError: in case the value can't be be represented as ASFValue.
"""
try:
attr_type = ASFBaseAttribute._get_type(kind)
except KeyError:
raise ValueError("Unknown value type %r" % kind)
else:
return attr_type(value=value, **kwargs)

View file

@ -0,0 +1,437 @@
# -*- 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.
import struct
from mutagen._util import cdata, get_size
from mutagen._compat import text_type, xrange, izip
from mutagen._tags import PaddingInfo
from ._util import guid2bytes, bytes2guid, CODECS, ASFError, ASFHeaderError
from ._attrs import ASFBaseAttribute, ASFUnicodeAttribute
class BaseObject(object):
"""Base ASF object."""
GUID = None
_TYPES = {}
def __init__(self):
self.objects = []
self.data = b""
def parse(self, asf, data):
self.data = data
def render(self, asf):
data = self.GUID + struct.pack("<Q", len(self.data) + 24) + self.data
return data
def get_child(self, guid):
for obj in self.objects:
if obj.GUID == guid:
return obj
return None
@classmethod
def _register(cls, other):
cls._TYPES[other.GUID] = other
return other
@classmethod
def _get_object(cls, guid):
if guid in cls._TYPES:
return cls._TYPES[guid]()
else:
return UnknownObject(guid)
def __repr__(self):
return "<%s GUID=%s objects=%r>" % (
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("<QL", header[16:28])
def render_full(self, asf, fileobj, available, padding_func):
# Render everything except padding
num_objects = 0
data = bytearray()
for obj in self.objects:
if obj.GUID == PaddingObject.GUID:
continue
data += obj.render(asf)
num_objects += 1
# calculate how much space we need at least
padding_obj = PaddingObject()
header_size = len(HeaderObject.GUID) + 14
padding_overhead = len(padding_obj.render(asf))
needed_size = len(data) + header_size + padding_overhead
# ask the user for padding adjustments
file_size = get_size(fileobj)
content_size = file_size - available
assert content_size >= 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("<QL", len(data) + 30, num_objects) +
b"\x01\x02" + data)
return data
def parse(self, asf, data):
raise NotImplementedError
def render(self, asf):
raise NotImplementedError
@BaseObject._register
class ContentDescriptionObject(BaseObject):
"""Content description."""
GUID = guid2bytes("75B22633-668E-11CF-A6D9-00AA0062CE6C")
NAMES = [
u"Title",
u"Author",
u"Copyright",
u"Description",
u"Rating",
]
def parse(self, asf, data):
super(ContentDescriptionObject, self).parse(asf, data)
lengths = struct.unpack("<HHHHH", data[:10])
texts = []
pos = 10
for length in lengths:
end = pos + length
if length > 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("<HHHHH", *map(len, texts)) + b"".join(texts)
return self.GUID + struct.pack("<Q", 24 + len(data)) + data
@BaseObject._register
class ExtendedContentDescriptionObject(BaseObject):
"""Extended content description."""
GUID = guid2bytes("D2D0A440-E307-11D2-97F0-00A0C95EA850")
def parse(self, asf, data):
super(ExtendedContentDescriptionObject, self).parse(asf, data)
num_attributes, = struct.unpack("<H", data[0:2])
pos = 2
for i in xrange(num_attributes):
name_length, = struct.unpack("<H", data[pos:pos + 2])
pos += 2
name = data[pos:pos + name_length]
name = name.decode("utf-16-le").strip("\x00")
pos += name_length
value_type, value_length = struct.unpack("<HH", data[pos:pos + 4])
pos += 4
value = data[pos:pos + value_length]
pos += value_length
attr = ASFBaseAttribute._get_type(value_type)(data=value)
asf._tags.setdefault(self.GUID, []).append((name, attr))
def render(self, asf):
attrs = asf.to_extended_content_description.items()
data = b"".join(attr.render(name) for (name, attr) in attrs)
data = struct.pack("<QH", 26 + len(data), len(attrs)) + data
return self.GUID + data
@BaseObject._register
class FilePropertiesObject(BaseObject):
"""File properties."""
GUID = guid2bytes("8CABDCA1-A947-11CF-8EE4-00C00C205365")
def parse(self, asf, data):
super(FilePropertiesObject, self).parse(asf, data)
length, _, preroll = struct.unpack("<QQQ", data[40:64])
# there are files where preroll is larger than length, limit to >= 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("<HII", data[56:66])
asf.info.channels = channels
asf.info.sample_rate = sample_rate
asf.info.bitrate = bitrate * 8
@BaseObject._register
class CodecListObject(BaseObject):
"""Codec List"""
GUID = guid2bytes("86D15240-311D-11D0-A3A4-00A0C90348F6")
def _parse_entry(self, data, offset):
"""can raise cdata.error"""
type_, offset = cdata.uint16_le_from(data, offset)
units, offset = cdata.uint16_le_from(data, offset)
# utf-16 code units, not characters..
next_offset = offset + units * 2
try:
name = data[offset:next_offset].decode("utf-16-le").strip("\x00")
except UnicodeDecodeError:
name = u""
offset = next_offset
units, offset = cdata.uint16_le_from(data, offset)
next_offset = offset + units * 2
try:
desc = data[offset:next_offset].decode("utf-16-le").strip("\x00")
except UnicodeDecodeError:
desc = u""
offset = next_offset
bytes_, offset = cdata.uint16_le_from(data, offset)
next_offset = offset + bytes_
codec = u""
if bytes_ == 2:
codec_id = cdata.uint16_le_from(data, offset)[0]
if codec_id in CODECS:
codec = CODECS[codec_id]
offset = next_offset
return offset, type_, name, desc, codec
def parse(self, asf, data):
super(CodecListObject, self).parse(asf, data)
offset = 16
count, offset = cdata.uint32_le_from(data, offset)
for i in xrange(count):
try:
offset, type_, name, desc, codec = \
self._parse_entry(data, offset)
except cdata.error:
raise ASFError("invalid codec entry")
# go with the first audio entry
if type_ == 2:
name = name.strip()
desc = desc.strip()
asf.info.codec_type = codec
asf.info.codec_name = name
asf.info.codec_description = desc
return
@BaseObject._register
class PaddingObject(BaseObject):
"""Padding object"""
GUID = guid2bytes("1806D474-CADF-4509-A4BA-9AABCB96AAE8")
@BaseObject._register
class StreamBitratePropertiesObject(BaseObject):
"""Stream bitrate properties"""
GUID = guid2bytes("7BF875CE-468D-11D1-8D82-006097C9A2B2")
@BaseObject._register
class ContentEncryptionObject(BaseObject):
"""Content encryption"""
GUID = guid2bytes("2211B3FB-BD23-11D2-B4B7-00A0C955FC6E")
@BaseObject._register
class ExtendedContentEncryptionObject(BaseObject):
"""Extended content encryption"""
GUID = guid2bytes("298AE614-2622-4C17-B935-DAE07EE9289C")
@BaseObject._register
class HeaderExtensionObject(BaseObject):
"""Header extension."""
GUID = guid2bytes("5FBF03B5-A92E-11CF-8EE3-00C00C205365")
def parse(self, asf, data):
super(HeaderExtensionObject, self).parse(asf, data)
datasize, = struct.unpack("<I", data[18:22])
datapos = 0
while datapos < datasize:
guid, size = struct.unpack(
"<16sQ", data[22 + datapos:22 + datapos + 24])
obj = BaseObject._get_object(guid)
obj.parse(asf, data[22 + datapos + 24:22 + datapos + size])
self.objects.append(obj)
datapos += size
def render(self, asf):
data = bytearray()
for obj in self.objects:
# some files have the padding in the extension header, but we
# want to add it at the end of the top level header. Just
# skip padding at this level.
if obj.GUID == PaddingObject.GUID:
continue
data += obj.render(asf)
return (self.GUID + struct.pack("<Q", 24 + 16 + 6 + len(data)) +
b"\x11\xD2\xD3\xAB\xBA\xA9\xcf\x11" +
b"\x8E\xE6\x00\xC0\x0C\x20\x53\x65" +
b"\x06\x00" + struct.pack("<I", len(data)) + data)
@BaseObject._register
class MetadataObject(BaseObject):
"""Metadata description."""
GUID = guid2bytes("C5F8CBEA-5BAF-4877-8467-AA8C44FA4CCA")
def parse(self, asf, data):
super(MetadataObject, self).parse(asf, data)
num_attributes, = struct.unpack("<H", data[0:2])
pos = 2
for i in xrange(num_attributes):
(reserved, stream, name_length, value_type,
value_length) = struct.unpack("<HHHHI", data[pos:pos + 12])
pos += 12
name = data[pos:pos + name_length]
name = name.decode("utf-16-le").strip("\x00")
pos += name_length
value = data[pos:pos + value_length]
pos += value_length
args = {'data': value, 'stream': stream}
if value_type == 2:
args['dword'] = False
attr = ASFBaseAttribute._get_type(value_type)(**args)
asf._tags.setdefault(self.GUID, []).append((name, attr))
def render(self, asf):
attrs = asf.to_metadata.items()
data = b"".join([attr.render_m(name) for (name, attr) in attrs])
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
data)
@BaseObject._register
class MetadataLibraryObject(BaseObject):
"""Metadata library description."""
GUID = guid2bytes("44231C94-9498-49D1-A141-1D134E457054")
def parse(self, asf, data):
super(MetadataLibraryObject, self).parse(asf, data)
num_attributes, = struct.unpack("<H", data[0:2])
pos = 2
for i in xrange(num_attributes):
(language, stream, name_length, value_type,
value_length) = struct.unpack("<HHHHI", data[pos:pos + 12])
pos += 12
name = data[pos:pos + name_length]
name = name.decode("utf-16-le").strip("\x00")
pos += name_length
value = data[pos:pos + value_length]
pos += value_length
args = {'data': value, 'language': language, 'stream': stream}
if value_type == 2:
args['dword'] = False
attr = ASFBaseAttribute._get_type(value_type)(**args)
asf._tags.setdefault(self.GUID, []).append((name, attr))
def render(self, asf):
attrs = asf.to_metadata_library
data = b"".join([attr.render_ml(name) for (name, attr) in attrs])
return (self.GUID + struct.pack("<QH", 26 + len(data), len(attrs)) +
data)

View file

@ -0,0 +1,315 @@
# -*- 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.
import struct
from mutagen._util import MutagenError
class error(IOError, MutagenError):
"""Error raised by :mod:`mutagen.asf`"""
class ASFError(error):
pass
class ASFHeaderError(error):
pass
def guid2bytes(s):
"""Converts a GUID to the serialized bytes representation"""
assert isinstance(s, str)
assert len(s) == 36
p = struct.pack
return b"".join([
p("<IHH", int(s[:8], 16), int(s[9:13], 16), int(s[14:18], 16)),
p(">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("<IHH", s[:8]))
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",
}

View file

@ -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

View file

@ -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 <mutagen.mp4.MP4>`,
but uses :class:`EasyMP4Tags` for tags.
:ivar info: :class:`MP4Info <mutagen.mp4.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

View file

@ -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()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"""

View file

@ -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")

View file

@ -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("<IIIHHI", header[56:76])
else:
compression_level = cdata.ushort_le(header[6:8])
self.channels, self.sample_rate = struct.unpack(
"<HI", header[10:16])
total_frames, final_frame_blocks = struct.unpack(
"<II", header[24:32])
if self.version >= 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

View file

@ -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 <mutagen.id3.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 <mutagen.easyid3.EasyID3>`
"""
from mutagen.easyid3 import EasyID3 as ID3
ID3 = ID3

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,542 @@
# -*- 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._compat import cBytesIO, xrange
from mutagen.aac import ProgramConfigElement
from mutagen._util import BitReader, BitReaderError, cdata
from mutagen._compat import text_type
from ._util import parse_full_atom
from ._atom import Atom, AtomError
class ASEntryError(Exception):
pass
class AudioSampleEntry(object):
"""Parses an AudioSampleEntry atom.
Private API.
Attrs:
channels (int): number of channels
sample_size (int): sample size in bits
sample_rate (int): sample rate in Hz
bitrate (int): bits per second (0 means unknown)
codec (string):
audio codec, either 'mp4a[.*][.*]' (rfc6381) or 'alac'
codec_description (string): descriptive codec name e.g. "AAC LC+SBR"
Can raise ASEntryError.
"""
channels = 0
sample_size = 0
sample_rate = 0
bitrate = 0
codec = None
codec_description = None
def __init__(self, atom, fileobj):
ok, data = atom.read(fileobj)
if not ok:
raise ASEntryError("too short %r atom" % atom.name)
fileobj = cBytesIO(data)
r = BitReader(fileobj)
try:
# SampleEntry
r.skip(6 * 8) # reserved
r.skip(2 * 8) # data_ref_index
# AudioSampleEntry
r.skip(8 * 8) # reserved
self.channels = r.bits(16)
self.sample_size = r.bits(16)
r.skip(2 * 8) # pre_defined
r.skip(2 * 8) # reserved
self.sample_rate = r.bits(32) >> 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")

View file

@ -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'] => <Atom name='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])

View file

@ -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:]

View file

@ -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(
"<Hh", header[12:16])
self.album_peak, self.album_gain = struct.unpack(
"<Hh", header[16:20])
self.title_gain /= 100.0
self.album_gain /= 100.0
self.title_peak /= 65535.0
self.album_peak /= 65535.0
self.sample_rate = RATES[(flags >> 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

View file

@ -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()

View file

@ -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()

View file

@ -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("<BBHIhB", page.packets[0][8:19])
self.__pre_skip = pre_skip
# only the higher 4 bits change on incombatible changes
major = version >> 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()

View file

@ -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()

View file

@ -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()

View file

@ -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("<B4i", page.packets[0][11:28])
self.serial = page.serial
max_bitrate = max(0, max_bitrate)
min_bitrate = max(0, min_bitrate)
nominal_bitrate = max(0, nominal_bitrate)
if nominal_bitrate == 0:
self.bitrate = (max_bitrate + min_bitrate) // 2
elif max_bitrate and max_bitrate < nominal_bitrate:
# If the max bitrate is less than the nominal, we know
# the nominal is wrong.
self.bitrate = max_bitrate
elif min_bitrate > 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()

View file

@ -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("<I", header[4:8])[0] not in [12, 15]):
raise OptimFROGHeaderError("not an OptimFROG file")
(total_samples, total_samples_high, sample_type, self.channels,
self.sample_rate) = struct.unpack("<IHBBI", header[8:20])
total_samples += total_samples_high << 32
self.channels += 1
if self.sample_rate:
self.length = float(total_samples) / (self.channels *
self.sample_rate)
else:
self.length = 0.0
def pprint(self):
return u"OptimFROG, %.2f seconds, %d Hz" % (self.length,
self.sample_rate)
class OptimFROG(APEv2File):
_Info = OptimFROGInfo
@staticmethod
def score(filename, fileobj, header):
filename = filename.lower()
return (header.startswith(b"OFR") + endswith(filename, b".ofr") +
endswith(filename, b".ofs"))
Open = OptimFROG

View file

@ -0,0 +1,84 @@
# -*- 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.
"""True Audio audio stream information and tags.
True Audio is a lossless format designed for real-time encoding and
decoding. This module is based on the documentation at
http://www.true-audio.com/TTA_Lossless_Audio_Codec\_-_Format_Description
True Audio files use ID3 tags.
"""
__all__ = ["TrueAudio", "Open", "delete", "EasyTrueAudio"]
from ._compat import endswith
from mutagen import StreamInfo
from mutagen.id3 import ID3FileType, delete
from mutagen._util import cdata, MutagenError
class error(RuntimeError, MutagenError):
pass
class TrueAudioHeaderError(error, IOError):
pass
class TrueAudioInfo(StreamInfo):
"""True Audio stream information.
Attributes:
* length - audio length, in seconds
* sample_rate - audio sample rate, in Hz
"""
def __init__(self, fileobj, offset):
fileobj.seek(offset or 0)
header = fileobj.read(18)
if len(header) != 18 or not header.startswith(b"TTA"):
raise TrueAudioHeaderError("TTA header not found")
self.sample_rate = cdata.int_le(header[10:14])
samples = cdata.uint_le(header[14:18])
self.length = float(samples) / self.sample_rate
def pprint(self):
return u"True Audio, %.2f seconds, %d Hz." % (
self.length, self.sample_rate)
class TrueAudio(ID3FileType):
"""A True Audio file.
:ivar info: :class:`TrueAudioInfo`
:ivar tags: :class:`ID3 <mutagen.id3.ID3>`
"""
_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 <mutagen.easyid3.EasyID3>`
"""
from mutagen.easyid3 import EasyID3 as ID3
ID3 = ID3

View file

@ -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

View file

@ -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