mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-25 22:35:05 +00:00
532 lines
16 KiB
Python
532 lines
16 KiB
Python
|
"""
|
||
|
ID3 metadata parser, supported versions: 1.O, 2.2, 2.3 and 2.4
|
||
|
|
||
|
Informations: http://www.id3.org/
|
||
|
|
||
|
Author: Victor Stinner
|
||
|
"""
|
||
|
|
||
|
from hachoir.field import (FieldSet, MatchError, ParserError,
|
||
|
Enum, UInt8, UInt24, UInt32,
|
||
|
CString, String, RawBytes,
|
||
|
Bit, Bits, NullBytes, NullBits)
|
||
|
from hachoir.core.text_handler import textHandler
|
||
|
from hachoir.core.tools import humanDuration
|
||
|
from hachoir.core.endian import NETWORK_ENDIAN
|
||
|
from functools import reduce
|
||
|
|
||
|
|
||
|
class ID3v1(FieldSet):
|
||
|
static_size = 128 * 8
|
||
|
GENRE_NAME = {
|
||
|
0: "Blues",
|
||
|
1: "Classic Rock",
|
||
|
2: "Country",
|
||
|
3: "Dance",
|
||
|
4: "Disco",
|
||
|
5: "Funk",
|
||
|
6: "Grunge",
|
||
|
7: "Hip-Hop",
|
||
|
8: "Jazz",
|
||
|
9: "Metal",
|
||
|
10: "New Age",
|
||
|
11: "Oldies",
|
||
|
12: "Other",
|
||
|
13: "Pop",
|
||
|
14: "R&B",
|
||
|
15: "Rap",
|
||
|
16: "Reggae",
|
||
|
17: "Rock",
|
||
|
18: "Techno",
|
||
|
19: "Industrial",
|
||
|
20: "Alternative",
|
||
|
21: "Ska",
|
||
|
22: "Death Metal",
|
||
|
23: "Pranks",
|
||
|
24: "Soundtrack",
|
||
|
25: "Euro-Techno",
|
||
|
26: "Ambient",
|
||
|
27: "Trip-Hop",
|
||
|
28: "Vocal",
|
||
|
29: "Jazz+Funk",
|
||
|
30: "Fusion",
|
||
|
31: "Trance",
|
||
|
32: "Classical",
|
||
|
33: "Instrumental",
|
||
|
34: "Acid",
|
||
|
35: "House",
|
||
|
36: "Game",
|
||
|
37: "Sound Clip",
|
||
|
38: "Gospel",
|
||
|
39: "Noise",
|
||
|
40: "AlternRock",
|
||
|
41: "Bass",
|
||
|
42: "Soul",
|
||
|
43: "Punk",
|
||
|
44: "Space",
|
||
|
45: "Meditative",
|
||
|
46: "Instrumental Pop",
|
||
|
47: "Instrumental Rock",
|
||
|
48: "Ethnic",
|
||
|
49: "Gothic",
|
||
|
50: "Darkwave",
|
||
|
51: "Techno-Industrial",
|
||
|
52: "Electronic",
|
||
|
53: "Pop-Folk",
|
||
|
54: "Eurodance",
|
||
|
55: "Dream",
|
||
|
56: "Southern Rock",
|
||
|
57: "Comedy",
|
||
|
58: "Cult",
|
||
|
59: "Gangsta",
|
||
|
60: "Top 40",
|
||
|
61: "Christian Rap",
|
||
|
62: "Pop/Funk",
|
||
|
63: "Jungle",
|
||
|
64: "Native American",
|
||
|
65: "Cabaret",
|
||
|
66: "New Wave",
|
||
|
67: "Psychadelic",
|
||
|
68: "Rave",
|
||
|
69: "Showtunes",
|
||
|
70: "Trailer",
|
||
|
71: "Lo-Fi",
|
||
|
72: "Tribal",
|
||
|
73: "Acid Punk",
|
||
|
74: "Acid Jazz",
|
||
|
75: "Polka",
|
||
|
76: "Retro",
|
||
|
77: "Musical",
|
||
|
78: "Rock & Roll",
|
||
|
79: "Hard Rock",
|
||
|
# Following are winamp extentions
|
||
|
80: "Folk",
|
||
|
81: "Folk-Rock",
|
||
|
82: "National Folk",
|
||
|
83: "Swing",
|
||
|
84: "Fast Fusion",
|
||
|
85: "Bebob",
|
||
|
86: "Latin",
|
||
|
87: "Revival",
|
||
|
88: "Celtic",
|
||
|
89: "Bluegrass",
|
||
|
90: "Avantgarde",
|
||
|
91: "Gothic Rock",
|
||
|
92: "Progressive Rock",
|
||
|
93: "Psychedelic Rock",
|
||
|
94: "Symphonic Rock",
|
||
|
95: "Slow Rock",
|
||
|
96: "Big Band",
|
||
|
97: "Chorus",
|
||
|
98: "Easy Listening",
|
||
|
99: "Acoustic",
|
||
|
100: "Humour",
|
||
|
101: "Speech",
|
||
|
102: "Chanson",
|
||
|
103: "Opera",
|
||
|
104: "Chamber Music",
|
||
|
105: "Sonata",
|
||
|
106: "Symphony",
|
||
|
107: "Booty Bass",
|
||
|
108: "Primus",
|
||
|
109: "Porn Groove",
|
||
|
110: "Satire",
|
||
|
111: "Slow Jam",
|
||
|
112: "Club",
|
||
|
113: "Tango",
|
||
|
114: "Samba",
|
||
|
115: "Folklore",
|
||
|
116: "Ballad",
|
||
|
117: "Power Ballad",
|
||
|
118: "Rhythmic Soul",
|
||
|
119: "Freestyle",
|
||
|
120: "Duet",
|
||
|
121: "Punk Rock",
|
||
|
122: "Drum Solo",
|
||
|
123: "A capella",
|
||
|
124: "Euro-House",
|
||
|
125: "Dance Hall",
|
||
|
126: "Goa",
|
||
|
127: "Drum & Bass",
|
||
|
128: "Club-House",
|
||
|
129: "Hardcore",
|
||
|
130: "Terror",
|
||
|
131: "Indie",
|
||
|
132: "Britpop",
|
||
|
133: "Negerpunk",
|
||
|
134: "Polsk Punk",
|
||
|
135: "Beat",
|
||
|
136: "Christian Gangsta Rap",
|
||
|
137: "Heavy Metal",
|
||
|
138: "Black Metal",
|
||
|
139: "Crossover",
|
||
|
140: "Contemporary Christian",
|
||
|
141: "Christian Rock ",
|
||
|
142: "Merengue",
|
||
|
143: "Salsa",
|
||
|
144: "Trash Metal",
|
||
|
145: "Anime",
|
||
|
146: "JPop",
|
||
|
147: "Synthpop"
|
||
|
}
|
||
|
|
||
|
def createFields(self):
|
||
|
yield String(self, "signature", 3, "IDv1 signature (\"TAG\")", charset="ASCII")
|
||
|
if self["signature"].value != "TAG":
|
||
|
raise MatchError(
|
||
|
"Stream doesn't look like ID3v1 (wrong signature)!")
|
||
|
# TODO: Charset of below strings?
|
||
|
yield String(self, "song", 30, "Song title", strip=" \0", charset="ISO-8859-1")
|
||
|
yield String(self, "author", 30, "Author", strip=" \0", charset="ISO-8859-1")
|
||
|
yield String(self, "album", 30, "Album title", strip=" \0", charset="ISO-8859-1")
|
||
|
yield String(self, "year", 4, "Year", strip=" \0", charset="ISO-8859-1")
|
||
|
|
||
|
# TODO: Write better algorithm to guess ID3v1 version
|
||
|
version = self.getVersion()
|
||
|
if version in ("v1.1", "v1.1b"):
|
||
|
if version == "v1.1b":
|
||
|
# ID3 v1.1b
|
||
|
yield String(self, "comment", 29, "Comment", strip=" \0", charset="ISO-8859-1")
|
||
|
yield UInt8(self, "track_nb", "Track number")
|
||
|
else:
|
||
|
# ID3 v1.1
|
||
|
yield String(self, "comment", 30, "Comment", strip=" \0", charset="ISO-8859-1")
|
||
|
yield Enum(UInt8(self, "genre", "Genre"), self.GENRE_NAME)
|
||
|
else:
|
||
|
# ID3 v1.0
|
||
|
yield String(self, "comment", 31, "Comment", strip=" \0", charset="ISO-8859-1")
|
||
|
|
||
|
def getVersion(self):
|
||
|
addr = self.absolute_address + 126 * 8
|
||
|
bytes = self.stream.readBytes(addr, 2)
|
||
|
|
||
|
# last byte (127) is not space?
|
||
|
if bytes[1] != 32:
|
||
|
# byte 126 is nul?
|
||
|
if bytes[0] == 0:
|
||
|
return "v1.1"
|
||
|
else:
|
||
|
return "v1.1b"
|
||
|
else:
|
||
|
return "1.0"
|
||
|
|
||
|
def createDescription(self):
|
||
|
version = self.getVersion()
|
||
|
return "ID3 %s: author=%s, song=%s" % (
|
||
|
version, self["author"].value, self["song"].value)
|
||
|
|
||
|
|
||
|
def getCharset(field):
|
||
|
try:
|
||
|
key = field.value
|
||
|
return ID3_StringCharset.charset_name[key]
|
||
|
except KeyError:
|
||
|
raise ParserError("ID3v2: Invalid charset (%s)." % key)
|
||
|
|
||
|
|
||
|
class ID3_String(FieldSet):
|
||
|
STRIP = " \0"
|
||
|
|
||
|
def createFields(self):
|
||
|
yield String(self, "text", self._size // 8, "Text", charset="ISO-8859-1", strip=self.STRIP)
|
||
|
|
||
|
|
||
|
class ID3_StringCharset(ID3_String):
|
||
|
STRIP = " \0"
|
||
|
charset_desc = {
|
||
|
0: "ISO-8859-1",
|
||
|
1: "UTF-16 with BOM",
|
||
|
2: "UTF-16 (big endian)",
|
||
|
3: "UTF-8"
|
||
|
}
|
||
|
charset_name = {
|
||
|
0: "ISO-8859-1",
|
||
|
1: "UTF-16",
|
||
|
2: "UTF-16-BE",
|
||
|
3: "UTF-8"
|
||
|
}
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), self.charset_desc)
|
||
|
size = (self.size - self.current_size) // 8
|
||
|
if not size:
|
||
|
return
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield String(self, "text", size, "Text", charset=charset, strip=self.STRIP)
|
||
|
|
||
|
|
||
|
class ID3_GEOB(ID3_StringCharset):
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), self.charset_desc)
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield CString(self, "mime", "MIME type", charset=charset)
|
||
|
yield CString(self, "filename", "File name", charset=charset)
|
||
|
yield CString(self, "description", "Content description", charset=charset)
|
||
|
size = (self.size - self.current_size) // 8
|
||
|
if not size:
|
||
|
return
|
||
|
yield String(self, "text", size, "Text", charset=charset)
|
||
|
|
||
|
|
||
|
class ID3_Comment(ID3_StringCharset):
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), self.charset_desc)
|
||
|
yield String(self, "lang", 3, "Language", charset="ASCII")
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield CString(self, "title", "Title", charset=charset, strip=self.STRIP)
|
||
|
size = (self.size - self.current_size) // 8
|
||
|
if not size:
|
||
|
return
|
||
|
yield String(self, "text", size, "Text", charset=charset, strip=self.STRIP)
|
||
|
|
||
|
|
||
|
class ID3_StringTitle(ID3_StringCharset):
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), self.charset_desc)
|
||
|
if self.current_size == self.size:
|
||
|
return
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield CString(self, "title", "Title", charset=charset, strip=self.STRIP)
|
||
|
size = (self.size - self.current_size) // 8
|
||
|
if not size:
|
||
|
return
|
||
|
yield String(self, "text", size, "Text", charset=charset, strip=self.STRIP)
|
||
|
|
||
|
|
||
|
class ID3_Private(FieldSet):
|
||
|
|
||
|
def createFields(self):
|
||
|
size = self._size // 8
|
||
|
# TODO: Strings charset?
|
||
|
if self.stream.readBytes(self.absolute_address, 9) == b"PeakValue":
|
||
|
yield String(self, "text", 9, "Text")
|
||
|
size -= 9
|
||
|
yield String(self, "content", size, "Content")
|
||
|
|
||
|
|
||
|
class ID3_TrackLength(FieldSet):
|
||
|
|
||
|
def createFields(self):
|
||
|
yield NullBytes(self, "zero", 1)
|
||
|
yield textHandler(String(self, "length", self._size // 8 - 1,
|
||
|
"Length in ms", charset="ASCII"), self.computeLength)
|
||
|
|
||
|
def computeLength(self, field):
|
||
|
try:
|
||
|
ms = int(field.value)
|
||
|
return humanDuration(ms)
|
||
|
except Exception:
|
||
|
return field.value
|
||
|
|
||
|
|
||
|
class ID3_Picture23(FieldSet):
|
||
|
pict_type_name = {
|
||
|
0x00: "Other",
|
||
|
0x01: "32x32 pixels 'file icon' (PNG only)",
|
||
|
0x02: "Other file icon",
|
||
|
0x03: "Cover (front)",
|
||
|
0x04: "Cover (back)",
|
||
|
0x05: "Leaflet page",
|
||
|
0x06: "Media (e.g. lable side of CD)",
|
||
|
0x07: "Lead artist/lead performer/soloist",
|
||
|
0x08: "Artist/performer",
|
||
|
0x09: "Conductor",
|
||
|
0x0A: "Band/Orchestra",
|
||
|
0x0B: "Composer",
|
||
|
0x0C: "Lyricist/text writer",
|
||
|
0x0D: "Recording Location",
|
||
|
0x0E: "During recording",
|
||
|
0x0F: "During performance",
|
||
|
0x10: "Movie/video screen capture",
|
||
|
0x11: "A bright coloured fish",
|
||
|
0x12: "Illustration",
|
||
|
0x13: "Band/artist logotype",
|
||
|
0x14: "Publisher/Studio logotype"
|
||
|
}
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), ID3_StringCharset.charset_desc)
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield String(self, "img_fmt", 3, charset="ASCII")
|
||
|
yield Enum(UInt8(self, "pict_type"), self.pict_type_name)
|
||
|
yield CString(self, "text", "Text", charset=charset, strip=" \0")
|
||
|
size = (self._size - self._current_size) // 8
|
||
|
if size:
|
||
|
yield RawBytes(self, "img_data", size)
|
||
|
|
||
|
|
||
|
class ID3_Picture24(FieldSet):
|
||
|
|
||
|
def createFields(self):
|
||
|
yield Enum(UInt8(self, "charset"), ID3_StringCharset.charset_desc)
|
||
|
charset = getCharset(self["charset"])
|
||
|
yield CString(self, "mime", "MIME type", charset=charset)
|
||
|
yield Enum(UInt8(self, "pict_type"), ID3_Picture23.pict_type_name)
|
||
|
yield CString(self, "description", charset=charset)
|
||
|
size = (self._size - self._current_size) // 8
|
||
|
if size:
|
||
|
yield RawBytes(self, "img_data", size)
|
||
|
|
||
|
|
||
|
class ID3_Chunk(FieldSet):
|
||
|
endian = NETWORK_ENDIAN
|
||
|
tag22_name = {
|
||
|
"TT2": "Track title",
|
||
|
"TP1": "Artist",
|
||
|
"TRK": "Track number",
|
||
|
"COM": "Comment",
|
||
|
"TCM": "Composer",
|
||
|
"TAL": "Album",
|
||
|
"TYE": "Year",
|
||
|
"TEN": "Encoder",
|
||
|
"TCO": "Content type",
|
||
|
"PIC": "Picture"
|
||
|
}
|
||
|
tag23_name = {
|
||
|
"COMM": "Comment",
|
||
|
"GEOB": "Encapsulated object",
|
||
|
"PRIV": "Private",
|
||
|
"TPE1": "Artist",
|
||
|
"TCOP": "Copyright",
|
||
|
"TALB": "Album",
|
||
|
"TENC": "Encoder",
|
||
|
"TYER": "Year",
|
||
|
"TSSE": "Encoder settings",
|
||
|
"TCOM": "Composer",
|
||
|
"TRCK": "Track number",
|
||
|
"PCNT": "Play counter",
|
||
|
"TCON": "Content type",
|
||
|
"TLEN": "Track length",
|
||
|
"TIT2": "Track title",
|
||
|
"WXXX": "User defined URL"
|
||
|
}
|
||
|
handler = {
|
||
|
"COMM": ID3_Comment,
|
||
|
"COM": ID3_Comment,
|
||
|
"GEOB": ID3_GEOB,
|
||
|
"PIC": ID3_Picture23,
|
||
|
"APIC": ID3_Picture24,
|
||
|
"PRIV": ID3_Private,
|
||
|
"TXXX": ID3_StringTitle,
|
||
|
"WOAR": ID3_String,
|
||
|
"WXXX": ID3_StringTitle,
|
||
|
}
|
||
|
|
||
|
def __init__(self, *args):
|
||
|
FieldSet.__init__(self, *args)
|
||
|
if 3 <= self["../ver_major"].value:
|
||
|
self._size = (10 + self["size"].value) * 8
|
||
|
else:
|
||
|
self._size = (self["size"].value + 6) * 8
|
||
|
|
||
|
def createFields(self):
|
||
|
if 3 <= self["../ver_major"].value:
|
||
|
# ID3 v2.3 and 2.4
|
||
|
yield Enum(String(self, "tag", 4, "Tag", charset="ASCII", strip="\0"), ID3_Chunk.tag23_name)
|
||
|
if 4 <= self["../ver_major"].value:
|
||
|
yield ID3_Size(self, "size") # ID3 v2.4
|
||
|
else:
|
||
|
yield UInt32(self, "size") # ID3 v2.3
|
||
|
|
||
|
yield Bit(self, "tag_alter", "Tag alter preservation")
|
||
|
yield Bit(self, "file_alter", "Tag alter preservation")
|
||
|
yield Bit(self, "rd_only", "Read only?")
|
||
|
yield NullBits(self, "padding[]", 5)
|
||
|
|
||
|
yield Bit(self, "compressed", "Frame is compressed?")
|
||
|
yield Bit(self, "encrypted", "Frame is encrypted?")
|
||
|
yield Bit(self, "group", "Grouping identity")
|
||
|
yield NullBits(self, "padding[]", 5)
|
||
|
size = self["size"].value
|
||
|
is_compressed = self["compressed"].value
|
||
|
else:
|
||
|
# ID3 v2.2
|
||
|
yield Enum(String(self, "tag", 3, "Tag", charset="ASCII", strip="\0"), ID3_Chunk.tag22_name)
|
||
|
yield UInt24(self, "size")
|
||
|
size = self["size"].value - self.current_size // 8 + 6
|
||
|
is_compressed = False
|
||
|
|
||
|
if size:
|
||
|
cls = None
|
||
|
if not(is_compressed):
|
||
|
tag = self["tag"].value
|
||
|
if tag in ID3_Chunk.handler:
|
||
|
cls = ID3_Chunk.handler[tag]
|
||
|
elif tag[0] == "T":
|
||
|
cls = ID3_StringCharset
|
||
|
if cls:
|
||
|
yield cls(self, "content", "Content", size=size * 8)
|
||
|
else:
|
||
|
yield RawBytes(self, "content", size, "Raw data content")
|
||
|
|
||
|
def createDescription(self):
|
||
|
if self["size"].value != 0:
|
||
|
return "ID3 Chunk: %s" % self["tag"].display
|
||
|
else:
|
||
|
return "ID3 Chunk: (terminator)"
|
||
|
|
||
|
|
||
|
class ID3_Size(Bits):
|
||
|
static_size = 32
|
||
|
|
||
|
def __init__(self, parent, name, description=None):
|
||
|
Bits.__init__(self, parent, name, 32, description)
|
||
|
|
||
|
def createValue(self):
|
||
|
data = self.parent.stream.readBytes(self.absolute_address, 4)
|
||
|
# TODO: Check that bit #7 of each byte is nul: not(ord(data[i]) & 127)
|
||
|
return reduce(lambda x, y: x * 128 + y, (item for item in data))
|
||
|
|
||
|
|
||
|
class ID3v2(FieldSet):
|
||
|
endian = NETWORK_ENDIAN
|
||
|
VALID_MAJOR_VERSIONS = (2, 3, 4)
|
||
|
|
||
|
def __init__(self, parent, name, size=None):
|
||
|
FieldSet.__init__(self, parent, name, size=size)
|
||
|
if not self._size:
|
||
|
self._size = (self["size"].value + 10) * 8
|
||
|
|
||
|
def createDescription(self):
|
||
|
return "ID3 v2.%s.%s" % \
|
||
|
(self["ver_major"].value, self["ver_minor"].value)
|
||
|
|
||
|
def createFields(self):
|
||
|
# Signature + version
|
||
|
yield String(self, "header", 3, "Header (ID3)", charset="ASCII")
|
||
|
yield UInt8(self, "ver_major", "Version (major)")
|
||
|
yield UInt8(self, "ver_minor", "Version (minor)")
|
||
|
|
||
|
# Check format
|
||
|
if self["header"].value != "ID3":
|
||
|
raise MatchError("Signature error, should be \"ID3\".")
|
||
|
if self["ver_major"].value not in self.VALID_MAJOR_VERSIONS \
|
||
|
or self["ver_minor"].value != 0:
|
||
|
raise MatchError(
|
||
|
"Unknown ID3 metadata version (2.%u.%u)"
|
||
|
% (self["ver_major"].value, self["ver_minor"].value))
|
||
|
|
||
|
# Flags
|
||
|
yield Bit(self, "unsync", "Unsynchronisation is used?")
|
||
|
yield Bit(self, "ext", "Extended header is used?")
|
||
|
yield Bit(self, "exp", "Experimental indicator")
|
||
|
yield NullBits(self, "padding[]", 5)
|
||
|
|
||
|
# Size
|
||
|
yield ID3_Size(self, "size")
|
||
|
|
||
|
# All tags
|
||
|
while self.current_size < self._size:
|
||
|
field = ID3_Chunk(self, "field[]")
|
||
|
yield field
|
||
|
if field["size"].value == 0:
|
||
|
break
|
||
|
|
||
|
# Search first byte of the MPEG file
|
||
|
padding = self.seekBit(self._size)
|
||
|
if padding:
|
||
|
yield padding
|