Skip to content

Commit

Permalink
id3: support for writing id3v2.3 tags. (Fixes issue #85)
Browse files Browse the repository at this point in the history
  • Loading branch information
lazka committed Sep 5, 2013
1 parent c808057 commit 7f54e8f
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 31 deletions.
13 changes: 13 additions & 0 deletions mutagen/_id3frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ def __init__(self, *args, **kwargs):
raise
setattr(self, checker.name, validated)

def _get_v23_frame(self, **kwargs):
"""Returns a frame copy which is suitable for writing into a v2.3 tag.
kwargs get passed to the specs.
"""

new_kwargs = {}
for checker in self._framespec:
name = checker.name
value = getattr(self, name)
new_kwargs[name] = checker._validate23(self, value, **kwargs)
return type(self)(**new_kwargs)

@property
def HashKey(self):
"""An internal key used to ensure frame uniqueness in a tag"""
Expand Down
30 changes: 30 additions & 0 deletions mutagen/_id3specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ def __init__(self, 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


class ByteSpec(Spec):
def read(self, frame, data):
Expand Down Expand Up @@ -72,6 +79,10 @@ def validate(self, frame, value):
return None
raise ValueError('Invalid Encoding: %r' % value)

def _validate23(self, frame, value, **kwargs):
# only 0, 1 are valid in v2.3, default to utf-16
return min(1, value)


class StringSpec(Spec):
def __init__(self, name, length):
Expand Down Expand Up @@ -191,6 +202,25 @@ def validate(self, frame, value):
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 zip(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
Expand Down
167 changes: 136 additions & 31 deletions mutagen/id3.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# id3 support for mutagen
# Copyright (C) 2005 Michael Urman
# 2006 Lukas Lalinsky
# 2013 Christoph Reiter
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
Expand Down Expand Up @@ -85,16 +87,17 @@ def __fullread(self, size):
self.__readbytes += size
return data

def load(self, filename, known_frames=None, translate=True):
def load(self, filename, known_frames=None, translate=True, v2_version=4):
"""Load tags from a filename.
Keyword arguments:
* filename -- filename to load tag data from
* known_frames -- dict mapping frame IDs to Frame objects
* translate -- Update all tags to ID3v2.4 internally. Mutagen is
only capable of writing ID3v2.4 tags, so if you
intend to save, this must be true.
* translate -- Update all tags to ID3v2.3/4 internally. If you
intend to save, this must be true or you have to
call update_to_v23() / update_to_v24() manually.
* v2_version -- if update_to_v23 or update_to_v24 get called (3 or 4)
Example of loading a custom frame::
Expand All @@ -104,6 +107,9 @@ class XMYF(Frame): ...
mutagen.id3.ID3(filename, known_frames=my_frames)
"""

if not v2_version in (3, 4):
raise ValueError("Only 3 and 4 possible for v2_version")

from os.path import getsize

self.filename = filename
Expand Down Expand Up @@ -151,7 +157,10 @@ class XMYF(Frame): ...
del self.__fileobj
del self.__filesize
if translate:
self.update_to_v24()
if v2_version == 3:
self.update_to_v23()
else:
self.update_to_v24()

def getall(self, key):
"""Return all frames with a given name (the list may be empty).
Expand Down Expand Up @@ -386,20 +395,34 @@ def __load_framedata(self, tag, flags, framedata):

#f_crc = property(lambda s: bool(s.__extflags & 0x8000))

def save(self, filename=None, v1=1):
def save(self, filename=None, v1=1, v2_version=4, v23_sep='/'):
"""Save changes to a file.
If no filename is given, the one most recently loaded is used.
Keyword arguments:
v1 -- if 0, ID3v1 tags will be removed
if 1, ID3v1 tags will be updated but not added
if 2, ID3v1 tags will be created and/or updated
v2 -- version of ID3v2 tags (3 or 4).
By default Mutagen saves ID3v2.4 tags. If you want to save ID3v2.3
tags, you must call method update_to_v23 before saving the file.
v23_sep -- the separator used to join multiple text values
if v2_version == 3. Defaults to '/' but if it's None
will be the ID3v2v2.4 null separator.
The lack of a way to update only an ID3v1 tag is intentional.
"""

if v2_version == 3:
version = self._V23
elif v2_version == 4:
version = self._V24
else:
raise ValueError("Only 3 or 4 allowed for v2_version")

# Sort frames by 'importance'
order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"]
order = dict(zip(order, range(len(order))))
Expand All @@ -408,11 +431,12 @@ def save(self, filename=None, v1=1):
frames.sort(lambda a, b: cmp(order.get(a[0][:4], last),
order.get(b[0][:4], last)))

framedata = [self.__save_frame(frame) for (key, frame) in frames]
framedata = [self.__save_frame(frame, version=version, v23_sep=v23_sep)
for (key, frame) in frames]

# only write unknown frames if they were loaded from the version
# we are saving with or upgraded to it
if self.__unknown_version == (2, 4, 0):
if self.__unknown_version == version:
framedata.extend([data for data in self.unknown_frames
if len(data) > 10])

Expand Down Expand Up @@ -456,7 +480,7 @@ def save(self, filename=None, v1=1):

framesize = BitPaddedInt.to_str(outsize, width=4)
flags = 0
header = pack('>3sBBB4s', 'ID3', 4, 0, flags, framesize)
header = pack('>3sBBB4s', 'ID3', v2_version, 0, flags, framesize)
data = header + framedata

if (insize < outsize):
Expand Down Expand Up @@ -510,12 +534,18 @@ def delete(self, filename=None, delete_v1=True, delete_v2=True):
delete(filename, delete_v1, delete_v2)
self.clear()

def __save_frame(self, frame, name=None):
def __save_frame(self, frame, name=None, version=_V24, v23_sep=None):
flags = 0
if self.PEDANTIC and isinstance(frame, TextFrame):
if len(str(frame)) == 0:
return ''
framedata = frame._writeData()

if version == self._V23:
framev23 = frame._get_v23_frame(sep=v23_sep)
framedata = framev23._writeData()
else:
framedata = frame._writeData()

usize = len(framedata)
if usize > 2048:
# Disabled as this causes iTunes and other programs
Expand All @@ -524,10 +554,39 @@ def __save_frame(self, frame, name=None):
#framedata = BitPaddedInt.to_str(usize) + framedata.encode('zlib')
#flags |= Frame.FLAG24_COMPRESS | Frame.FLAG24_DATALEN
pass
datasize = BitPaddedInt.to_str(len(framedata), width=4)

if version == self._V24:
bits = 7
elif version == self._V23:
bits = 8
else:
raise ValueError

datasize = BitPaddedInt.to_str(len(framedata), width=4, bits=bits)
header = pack('>4s4sH', name or type(frame).__name__, datasize, flags)
return header + framedata

def __update_common(self):
"""Updates done by both v23 and v24 update"""

if "TCON" in self:
# Get rid of "(xx)Foobr" format.
self["TCON"].genres = self["TCON"].genres

if self.version < self._V23:
# ID3v2.2 PIC frames are slightly different.
pics = self.getall("APIC")
mimes = {"PNG": "image/png", "JPG": "image/jpeg"}
self.delall("APIC")
for pic in pics:
newpic = APIC(
encoding=pic.encoding, mime=mimes.get(pic.mime, pic.mime),
type=pic.type, desc=pic.desc, data=pic.data)
self.add(newpic)

# ID3v2.2 LNK frames are just way too different to upgrade.
self.delall("LINK")

def update_to_v24(self):
"""Convert older tags into an ID3v2.4 tag.
Expand All @@ -536,6 +595,8 @@ def update_to_v24(self):
at some point; it is called by default when loading the tag.
"""

self.__update_common()

if self.__unknown_version == (2, 3, 0):
# convert unknown 2.3 frames (flags/size) to 2.4
converted = []
Expand Down Expand Up @@ -581,30 +642,74 @@ def update_to_v24(self):
if "TIPL" not in self:
self.add(TIPL(encoding=f.encoding, people=f.people))

if "TCON" in self:
# Get rid of "(xx)Foobr" format.
self["TCON"].genres = self["TCON"].genres

if self.version < self._V23:
# ID3v2.2 PIC frames are slightly different.
pics = self.getall("APIC")
mimes = {"PNG": "image/png", "JPG": "image/jpeg"}
self.delall("APIC")
for pic in pics:
newpic = APIC(
encoding=pic.encoding, mime=mimes.get(pic.mime, pic.mime),
type=pic.type, desc=pic.desc, data=pic.data)
self.add(newpic)

# ID3v2.2 LNK frames are just way too different to upgrade.
self.delall("LINK")

# These can't be trivially translated to any ID3v2.4 tags, or
# should have been removed already.
for key in ["RVAD", "EQUA", "TRDA", "TSIZ", "TDAT", "TIME", "CRM"]:
if key in self:
del(self[key])

def update_to_v23(self):
"""Convert older (and newer) tags into an ID3v2.3 tag.
This updates incompatible ID3v2 frames to ID3v2.3 ones. If you
intend to save tags as ID3v2.3, you must call this function
at some point.
If you want to to go off spec and include some v2.4 frames
in v2.3, remove them before calling this and add them back afterwards.
"""

self.__update_common()

# we could downgrade unknown v2.4 frames here, but given that
# the main reason to save v2.3 is compatibility and this
# might increase the chance of some parser breaking.. better not

# TMCL, TIPL -> TIPL
if "TIPL" in self or "TMCL" in self:
people = []
if "TIPL" in self:
f = self.pop("TIPL")
people.extend(f.people)
if "TMCL" in self:
f = self.pop("TMCL")
people.extend(f.people)
if "IPLS" not in self:
self.add(IPLS(encoding=f.encoding, people=people))

# TDOR -> TORY
if "TDOR" in self:
f = self.pop("TDOR")
if f.text:
d = f.text[0]
if d.year and "TORY" not in self:
self.add(TORY(encoding=f.encoding, text="%04d" % d.year))

# TDRC -> TYER, TDAT, TIME
if "TDRC" in self:
f = self.pop("TDRC")
if f.text:
d = f.text[0]
if d.year and "TYER" not in self:
self.add(TYER(encoding=f.encoding, text="%04d" % d.year))
if d.month and d.day and "TDAT" not in self:
self.add(TDAT(encoding=f.encoding,
text="%02d%02d" % (d.day, d.month)))
if d.hour and d.minute and "TIME" not in self:
self.add(TIME(encoding=f.encoding,
text="%02d%02d" % (d.hour, d.minute)))

# New frames added in v2.4
v24_frames = [
'ASPI', 'EQU2', 'RVA2', 'SEEK', 'SIGN', 'TDEN', 'TDOR',
'TDRC', 'TDRL', 'TDTG', 'TIPL', 'TMCL', 'TMOO', 'TPRO',
'TSOA', 'TSOP', 'TSOT', 'TSST',
]

for key in v24_frames:
if key in self:
del(self[key])


def delete(filename, delete_v1=True, delete_v2=True):
"""Remove tags from a file.
Expand Down
Loading

0 comments on commit 7f54e8f

Please sign in to comment.