From ee7bb4b9e8932cc186e46e7846a2d0006535c0c5 Mon Sep 17 00:00:00 2001 From: "adrian.sampson" Date: Wed, 14 May 2008 01:42:56 +0000 Subject: [PATCH] initial import --HG-- extra : convert_revision : svn%3A41726ec3-264d-0410-9c23-a9f1637257cc/trunk%402 --- beets/__init__.py | 2 + beets/library.py | 167 ++++++++++++++++++++ beets/tag.py | 360 ++++++++++++++++++++++++++++++++++++++++++ bts.py | 42 +++++ test/rsrc/full.m4a | Bin 0 -> 5862 bytes test/rsrc/full.mp3 | Bin 0 -> 12820 bytes test/rsrc/min.m4a | Bin 0 -> 5862 bytes test/rsrc/min.mp3 | Bin 0 -> 12820 bytes test/rsrc/partial.m4a | Bin 0 -> 5862 bytes test/rsrc/partial.mp3 | Bin 0 -> 12820 bytes test/tagtest.py | 135 ++++++++++++++++ 11 files changed, 706 insertions(+) create mode 100644 beets/__init__.py create mode 100644 beets/library.py create mode 100644 beets/tag.py create mode 100755 bts.py create mode 100755 test/rsrc/full.m4a create mode 100755 test/rsrc/full.mp3 create mode 100755 test/rsrc/min.m4a create mode 100755 test/rsrc/min.mp3 create mode 100755 test/rsrc/partial.m4a create mode 100755 test/rsrc/partial.mp3 create mode 100755 test/tagtest.py diff --git a/beets/__init__.py b/beets/__init__.py new file mode 100644 index 000000000..b7227d752 --- /dev/null +++ b/beets/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +from beets.library import Library \ No newline at end of file diff --git a/beets/library.py b/beets/library.py new file mode 100644 index 000000000..386dff15b --- /dev/null +++ b/beets/library.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +import sqlite3, os, sys, operator +import beets.tag +from string import Template + +# Fields in the "items" table; all the metadata available for items in the +# library. These are used directly in SQL; they are vulnerable to injection if +# accessible to the user. +metadata_fields = [ + ('title', 'text'), + ('artist', 'text'), + ('album', 'text'), + ('genre', 'text'), + ('composer', 'text'), + ('grouping', 'text'), + ('year', 'int'), + ('track', 'int'), + ('maxtrack', 'int'), + ('disc', 'int'), + ('maxdisc', 'int'), + ('lyrics', 'text'), + ('comments', 'text'), + ('bpm', 'int'), + ('comp', 'bool') +] + +class Library(object): + def __init__(self, path='library.blb'): + self.path = path + self.conn = sqlite3.connect(self.path) + self.conn.row_factory = sqlite3.Row + self.setup() + + def setup(self): + "Set up the schema of the library file." + + # options (library data) table + setup_sql = """ + create table if not exists options ( + key text primary key, + value text + );""" + + # items (things in the library) table + setup_sql += """create table if not exists items ( + path text primary key, """ + setup_sql += ', '.join(map(' '.join, metadata_fields)) + setup_sql += ' );' + + c = self.conn.cursor() + c.executescript(setup_sql) + c.close() + self.conn.commit() + + # DATABASE UTILITY FUNCTIONS + def select(self, where='', subvars=[], columns='*'): + "Look up items in the library. Returns a cursor." + c = self.conn.cursor() + if where.strip(): # we have a where clause + where = ' where ' + where + c.execute('select ' + columns + ' from items' + where, + subvars) + return c + def selects_any(self, where, subvars): + "Returns True iff the SELECT query matches any rows." + c = self.select(where, subvars) + out = (c.fetchone() is not None) + c.close() + + # FILE/DB UTILITY FUNCTIONS + def add_file(self, path): + "Adds a new file to the library." + + # build query part for metadata fields + columns = ','.join(map(operator.itemgetter(0),metadata_fields)) + values = ','.join(['?']*len(metadata_fields)) + subvars = [] + f = beets.tag.MediaFile(path) + for field, value in metadata_fields: + subvars.append(getattr(f, field)) + + # other fields + columns += ',path' + values += ',?' + subvars.append(path) + + # issue query + c = self.conn.cursor() + query = 'insert into items (' + columns + ') values (' + values + ')' + c.execute(query, subvars) + c.close() + + def update_file(self, path): + "Updates a file already in the database with the file's metadata." + + # build query part for metadata fields + assignments = ','.join(['?=?']*len(metadata_fields)) + subvars = [] + f = beets.tag.MediaFile(path) + for field, value in metadata_fields: + subvars += [field, getattr(f, field)] + + # build the full query itself + query = 'update items set ' + assignments + ' where path=?' + subvars.append(path) + + c = self.conn.cursor() + c.execute(query, subvars) + c.close() + + # MISC. UTILITY FUNCTIONS + def mynormpath(self, path): + """Provide the canonical form of the path suitable for storing in the + database. In the future, options may modify the behavior of this + method.""" + # force absolute paths: + # os.path.normpath(os.path.abspath(os.path.expanduser(path))) + return os.path.normpath(os.path.expanduser(path)) + def log(self, msg): + """Print a log message.""" + print >>sys.stderr, msg + def pprint(self, item, form='$artist - $title'): + print Template(form).substitute(item) + + def add_path(self, path, clobber=False): + """Add a file to the library or recursively search a directory and add + all its contents.""" + if os.path.isdir(path): + # recurse into all directory contents + for ent in os.listdir(path): + self.add_path(path + os.sep + ent, clobber) + elif os.path.isfile(path): + # add _if_ it's legible (otherwise ignore but say so) + if self.selects_any('path=?', (self.mynormpath(path),)): + if not clobber: + self.log(path + ' already in database, skipping') + return + else: + self.update_file(self.mynormpath(path)) + else: + try: + self.add_file(self.mynormpath(path)) + except beets.tag.FileTypeError: + self.log(path + ' of unknown type, skipping') + elif not os.path.exists(path): + raise IOError('file not found: ' + path) + + # high-level (and command-line) interface + def add(self, *paths): + for path in paths: + self.add_path(path, clobber=False) + self.conn.commit() + def remove(self, *criteria): + raise NotImplementedError + def update(self, *criteria): + #c = self.select(criteria, [], 'path') + #for f in c: + # self.update_file(f.path) + #c.close() + raise NotImplementedError + def write(self, *criteria): + raise NotImplementedError + def list(self, *criteria): + c = self.select(' '.join(criteria), []) + for row in c: + self.pprint(row) + c.close() \ No newline at end of file diff --git a/beets/tag.py b/beets/tag.py new file mode 100644 index 000000000..624fcb7c6 --- /dev/null +++ b/beets/tag.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +"""Handles low-level interfacing for files' tags. Wraps mutagen to +automatically detect file types and provide a unified interface for the +specific tags Beets is interested in.""" + +from mutagen import mp4, mp3, id3 +import os.path + +__all__ = ['FileTypeError', 'MediaFile'] + +# Currently allowed values for type: +# mp3, mp4 +class FileTypeError(IOError): + pass + + + + + + +########################### +#### UTILITY FUNCTIONS #### +########################### +# For dealing with the sort of weirdnesses we find in metadata fields. + +def fromslashed(slashed, sep=u'/'): + """Extract a pair of items from a slashed string. If only one + value is present, it is assumed to be the left-hand value.""" + + if slashed is None: + return (None, None) + + items = slashed.split(sep) + + if len(items) == 1: + out = (items[0], None) + else: + out = (items[0], items[1]) + + # represent "nothing stored" more gracefully + if out[0] == '': out[0] = None + if out[1] == '': out[1] = None + + return out + +def toslashed(pair_or_val, sep=u'/'): + """Store a pair of items or a single item in a slashed string. If + only one value is provided (in a list/tuple or as a single value), + no slash is used.""" + if type(pair_or_val) is list or type(pair_or_val) is tuple: + if len(pair_or_val) == 0: + out = [u''] + elif len(pair_or_val) == 1: + out = [unicode(pair_or_val[0])] + else: + out = [unicode(pair_or_val[0]), unicode(pair_or_val[1])] + else: # "scalar" + out = [unicode(pair_or_val)] + return sep.join(out) + +def unpair(pair, right=False, noneval=None): + """Return the left or right value in a pair (as selected by the "right" + parameter. If the value on that side is not available, return noneval.)""" + if right: idx = 1 + else: idx = 0 + + try: + out = pair[idx] + except: + out = None + finally: + if out is None: + return noneval + else: + return out + +def normalize_pair(pair, noneval=None): + """Make sure the pair is a tuple that has exactly two entries. If we need + to fill anything in, we'll use noneval.""" + return (unpair(pair, False, noneval), + unpair(pair, True, noneval)) + + + + + +############################### +#### MediaField descriptor #### +############################### + +class MediaField(object): + """A descriptor providing access to a particular (abstract) metadata + field. The various messy parameters control the translation to concrete + metadata manipulations in the language of mutagen.""" + + # possible types used to store the relevant data + TYPE_RAW = 0 # stored as a single object (not in a list) + TYPE_LIST = 1 << 0 # stored in the first element of a list + TYPE_UNICODE = 1 << 1 # stored as a unicode object + TYPE_INTEGER = 1 << 2 # as an int + TYPE_BOOLEAN = 1 << 3 # as a bool + # RAW and LIST are mutually exclusive, as are UNICODE, INTEGER and + # BOOLEAN. Must pick either RAW or LIST, but none of the other types + # are necessary. + + # non-type aspects of data storage + STYLE_PLAIN = 0 # no filtering + STYLE_UNICODE = 1 << 0 # value is a string, stored as a string + STYLE_INTEGER = 1 << 1 # value is an integer, maybe stored as a string + STYLE_BOOLEAN = 1 << 2 # value is a boolean, probably stored as a string + STYLE_SLASHED = 1 << 3 # int stored in a string on one side of a / char + STYLE_2PLE = 1 << 4 # stored as one value in an integer 2-tuple + # The above styles are all mutually exclusive. + STYLE_LEFT = 1 << 5 # for SLASHED or 2PLE, value is in first entry + STYLE_RIGHT = 1 << 6 # likewise, in second entry + # These are mutually exclusive and relevant only with SLASHED and 2PLE. + + def __init__(self, id3key, mp4key, + # in ID3 tags, use only the frame with this "desc" field + id3desc=None, + # compositions of the TYPE_ flag above + id3type=TYPE_UNICODE|TYPE_LIST, mp4type=TYPE_UNICODE|TYPE_LIST, + # compositions of STYLE_ flags + id3style=STYLE_UNICODE, mp4style=STYLE_UNICODE + ): + + self.keys = { 'mp3': id3key, + 'mp4': mp4key } + self.types = { 'mp3': id3type, + 'mp4': mp4type } + self.styles = { 'mp3': id3style, + 'mp4': mp4style } + self.id3desc = id3desc + + def __fetchdata(self, obj): + """Get the value associated with this descriptor's key (and id3desc if + present) from the mutagen tag dict. Unwraps from a list if necessary.""" + (mykey, mytype, mystyle) = self.__params(obj) + + try: + # fetch the value, which may be a scalar or a list + if obj.type == 'mp3': + if self.id3desc is not None: # also match on 'desc' field + frames = obj.tags.tags.getall(mykey) + entry = None + for frame in frames: + if frame.desc == self.id3desc: + entry = frame.text + break + if entry is None: # no desc match + return None + else: + entry = obj.tags[mykey].text + else: + entry = obj.tags[mykey] + + # possibly index the list + if mytype & self.TYPE_LIST: + return entry[0] + else: + return entry + except KeyError: # the tag isn't present + return None + + def __storedata(self, obj, val): + """Store val for this descriptor's key in the tag dictionary. Store it + as a single-item list if necessary. Uses id3desc if present.""" + (mykey, mytype, mystyle) = self.__params(obj) + + # wrap as a list if necessary + if mytype & self.TYPE_LIST: out = [val] + else: out = val + + if obj.type == 'mp3': + if self.id3desc is not None: # match on id3desc + frames = obj.tags.tags.getall(mykey) + + # try modifying in place + found = False + for frame in frames: + if frame.desc == self.id3desc: + frame.text = out + found = True + break + + # need to make a new frame? + if not found: + frame = id3.Frames[mykey](encoding=3, desc=self.id3desc, + text=val) + obj.tags.tags.add(frame) + + else: # no match on desc; just replace based on key + frame = id3.Frames[mykey](encoding=3, text=val) + obj.tags.tags.setall(mykey, [frame]) + else: + obj.tags[mykey] = out + + def __params(self, obj): + return (self.keys[obj.type], + self.types[obj.type], + self.styles[obj.type]) + + def __get__(self, obj, owner): + """Retrieve the value of this metadata field.""" + out = None + (mykey, mytype, mystyle) = self.__params(obj) + + out = self.__fetchdata(obj) + + # deal with slashed and tuple storage + if mystyle & self.STYLE_SLASHED or mystyle & self.STYLE_2PLE: + if mystyle & self.STYLE_SLASHED: + out = fromslashed(out) + out = unpair(out, mystyle & self.STYLE_RIGHT, noneval=0) + + # return the appropriate type + if mystyle & self.STYLE_INTEGER or mystyle & self.STYLE_SLASHED \ + or mystyle & self.STYLE_2PLE: + if out is None: + return 0 + else: + try: + return int(out) + except: # in case out is not convertible directly to an int + return int(unicode(out)) + elif mystyle & self.STYLE_BOOLEAN: + if out is None: + return False + else: + return bool(int(out)) # should work for strings, bools, ints + elif mystyle & self.STYLE_UNICODE: + if out is None: + return u'' + else: + return unicode(out) + else: + return out + + def __set__(self, obj, val): + """Set the value of this metadata field.""" + (mykey, mytype, mystyle) = self.__params(obj) + + # apply style filters + if mystyle & self.STYLE_SLASHED or mystyle & self.STYLE_2PLE: + # fetch the existing value so we can preserve half of it + pair = self.__fetchdata(obj) + if mystyle & self.STYLE_SLASHED: + pair = fromslashed(pair) + pair = normalize_pair(pair, noneval=0) + + # set the appropriate side of the pair + if mystyle & self.STYLE_LEFT: + pair = (val, pair[1]) + else: + pair = (pair[0], val) + + if mystyle & self.STYLE_SLASHED: + out = toslashed(pair) + else: + out = pair + else: # plain, integer, or boolean + out = val + + # deal with Nones according to abstract type if present + if out is None: + if mystyle & self.STYLE_INTEGER: + out = 0 + elif mystyle & self.STYLE_BOOLEAN: + out = False + elif mystyle & self.STYLE_UNICODE: + out = u'' + # We trust that SLASHED and 2PLE are handled above. + + # convert to correct storage type + if mytype & self.TYPE_UNICODE: + if out is None: + out = u'' + else: + if mystyle & self.STYLE_BOOLEAN: + # store bools as 1,0 instead of True,False + out = unicode(int(out)) + else: + out = unicode(out) + elif mytype & self.TYPE_INTEGER: + if out is None: + out = 0 + else: + out = int(out) + elif mytype & self.TYPE_BOOLEAN: + out = bool(out) + + # store the data + self.__storedata(obj, out) + + + + + +######################### +#### MediaFile class #### +######################### + +class MediaFile(object): + """Represents a multimedia file on disk and provides access to its + metadata.""" + + def __init__(self, path): + root, ext = os.path.splitext(path) + if ext == '.mp3': + self.type = 'mp3' + self.tags = mp3.Open(path) + elif ext == '.m4a' or ext == '.mp4' or ext == '.m4b' or ext == '.m4p': + self.type = 'mp4' + self.tags = mp4.Open(path) + else: + raise FileTypeError('unsupported file extension: ' + ext) + + def save_tags(self): + self.tags.save() + + ########################### + #### FIELD DEFINITIONS #### + ########################### + + title = MediaField('TIT2', "\xa9nam") + artist = MediaField('TPE1', "\xa9ART") + album = MediaField('TALB', "\xa9alb") + genre = MediaField('TCON', "\xa9gen") + composer = MediaField('TCOM', "\xa9wrt") + grouping = MediaField('TIT1', "\xa9grp") + year = MediaField('TDRC', "\xa9day", + id3style=MediaField.STYLE_INTEGER, + mp4style=MediaField.STYLE_INTEGER) + track = MediaField('TRCK', 'trkn', + id3style=MediaField.STYLE_SLASHED | MediaField.STYLE_LEFT, + mp4type=MediaField.TYPE_LIST, + mp4style=MediaField.STYLE_2PLE | MediaField.STYLE_LEFT) + maxtrack = MediaField('TRCK', 'trkn', + id3style=MediaField.STYLE_SLASHED | MediaField.STYLE_RIGHT, + mp4type=MediaField.TYPE_LIST, + mp4style=MediaField.STYLE_2PLE | MediaField.STYLE_RIGHT) + disc = MediaField('TPOS', 'disk', + id3style=MediaField.STYLE_SLASHED | MediaField.STYLE_LEFT, + mp4type=MediaField.TYPE_LIST, + mp4style=MediaField.STYLE_2PLE | MediaField.STYLE_LEFT) + maxdisc = MediaField('TPOS', 'disk', + id3style=MediaField.STYLE_SLASHED | MediaField.STYLE_RIGHT, + mp4type=MediaField.TYPE_LIST, + mp4style=MediaField.STYLE_2PLE | MediaField.STYLE_RIGHT) + lyrics = MediaField(u"USLT", "\xa9lyr", id3desc=u'', + id3type=MediaField.TYPE_UNICODE) + comments = MediaField(u"COMM", "\xa9cmt", id3desc=u'') + bpm = MediaField('TBPM', 'tmpo', + id3style=MediaField.STYLE_INTEGER, + mp4type=MediaField.TYPE_LIST | MediaField.TYPE_INTEGER, + mp4style=MediaField.STYLE_INTEGER) + comp = MediaField('TCMP', 'cpil', + id3style=MediaField.STYLE_BOOLEAN, + mp4type=MediaField.TYPE_BOOLEAN, + mp4style=MediaField.STYLE_BOOLEAN) \ No newline at end of file diff --git a/bts.py b/bts.py new file mode 100755 index 000000000..9a581cfa2 --- /dev/null +++ b/bts.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +from optparse import OptionParser +from beets import Library + +if __name__ == "__main__": + # parse options + usage = """usage: %prog [options] command +command is one of: add, remove, update, write, list, help""" + op = OptionParser(usage=usage) + op.add_option('-l', '--library', dest='libpath', metavar='PATH', + default='library.blb', + help='work on the specified library file') + op.remove_option('--help') + opts, args = op.parse_args() + + # make sure we have a command + if len(args) < 1: + op.error('no command specified') + cmd = args.pop(0) + + lib = Library(opts.libpath) + + # make a "help" command + def help(*args): op.print_help() + + # choose which command to invoke + avail_commands = [ + (lib.add, ['add']), + (lib.remove, ['remove', 'rm']), + (lib.update, ['update', 'up']), + (lib.write, ['write', 'wr', 'w']), + (lib.list, ['list', 'ls']), + (help, ['help', 'h']) + ] + for test_command in avail_commands: + if cmd in test_command[1]: + (test_command[0])(*args) + op.exit() + + # no command matched + op.error('invalid command "' + cmd + '"') + \ No newline at end of file diff --git a/test/rsrc/full.m4a b/test/rsrc/full.m4a new file mode 100755 index 0000000000000000000000000000000000000000..e2a952f12c0afbf27dc817ff92a59b519ceff34e GIT binary patch literal 5862 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 KdK6;Rvi!f+Uqh<^ literal 0 HcmV?d00001 diff --git a/test/rsrc/full.mp3 b/test/rsrc/full.mp3 new file mode 100755 index 0000000000000000000000000000000000000000..06313f20eb4a8fcef88eb249cdd73a3b49fc5f80 GIT binary patch literal 12820 zcmeHsc|4Tu+xKOQvagM9WRIaTma=A>K|*HCSc9 zH2Bnw&ssu1fX8JoZ+z%wYg0pOKm`Es7%Kqa1>jpA!IEJokDWNC3NS=3{Y%v9EY1>; z)Ku9Ul7IiI{GTSOs_OsSBh7!iqh@IQA9wbw-Tap|8%t{=;6EPjO;k~TTQgMsPd(TV zLu2)SGp36AFVO!611bDp?f?V}{NDirLN0ko`b7kJy9EPyYZL%*G48kog?I;tKzCFD zKw$5VpKGYU!!? zS80Ya-}#bSKxjmnsB}+*#$4dV%@z21?M<=b+=NSp>Lw{tRQG0n#JV2u)lI##lFmaE zMBi;LG09S-U5euiBe@=cX7?NP=u;*c$m)_46sraW6d#*G~2SQfyrevz&zBow$ zfHA?J;5&VA3!d%1m&eBAewYi|Ck{C6H!Ht zy2&mc56}+BA-(6Ib?&pLvgjIx`PRY}>tye8I0!<~0wk^9{)p#Z_-rPl4v?E~;f7fa zU0;R1kxwbu*4A|+aZpcp3VZv=ypMAxs{fghW|kJ{5n7((qk3W1zj1=x{>!`FtahfA zihg69{@G_@_VUH*mYmj2jBj}O_|%P}<%x3%Cl)zPEemB)uh9K}mQ@u&v8EK7n z=v98zf}R-JrT!%4DwNrPM`QzvUm(3os)wfnSCExNEmtg3Q21P=OmTaZqu4WPUsR(k z7Kn+%vujz2BMg2LL!kUP41&XKyBi?%9h^J%0bHwkzZJ0hcQ*ISgz(*NhK#HY%uGJ~ z>($0-tHLkdAHi#)AM+i&%55?y7u_+(k3deqV{SL9oZ9TgX}7o{FZbFzs0SVT?ZmOE zuOX9Z{sJM^@^s~tnChZ*bm*ne!mbr)*83e+^$f)w=X2oBwLJ{#SO1Su2iHoEcjx=O zFg1(QmH#F@Xj}?z$3^A)Z7^g&aAy~sGanRtWgT-HMK0wyoxVCU)3&Nrtk!tW%!Hzo z!Z8wbJj|OpwmTJdpkOj7zF0Mp8(8X0Wnt)#v8{u<5eK2S1veudD?TW>qX(<*poqP0 zqQ3ak;aT98Ylk4f^+oQtf;h`pHD*u{lA5#mD&m`P4Nb2f|A0Z9tQzcKQDzrh5a-~5f!2x9aNH{~T+g%BR*esvb3GrSDji%hdS4U6Y&;EU}R zEbe9PRC3~}Myg+3-F!JJxq>+0scl=~FaKf|ZEB>W&(PVd#ZecOVzm;lla<~yD%HOx z49!zqt!-Gve(9i+>jw zv!+)j1R%G#sCo*AAk_wIY!8Zr+xk(PKa=ThsRjmphi+a`73ve4#rEllB2KYN76}zc z1AsmVuEzw!4uIQjPXy{_we>2NSHiSgTSdsWM#l&J=<`30QqIYK$ksRu$?U#9g0w^z z3+Eka+uwieUS!LCo9YZI-A*b_sL$m~YvDO5H5pN>?l@|k*g+Yo*rLyFW*WAJ&8rp8 zEKCW2^S=6+we`)Y-FDbs9ob6Ui$1Dl zBD~)uEf1gQqs1dVsNiPqDkIgGdX+e;*2sOR3Ihj08Q`AKI|v2g)x&-nbto0W1l@82 zEhIEQF}eIfu?{SXp{X8{Ro7&E;N3CahoN9A>;b4>oHmsar*R+Wcc0~om+4S))erBJ zt>U98PnXVwGbAR4rBPECvnR_nOYm`Mue=!@huB<`89D2vURG`~$Nm`{KgonE`sO~? z85<5YZLZFKtWnh;xtktUclbW+=*YU8`0Y%Rg4yTeaU z^VI(SjKC^9Px-UHGxAYzKo_%;Sc0Uv;D`5`hhfFt+cA~f&sM!~p2KfuVG1D2esx1_ z@07PnIYer^Z^+Q!AzS6Ff9YR)2#bUBugackI1b{9Uo71u;lr+F^~f4#a|+^wd}-`_ z-fRg(sbV(v`j?cLF@@hNf{0lGe=V>SE$eo5b|@*PUIGA>4nQMSdhgXh_hpzAi~t^6 zLpT#Ud-{r@^3Za}q+a{khf@&;>9vYesY^Apt=QEDrIT)+c*udp@$+l1?25^kf`oJp z37WU`YFe;gyrff*wJBPntQb~%Om`U$ofVj@&>ffRoK=)dmBgdW=Ol57jsbtg2vz(^ z!~;z5n>4V24_843_Z9N0>Woh-`rD~xr)5=_-pze?uascdR2IS<=xg8PV=xGZ`ZvbeD|pP9?1Tj3L0v?nWo z#Tn>edYsHlj{@6am{F|!1&YT`l3imi1?@na8V<9mf>XgPn)@D9A0TQR=$HP^M{cHs z;8e5E)TM0D+M*!6U3vz-sNt=-xu^GFj~%t*o4ec*X`z>@h**v)%^B1q9ax?q5^#lopM27)tay zZZY^6H<(=Uxnjem|MTTi{;bEQ;y7)-{yD9V;2c*(kV4jQ44OmMFy06q1sMQ$-yhV! z_;3!Mf-5_3Y7*{m`s;qZK46Cax~24LgmxvL@|ts^#^#lOJ3|KCj2b+gb9NO}^Ky;P zS%QOLAI+U}63T?yR}FBHyxqKNcK&(dX14Qt;;Yo*6PNiNyP62bjq!wIS)C>UKgGHm zPCi1)zRrt|-b?H;HZW#xaS#3DSNoLEpXt)wtDZ!YIHLn%w*CBf<=$19@+MNE+-&~# z0U}k!?uF}%qdOe$Zdvhvy_;8=kzIPdHaPc$O|1RR{ghUYlCu~kuE#bAdjUs3;}>7J zjvxSb$i(h@UZ~(-2>k`k=REF)=rrUkd&&Ji=fyFHJLyIfuc@;M8s~}|=NeovjfjGT zz%tT1-xIUPmWdTrm?|kJ+>L}#9_sZ=iP%dX5#%MlUL!>(I0$Y~5a8wD|EjVVV#cJF zLAT;OW}YBUAX#~oheP}=z+g)72iq?&WB1~zb{P9kl=s=B+w!UJNG`)o{qYUO(Kz}SPk}#sYEf<7YFP@x6E&w&>WVQXNjv0;{riqUmS_cK406A z()DK3u+WX=B>##`FQj%>ypr@4PjB+aE>;m4Ud$cI)QSd0^SR&b42^~8%Catjls?6{ z=m1o*w19y1eA-L1L(6%CLuL~nZ-&%48s?c@s>oah)yj??toB0@M}y& z)C#yT4NZ3(x4OhHpl9c_#RBciE0$8;>Sf;lJ68j$A&u~es7?I3+LOm8@on*>^HJ%j^(d{Nl zFsK+DW7~BCcdOwqx%-hAk(M7Mq&6Y(!K?g1iN88QNlD86?pV`B^T)5R!9j=xxGx*R zq|jMqTCC0|+ycKWJbs97p6m~d@HPuJRw=;*UDQW%-SZl;mKe|nC+5u9&}I8Dfq;~ z$_v=fh;nep8$-t+s8PPJHWu7Gx89m(yCdK&AzAC%f~-30sYWPQevQ^c)M4Zaf3B<28cp^*okUEv3h(p(yW-NSIGO z8H=!|8gU{sTr!GF0@X4)PZoRv~v$t4=x7K~Fbs7z-J_#AziYl6@b63JYk&z-3(H^;p zUliXj#hZ}p0-YQ4umhB*Y-dFGCra(p=Ib2n>P`&}x@DYIC-g@Yqze8rcyaObHk=BT z(cJku@Xq;FrzsQ#atpkUz_}GrPIayHYGnTUfD2D$=G1im>n6(wIo;X(iaNU95LtxQ2$yGmWYB+6L=6Z!=lFzw<-*4f$c@L&G_efC}Iy(LU z4#R5TZKenL-0$ZKGOawc9zgau**tJkpXSDFqv%EJx!0w_yUu<+s74@6oU@A}oT429 zC+(tOJ$UGU{kC>^L&?`-ta~#B`F>Hz8UY8PA)5ObydEq&Yhh-*-I63OBU+!{ zF887w+;xa*4%u4%^=EbsJv%9)G%H~zoU}esS+?POtNXP!F&@0(@^2`(lYh9JZ&5f?1D$16h){}jF zq9BRgypyFubx%X^s!CciQ)9aKT66m=k+p0GT$m#EC1aUTVViw3-r>Xa1Rqq*4?oqhMf9pO;mR`AibucSk^zq%L$7&4gr+XOH_kKhl{7X11aj z=Y_4o=N=JJ8e!LewIb`!Y!Fn_#=LOZ_d?D{Ke>W|&4LTdUo9xf7;ROh@$~Cv%bE$E zZj!ee6%$@kmzRwAi7tIY?gxXL@A62+$06*kgR-uE<3muCNeZ$mx=0N^fI9&I)cp=K zmP2DSPs83B>Vp~M^f2~2!vy%l`i*s}FH*YPQ=_~H=`hM` ze8IJqUfyOgIoco!Yk-T(N#Sl)WrRUpijJ_#h#nR(R-Kw7FUjo0NpIx z1)bE@pd5kARIoj$aKxpn$An~PslH~~V|caXbB7<$?UDeU9$Y(~06Zdk3{IHX3&Nut zHoFg&o<~8)&7bLcdhg$g4?N!recf18sjgNiE}BCh@fH_s5HNYt4*Bx%TI3;9pNzgX zn)#TNnYfQAyX>fAhn+;Zg8c@cOBKzPJ+8Bd)ugo`RVtp)FDcJVB*rQZ$}a`-x%5eL zFy{tbl~yrBbLlbdEr#8+n8xmT`h^8w-So`kAoz0A!jdaQN0nAHSTTpG3Z6`I{FRY3 zG(V$9^t|@IJoF;u0=Q6oX*we4h^h8KV;-mHEv}EUlU%tD3aT<1nF`Ukho)r`i8(Mw4 zzi%~p=&?rg_4J4H{u8;u_tfuR^GMW_4NX-k^yINPG>K4_e1zBM@m8=j>*F9M-r>nt zfU4mZbl{BH-X6R0BfoOeGzm4@wK4mC!WPT||yOr%zaW^iRj&&Ed& zOhbmZqE;sFTcjkaRAax6!y^Uy%Af^fU z>UezRL98pn6+Ja503U&%AV8@wyw_ct?<;~x0opG8XkY>FU1&eKM(2fc^?8#!Wd&0l zsw8~qwOV>aL-jJ9gbByj^b+#2NELRF8#_08w#>GKQr7WaO5(!!GUJR==>}mEE|(Ol zYE@oJ(c*V>AqFb4a^W~rR5g24Z)F9(><=a;A<%4^pJWdq#QxW}!ts zFkT5?`Q*qroI7<>)xH3c&3zmFBX`84lw5xK`SC zTm1Epq#*1WpXqb{q1VHp=eyO5ks%?Ga%FfMGHOPK+WS1!M=uRAT$NFUl@d{LD|jnz zrd%{D(ZyBV)6-2-E~JGEb0qdl3J7Y>p@wET5>%RpfDc?ix6jbwA22C2Me_<63PYy{ z+W1~y<$7=?2=NoxW3ZuZ-(P2mtT2{vYY{FF%Gj=znL}eEYpZt3pD04UK;5u=G%MG+ z`$sLXesL=A8o3qoCVjbdvbva+HCQK$U$VyD64sF>9nc&qGpvwh;p?OYGWEtdS~AD8 zU|5+9jSM>H6nG>#kbAwC+cDtLKh7E4QE zYK45Hkw%fz*BCYA*VY!3gr7_A*sMF z5=_kaR#A>tw~p=?h`HIrB_YhlFO?Oo3BLuL3QwbX()SuG-}XoWTuSB+myXJrdy{?- zD%QJ}VBb#o!L_xA8&Z(aIUN1`KN~Sth89V0j^vBtnnubPVb<- z^^fB7w??zgNb`SMAaKO2YLSTKM3LMYW4XydC#>tzQK(05DCNMO`Z>Y580BdH*#H23 zbKW;B8KKVz0>FD>Z#{g5Q=zaqQwBz-)XvCLss{4-N9XC3>$W8*;ju;&{>ztJw71Sb zgq|<@`%o8y3x`Kdq)4tL5qe#(VphT}j+@9di)1$r;-gfb!WyImA8(RS(TYkyDU;AC zSuvL|sa*Scb+lP$r-ne{%bp5?LVy9v0OCaGrE6oilflxFft5w*>Li`G^aLmMK&JQ! z4{5&Q#$=hN#ural)W?)5ej90ic*nXsJ^$J}r`WnK%zUiQ9Thkqn4vn+ucG}@DGeXX zal)m`72~JRr<%xyM#m>|BsyC1NDCWr77G_8@UU=Zbh;7M9f1?wT!1Kp8KZfQ?PbCo zlnGEz;W7J8%Z;cXQLrI=^#|$V%*np|Oj__b?Gn8XeQ40%fy4dzvb)V?#^!n9>x)|L zDz%*Q9I~4nU!8^-mT*Y7T8@u+U9Wo5ce3erSwyb*p74;=(V(IB=f^WEKTF|m- z2;O6ZmI+II9HoCyQZgTGlIyMi*xy6dy`_`JXWyq8NW96WeT&wL0IBTS_%z*=6e1Aa!Uj@(Q%;AlTe_ha+I<%gaTj45! zuS}baJX&`zTd(A~$OFjOj<<1s#{7VhVx>!H#?33rG#250%|tP`L@r5bX@jl|!y$C3 znC|Ib$!LX6mkt$sF?@xch$LU*dS_3{V<#gP0>7_ zd+Xu3Db!QA-SS@4Lpyp0viasZJtu>n_#InCTns8~(w4cet@UNWO>jKjOKPl|!H7FL zOQmNnHvCXX>goSBnp+~0v|n+GHAR-5pfH2Nyq^=&dMVa{6GdA)?(<|3O_5ZVl~!gK z6m+@zC^Rk(gTb@!(+s%E$OCAjwB8P8esOvOEyL?a?Y?B+_G}XwRSZ8Pch^|%dhJ5!-V8Hf|ON_g>TwO9rWLOz9ni!5b2JHnpi=+PGang zupz2crGj>~;CIU&E?qNeF&C+W+%n9~isr}kVHmK49S*{CfR~~t44%8$0PFY76;653~KJu@<9zvp|3c&7;ldw(jtx@=dO|64U35@U=l z@aN{w9b{qD)#`ljob~64GU<<%oj9u~oZVJ9(ryjnJ5>J%@b?b8(jrL@I zvcJC~TO3}z%>FP8@_mmKcs}ltLjPV%A^C85vFq^4toCb2gm!S(1YNpfeklQ8K$5Mh zvFoo&7YUIaSS)!;P~)2k)`w)Cu1sOowZ$@BMaL>4j+hk6XGOf$%&;`UkiJUvyNMO0 zbhC@u8$_fWlRlh?PCg>$;&MKTi70+ZQyV8#xfy07f zGPiNW1N&3;vC{FWPUcbvUcQq|gTN z{$QM@JOA1{LjivKhE;CdfLbQsqM#48qwY;hZT(eCAUHnsFp{0`kwMJIYxtL09wXy5 zeA*#jhkI!kM^aMC)r1FE3kkx1`r}UI;eHzwM@=lo$yQA0Ok!T#=(2RvTNH zYzN2OrbztiTBRtt9rkG1=Q3qD`|f$2$&^0BmpWpnS*d6pDQcemvfIB%{?(aY$*;0D1ThM!zPWk8w-K;DK$P$ajg=|uniU#KHyvs!!FZUPpSkPGdhhFpuN?9 z-++VA7|nfIpV4=xnG``gg_i$5`2QJ53}%dGmpAyx1Xy-31Lr&V{%1J;-|8it3Scw@ Q`Ed{Sw~oEv|F8c3FCd!;{r~^~ literal 0 HcmV?d00001 diff --git a/test/rsrc/min.m4a b/test/rsrc/min.m4a new file mode 100755 index 0000000000000000000000000000000000000000..6580e7b667a36b9fe2316cd5cc0a4d6f2c3afa96 GIT binary patch literal 5862 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 EAGAdFF8}}l literal 0 HcmV?d00001 diff --git a/test/rsrc/min.mp3 b/test/rsrc/min.mp3 new file mode 100755 index 0000000000000000000000000000000000000000..7a8006b36b8aca1b2d40a45ebe7f059cf919eac8 GIT binary patch literal 12820 zcmeHsc|4T;*ZS?;oi z#v~&Yk-Kif5O*ZWay{3@{XDs;saKIe0sMXR&I zz$Ktza^4dBfO%Z?@+O2{wKg-dhN;3}1dJ67#t(zO@(7j+JALBR2{qWB=#_sLwK|Wt zgh^?s?k}l-K2`r;7iwx6|JP4i|K%HXBa{F5X5ZTFf4631X>APqk019h)KLFeGgA9M zdXOJRCK~_6m>TLoLH`#RP~rdc4U9lggTXjp{@z!ifc#*nJn3p-o6RGr9$mw(SbNA7!1M$egf|dfGy~1_r+A0Yl-MPW3l`ixJbEFqlO^7L;SDD4a&_= zyGmaNweWSOjo=^m$m`}W<4bTCL6w!U$z`DN#l&f%-6?khksuxFs`aGrC(qpcb%Spw z=z`H)ByimYCMKea8}(9LJRYJQPJ(*R0qeX!p2?wW6z5xuR;*LJE1)2VL<^C%fBQ3@ zci}TuRs%+9PR0+j8@av;eWQ?CxUHk-M&_cQ?Sy;#$i9zrCTaYYnT|~l^a!oU^-;St z>)$v*Y5(Qjj;)<(rK8`NWPJ9Sn7w+rx+S-D6XP2mK0bA;czNPN!l^}WGs_}5)GKuV zU*%OMs~>w!j3tx0I$$i(Y`jg;bKpaPq4>2i7yiByqa&K?;`*L>+pRce9dSm#XUgJ< zn)FDMtoCy1h^&leJNPKSY5`A-+){s%3KfMN@Q7?c2?}LUrF-})@rBt*^a`b7Ma9p> zDm1spxk^2g_Qf?S;(?er0;jf>1j6tqDFjUWqX00>vAY3)@4>m_0E}l<|F=SR|IX%q z*${y{&7hIBftkrif4|x|YgP2c`y+5e>|=q0SA|X1hN0G$?gK57iQQvJ%w-ZL6b6IJ1(lgZ(~me0CskPIrBmBSJp9iP?R#Rvl*)+ zGi|HdCF+eAu%pfbJ*-_Z2>$qxv` zeTsRnQFv$r#SE$vk)pk6Tpn&~^c$lb^czgz`_11ZohZ)Sa8p^5Qw-r_?bl#4KF7aj zdy!>!r(yAe4Ro{Jg2TV8ok~eO(@6KLtDCPtrBspzJaud<{S{u!qRotT4fb?4Yw`32 zA6!}SADKZ-(9#5f%vxj6x4{94j?-NX+CXQHO z5uiZVGY6J_;ltN7^ZlrC!DndA>$3Ej)=*Qw#UbV^3gZDNF#$AIk5Em``>7Ern9yL6 z`%o&#!1xXsh!w`wcGmRDhQKH-E^40e5TyEGjqPF4a9clm^JfavEzQud@5t?IY9f8& zv$#H8F~k{msbZ0mXc)`@0M=uIAqRl%wx>dMvpV{f%PV0zt*xRITjP_1e$4qF$7vVj zKICYg2W56&A46KAOW^s(+79-gxF6Z_z@|Er&a{(`6X|pL(pq#uT3uGmsymJzCw^E~ zI=1+;8&=cSsCl*0nT;g@=6s+&W^H{lYPTJ-S4Z*E#oH0`$BCoQN2YN69t?&cq36Y* zv3wL{3Y23U_F|4|o5By8rsoq9eYE*x1{K}RU1g>F(yo(6)f;(_P%%&tRA9Ul`iH?F zyn56xs{y7$n6O)3poOH?Cl;4KY1VaH{_1V(7@I8r%VHwoa<($b1tx`f9+ADuX*C95~bVlBKsh6Es z+_8TKFGx1!iN1Y+ea41MU5BT$A7@PclQOAz6jI-P9g_u|+%B4axDMW3OV9i2dez~5 zq+(L~V(2?_otbB6-Ah(342_IK!ocoB^J8u1u{@@pbt_d?9?FOMrL<;{LJG))Ks9dF z7&^R&O}J0l+l2XwJ2izi5#3x9a8bre$Ey%STl{l}^o z-gEfPEJOhmxvy@h?VXBNX@^K1_YGO*J7lZ8^)G`Pk05bi{#E%iO~*le$&01iWJ1`D z>>fFz9ByH}h%bXvz?&n1BwfP6S^tt2Gp6`^We_Pl;O_;t;$^+A&JJazv@0-}Y6na+ zO=kbm0QY5x6pUefwnk7UboTU>faRg(u4%o_A0N&{9A?%kO{Fc>%(miI8({j4zIe%`B5PB%#n>_I_L%N+JUTltMX@_B%{jX`k1j<( zna@e#6CDHojuEN+i$s92Ku^-Z0X$j-_PDQzUrl#>TFKu|JtsZ8y6j%wyZdECyQcDh zzQ)GZ3&BqBAbBh5{rbopo`FX3%?GV`@0eh6<$uHk$W7 zSRX)Y9O#$%ETW@|(N-F&UAUYKT}a zs@4o@oKHngMPpD74Zm~bM4%BH&ruh_;+i})E{r^(}7wD7R}oKg>8sqbe~5(pZ}4KJ1!be=PyAXrE9neOk0XHb!(G-pX0fO=L{ z553f8wBf_?nmtpJC)y5Y*QEO5rp86hXe!GN{iZ_hsD2!4RebzVS(p*Vut8-8x1^Aq ze_QS-s0;vO@eii~0L*S+cjwZ_r3EwT!IW_oV#_E|$V=(ULMY14_K(b9O^OCm!T^+c z@!*ECr;J#n!%(8vNsGZJ_`#IQ&y^c4{hzOv31&Yrlfdf;^v`K`1n0UUf)uldW6)f3 zM)AhzD9`|~`~I-O69eboRJ$ve%f*o)5-H*=idlU}6_pSmjO*wsWd zX^bbH$nG=^_$l7qaQZP)?sa~2^nPNGaR4!Mi~HywzuKpU{>+f+UiBoI#u*1OSTgHdN02!GpFokZE)Txn^^nX2WhQbrROoq zJWp&8_Ck(+CNIA597Di3K@+?0`N4vJDfAb3J?C*RM7JS#*-QTSIWMj`{Ao9aL`|Jd z&^S-rIM3jMSws{l1eBBC`JS3Ru}rF@VyMzi_*)5~eDs@F5^+~NA}C7&y~avTP!Qa} zAVAx}|5a5l$c#xXgKj1Htb9S-uoRV1J}!y3usx;(esKH(GIuYZX@{`yMtPr4x}%Wx zj_fkrRPiH&(duh)Z^TY@!(cMtsd&It6eI*L6!326*F*5ZR(+IQwimvmDz zqO#+cT!M7UuUyE&U;915D12gU$$s@(-6RruhTZVMm}=CX`r?3H=$82%Q-;IR@+@i9 zVO%Ik{EH)L+2?B;Ql=g|4GG;^PV%qJ@|z&{<;UEWN~>&8GN1d+ z&d5ZBsUqhRNb6IIiw;1g$Os8p&!@k{CMq(DoVC&*X1o^7C#3mXVLb1v=U5p!*f@*8 zVCwwyQL6w%#$)fMBIo~Dde_z;bh5wr5UnoLy+oan6=78xxLj5e1xjqp+>p628!Aip zz#nY>mf91%T<38dW8wOs8eo_W+z`MTgtPf1cf%7(h3jCuq%9`JUOQfDwwDU7a&cj;o*`cDTUEZq<2iZr zy`{ajGoBwHt)+)d{F%44qvtdgy)|{=JcHlvQ4kDrIKsOR$<;zCl@b3;MxH>T*gluL zJ-Pm54$p}-yhoCyxOVL2^AE#z;CP!}YLCxTeW$Uv!USq*U9a}3a{@DZ%QL}O8~<2? zm~$sgh9S6qgW+Vc=!J*pdsR_QHB(JP*Dnb43E|Z5;#l4uO6+_5Ak!tWazrZoDg8u< zb&?qu&SX|dRIi&Xxktso7{{&?uv-ni$=#2ni1dOW5%mel4_*}yOZ_#7%F5F2_r{to zn?HGd0}4VcjQ6S$L<*f$vl0yfcnkEf(E1^^dAdI^!W$cGqFRaxx@>^tx$iY(Wu2C^ zY}yG~sm3DGNI0+0{2il2lmm;R>fR}F$NBQw>h5I-XM6f9qhKY{{Bp}V zg(u#*-!bYEWRFm3g)=15u4xn`u|VI^BL&yv1Z5>CAKfNa)U_otI`Zy~OXOXYpJYt+ z^l1e*54tDl7Jg!5=ZD$Ph;i}8n}Ek4a8Q1rHWt{tu-=+)yCdW+DOKy*f~21JR3}!b zyhiII>M-(`rUoDrYgp#jMt$O7-J@Ek{G5XKpX;dFqbZ(8+-0?)Af&-~SD_&IcNRe9 zA*q>>KN3@F(k4M51^g6@f0>Dmb?xT~PUpzy#T-5?u7bn~La4xwv-Y(CrZ2Fw1KtcS z%1F#&D$e1S$Hb=c&6g3+^b%amcnN0xRJ#YJH=Vp84dzGcFIms(VtVCEA^}T(ikYKR zpXmzndzMB|e(o3-WQeYARuYR{bupDR#x*4(u_~ZUfua-0sa=mz}@@yYhXB z-nS(vG#`E<7xyvfJ)J(|dR)RUa(;L?Hwv;dy-+(P^3{eo^u? z9f0wcaPC9_dnIURY&R?9H9BoGq5&t#w~(okoMIPlE=xqKYT# z+?5GWWu+-3v`1dz7p3=0@urlzKYQJ7nt@TEv_Sg^m|Fql)z-?cM;5FPxbRhFO-=W|ZnAus z+npn*q`T`;yGId_;O4Dc=Yni%E((W+MLvvl2rT|4wd#jU3ujEr-mG*?^0`p>doqES z|8QD!k2HOuqvKE5QAiEA!}73z_x)U9mX(M0L(m>KhX-Ez)7+SC6tj3e@1}Hk*ZHr9 z)rrK33wBY&GmIm^q+JxG2Q7oI-`0+9DEnHBb#JC3-!F>!rU&yQv%JjjYB(3PU_|+P z`@+0UWfJAGBdZd$Z}Zu&3v{`}3}k8t%v(PbwkJ4o^zGreIR`F_MLTWI~V zhX%3_&<1TQ6sTRHUDTd zPtk-aB3hBMK(MNVeQn%zK)X3wwrCIS3|ctNXLF^=(U`?`B=rlT32NW zbK@JR%JSuB^%NhUC{SWI|8$v1-LnvanzFX+)R^A=*1Y~IWG%-b7nXu=CrG%IC5rl$_{_d~z-r0fbh7PvX#(j>hbc;_{^EqmVK1C^@|im}cqsPJqLL&DpvK^&9JS zU!+Whr)EVl(qWX}DV&Q*P9;!%nh7(SAd~h01W{jO*-SH*GCUla43$ODV9D zNU=(T3QK_kE`3s5ta$;~WmK_f9(|Vm&9Ivu)7U-FytLq}myvZ60A6ldSn`DEsxfK? zE9WrO;K?M%Uztfm^E3J+&l~S6LN9|Z01G8orXzxmnduBP=5u={^BkYT%?gHy@~d+v z<>!^8sH8g2lYX}k&WX?Ip>;C4ico4Ijx*v4e;2bVrZ~g8d7=H#63u@VN`gD zZ$(RN9~UX{E?J|xYNnIhBq z=L3O14SznakLzY?5C~O=ajpng^wgjbbOZu}0H(t5{&Z=+uNWi+uwDAmzy{pE)P8!6 z$q(l0^CoxN3Z^7f8GhuodPYP;^)i!;2`AL_67#dkm3E*TJGXkauv;Rj>jW=l2{@tL zB(qGW0Zzu}kwew2D#~cuf{revKqYn_Ja?*^R*zb8cHqnYU{VqS&7t*4?g&yG2HWN@ zJ=3_>4uZW|Xi*4^S0+?FJvL4tQc}D;@KT==jn&nZgtK-Eo}qtV0fm6KPSuvLT9qAA zE{>I}4lB`I1yaj|MN>5~xVRn+7pH)*i!?{JRs|qN;9%ZzZrQ#j}!KJS9Cn-DH&_MmU@+v0q9^SZfY7G|QEs+B^jNzys^{89Mp{ zB88@Cejy_`czR%r@6T0k24?{vKLI_48#?y=b(Y9V6G^ugctud=cCG9j8W&kh-Klu0 z1o{F_!|pSzT<0Dfx4`+uslIFERmz|A<c-hu(iTLcC!jDj8 zy*D{ri6ItCOJV9o0_2fK(X%)9YRIpxEs6t!rwmywd5<}Fgu09uOc>Hj)$bdzAjZ4A z6cz~-_+%la$SE33%KTPYf!45&?iY%=-NPdZ=Ma?6j@E*n0!oEv(R>;EgOzXlqyQ|X z@P^Ao<<7myxDOWVT}z;EC;af*+M|uhP$y4Q`Y3k82A?FcF8saz-O+Q8_Pp5xdlOy} zxHfIE`nc_U*?IM#29$)^NUi_tw)$CJ{ahOwzg9$vn%^5)iYlY08%qjU<%uaSyDJEn zFRVVeUnYPCwuf&n&kjU_aJ#$Yl#L5&XTH~qQFv15PE-@ee7^oTTQjrJh*3paP_g-w zy$85xOq_b?;#2*j;(bFe`Ypd2tIUe>~;!JHZriX2;QA$ zl9rz0r605BT8Iwfc$BM5b@yVLWpy>m;f>%z>(>fBL*^MRRa z6aA_>FO|~?v0SHIx?C}S1_Ek{9B6cWB3GiLC7%r3n7agCoWRG%o!RL|(r|>G>gIuo z?J;9C---Q9m;*BboGCov+-bQL^&<*0M5z8CQ<62=SCGXB9%o!()}fCK`a5vBKVNpY zxw_XpFMNGbt3y@Kt;nUg$qUqJVsXSHdKJk*wS4g8HjIg zKmzdP*ye{Y?AM-|JdI{*Gtw-7qIWs3MAd;&`IVs5_CBg z=y9CKP=qF7WK<4!PG@6BlU!tadf`3UafW~AWa=0|FgtSl+kyYcekQE!ZwJnkyH=NL za=u4_uVjXM!)*P`3-hwKIt}?*W*W`)IK{XS6UXHn%2kv)uk#ShQXS>F;0UQv`n+D< zqBK`!s=EsL3m%0yb6eh1^>K|M^Gx99qVK|7yuw0?mD#fh@jQ(&VVtz;&*)YW``aFe zaVG51``kFpHnZ!sWB`Ez$J6u5Q_spE>FAOi3cbRi;_TJ?KG<8e8MeM&ZG<{E`2GR|z6xB(og?&_ z8N;qjqak#exZc@bsc6Mcmkw2XaYCh?sFXnGK=L8&o(}8mu-?OV@Ppj!#`|F}F^rnh zI0~ggQ#7CF{(g9F2F?`jw7eJd(23pwZN9n5%*|vbe#cQsi$O(AIl@aL!)l_0W&&Wdui5yC*`3+4k2W1inStD(kvqoJ^c+N4_RN)}xR8=V{BRpFS-OdueVK8#&Y*$|NilGirN>J0FhU4+ z6jBG9B7O@pNg(8+f^0( zR?P&(_SP2gbMxm8vMB0$b%A&8`twBDj3+8i+*BHOH<}w^g*<7Y*DLd>N1&tBQ*}VY z`a))-Jw=e>@2|uWN02DDKMH|--zNpWkNc$1zduq)Ia*QTI=nKg^BNRk9NslWm#vsz zNgx!G<)}4w{nQN65V?WH(q}|5=XRj@FK+?fo7ry1XQ#4(;+=}tzdh* z;l(@%3jno43Q=67IN_0}zLWb!Qvd{H|Z-i)-?U$sP{<0B7ax%nPh#C*J_ zf4Sue3PIDS9rSg$mvMO{HMK$=KDb&$g#YDFaIK1?d7-kgJv5a*#rmF6%tSf=A*_Oj zQAuC6y&=Xpft-fLnB5Ul+)y^@itRZh=N!IQD0gaOHbpj10zY42Ho@7T8er@nc8VAr z1n00wxm(=>zp-6<5xx=nsAlCN=o$dL8`rmW*YCgJRRZs~L2=c@Vw`NnMb0JW$Bo`O zf=p0H8OD4w6AhT}g#9TZ$`f=bEih%tCfl_-kx$RLevcV}C(f%-Dm;zmTmDa-0;L)F z6}8p^8F}D zA}xu6p6+HjZil~_+I|UI++U}G3F72w z-M(7dbg-<@g>UZ!^qj|~<`uZ4{3sCw*Cc@kxrrW(bN@FTTTG&Rfy`<^38@P;bLf=< zwgVoYG!I~0JP6WpsQ4nAg$9DVX z;tF(G1pxO`U)L_1VT&^!w^@Cx&*`y~Z-^XSd;b%C?ViU+NCsTHm$wa+T8{X6MDVfO zC$!Hg4dzAS>7<7;P6HH+C&%2cBl?F#;UPBM>U^v|^&h0f7YhH3+SBZ~mF*t9$BfYe zXN-iQ=@JKa?==18IX55+$_xlLJQW)nbyH1JuuT%b@Y(pKP!QccV9}Hl6JaVr@&}vt z#+tzwi+gnfXm9s-ppk89y*DkGy~<~N^hIu0s>~io65S9ljT`jY^AF){5QEn9j6oTi zLpGKNE+qBao+JPKfruDPKJZZ5tLSWwp(3Tn$7!xq@l4xbIQ)=vVGO5iV?DVFXw2+1 zRs?&i|GotUp)s2Gs=?meoncxGb_y;3eenM?koK4{np45>BMZ#3gB5nML*RdgPwx5McbD(p zcklb&7XSbyRk21I=^H`;kPu3wzD$ukQ-an2m~ND0X0C>USh6}@fHZ9j?yqQD@W=#! zhA;q{0no#K$fP5<{u8~m4*rwzorOLC`UMIZe-*SWQ>?;r8vl_KWkUq>zCoDstvq9% zV{XA?0*3mNB?1v2iWG_9U+-&C8|Fw&7l>tJq4Lb^j4^o(^|~aYj8wQH$R)Zbjar-q zIz3q+6CyhzRHJFOOiaWY$%u${K5mkfO4YC(x56C`{r;W<45E+n>9Wh$FKz4^r zhm2x8Ay0*jbSTf54e7>wk)JPQ6h9p@@pY4qh$!z^9#bfzddYG{N+v`^aE})4 z09f4zz&0NMlWo}o1s~!ji4Y-1e>w>8@W}X5DH@%Q8e!u~L}IxDE~nII@Ffr@3HVqv z>Ku^rWeO1=1=yuXMF=fN&8X2B*-6v*QYgJ+uvbW=s8p-BBf#`}MJBR%znMxDmz^P$ zQ&#(XPxoeeQlhg(DXZc{5+TLk+t=Fi-js1xCbG! zAx&%~Hx!DhH?vR}qofl8d>J}{a|?d$uy;(rui4eI~4_Naw1kuYxMf2bW+ z`R@v2G6UW*y{+*H@c-QhW}?=I#Kb|ge>0;ZT}VME06QQ~^?Y$M^ssx!L!6wAP7XyO zTZINg8g+moM*5@ZC=kif1!?=19ebILx>IFBY;>dE<1pTV@eYi4V7vq49r){Zz`$nw zv-a1W-gxEzj}92qCGa00xCV^%9EnB4%MSYZ%xq&Dwu0xxq8eY4%A%S#Z*!_vxIJl- z_K{;b7ssI8KDKN3+2qQ$lQ!`S^>6m%)2E)EugnSnKM{*mS$MLM5vcbH=A5q?TQa?ev$IA66H9@4O7Mu&PxJ5;5N;D#c82mu{++B zwLCe$wi&!67PWEb^eC04lmm@ncYC&Q<}VVnaF;@yZ@7!xizc;V9wB&Iff8@%5y*PP zOW@<1c2r(`5k2!@>ygTmK`nSjEK*Y`D)TEwCS~1V!sArE_p?m!If);)RkQN+&ZN@Z zqk6L3P$kICV$~&??Wvv{`^?fT%lH54RxxBsbYC;IuJYEilb(%NG`U85ZOu_u%voku z97;%t54Rc~_RmPaT1Sqm`XJBciIB(ui-d;`el+n@l)f+&b`iINXE$RYQ#~^O^RPU| zVfUY&-?8uY3C|DM^77cg#mO9h#W7mxB7%0b?X-^)@V!s_cAUPWCD?t#Yc4D7>&`Wf zE9R3TO4g$yh{q8z`NIHg0xSZJi`xVkQ(7E;+!n{% z$~x{aSk*-fT3h?~r?*ZHUoTZ6x0+}?ZLXN7DLH$p){1oLz5VXd?sE!%e9AFe_{F&= zhhwkT86&rLDv8E5=@m#X+3z{i%e?>jI(hGfu1A~PxIYZf>U4eOM8))I^ehro2V6~#KKcNu_&wM`U!SH17~og)Ai9;Rdw@O zYqu@85=1L)l-Lf`yzuJkfO#IDGzIeSd@_?!+rg2LIWxbDJpHh@d{4Jo?KG=2Y%)@g z67(g5_gMa#m00X>d@yCctoR(RNv|CF*6`*i@tgUc%Qf!HLPa^me8&r(YsmM4 zzd8}t{9^Yjx3>Mb+Rtu}?aBOFtI~*(^;l~$E4hj!2|aHflJjD2W4Dv(&al`6nHR29Jlr|o;{5|jKRz@%kQC@VLvwX~n%t5X>9YRo#6-n`q(%Fd&@-{&sHvn9iyFp^ zPq)&d{&tLZxgpQ-*5X|=6=oq`K7uvhRj_K3s&z+*N}&#e@PFhCx4cw&Y7ClX8(%l9 zl)F^y`R1X#C$&l_oz;T1iWgz3)DLdG`;A|awuQvocGIfhqIGBF7Z;wj|GFeJ2}kUn zIUBnH)V$COdX)CNqL}B~H;C?+O)p`X&3JX1o!4KsV$#r=f!47GxkTbF_l75Skk*&} zY?(`Tb$=L3cKM{;&sa8eYOlB>i>>PqoixWYNNG)FGitT#7WXNqm0s1n{$b!ZRO~b9 z(<)KBr%5xGfEPhBFg$)HFOM_Owfez&`t9F-i&!%M==xce4(iN%;*Q1GlUWo(sQxw9 zZCrFZX~B(l=V?P5lG#76`%)9|;FsPjJ7YI}o-uXh(*)u9ZvU+`0?t;8y?Io+$~^d{ zv9(!<;4{M1bG*lv`8{rTN*c}{O36INiT}mw9^cee<5Eq=a`3_n&D2NwzstS57T3&v zp#Sx&RIcSx<}HIPiv;_A@BEE!^E6WQwqUna4QXoE&7C*ghBkIwjM0zHu7-#F zLe=StgpODyQR;?W`t3}DQ?(85e&R@9?iZTVFNR+_*Pn^cIO?*J{y5&gb&_rx;e`dt zXJxy?i-UKitX(@Y`XJ1|J@c zr6)NYN;E7N7}*b~@|skWi0EIoO|7B8Qis6G(VYS&qj zz}#VN*<+}TrL5Uzn1sxju@q&Ot?Z(^kZlYJnJgjuGL}Nga+fux zG06x;*pKhOJoe(&df|9bv;KJV}R!<^T39mn@LuH!n+?{S`ovnUN# z7&rtpP0w3_KQPa$-ah!St2XAwHZV0943D;k!T4d&Ezc0?@Y5$woluAEiC*~+QS0+K zE10yl+WwIK=U46jHc?mC{68LP|H~Z>W7Ge3jdEgFuV;C2IGN+Uh$N64G#4Q35DT}EnzV3{X70{VF55a z?i>up22%%RjiHGq43Afb!4AR#Aq`i5DANBP|Bk@FBk=DC{5t~wj==x)2mo6yonTEg zGhm0ozP`#ZmisP{+zNmr+Dxry8ZhMnE^n?t*K2P|mFFg0v(z_9QR4cy3nSMJ_^)po zoR@YPqQU#`@JL9Pv;3Lf2ajAhl+^Ev>w7cwea4TO| z`UviEuYz8|GOiSR5mZ?jn_LE(UQC=O*q`#i6Yw%&ZrV@!fAY-DUpM@Af-V@tMFhuP zU}7S=q)9*3)$<|B@g%7C9I(m%uq{#8+Jy85xt z)I=(|yA#F|!^YbjGY7sD7>ZvTa}n>W(7Ix2u5Ry{x7|x(*WqUjdZ#R(sLPBr%jqnq zjmXJrb%3w(s}Ar&%P&!rRVhf!fM--AQcx(9BGb!Pg)7QQrdKMLC@Fm|QKh**&QtE4 zbSSA+l?Xz|<2iM#CE-RtiJ@Tj9tD75j@=Ca{0=Ui2Vgv_2EP@t2XwVixMnW9hVEh&W(Kx`qzMu(TCT{ zPxci0zA(qc>nnZ}9W*Tmw&SA<{Wtbx0AObqn6ns^cx4lN2T3mHI-9vVGSj}QQ>xK) z0b@qfOXC^|J{j)A8rPGKJXAE9oKUKs#0y*MN@v^CALCdDb|VjiZwqWjIaPg7@jwky z?jnhO?&5yCij`kr74HFLxPivSs#&m36# zg^O6zD)6Vohn%6ctjp17+QQ8I7l)Xy$czV|#01b(GeR-5pi&}J(P1GX_n}mfh4CFS z6fcUe>#FUO3x$zeUDdrrLlGK-wRVTaBJBL>EuYCu_jDtp{v)@qsf+YW%wqfX#NlVy zrAtIgV_+~t09cO=fgAv~+n);6&*~afEw6;@wzY|o?MzM%`ZMQ$9H(87|B$P79+cUA zeGFlRDitj_)_#zB;(k=?1KXM`I@4YzUZmgkOIz^;84Wpc>z;Ueyu@KSnYfbA?iejQ zdI={CvHv}a4_BK9OEhGmgcmvbj8waf7FDDQ$9J;%6wvl#`Or9O6E2`B0dPLO2A z6Lb3j`;0A@hAvMR6>CEIlRT+(6jI-P9h(iD+%BGexDH-j%gF!gcGdBHlu~lWV%R$i z-I-@+JxW(D42_IK!ocoBi(~B;aXe;T^()oZo+^i^(%Lf!Aw@)DkUFWd~)qw$94xe1y-Fy zTJwj#C-BU)Q&77)Qf5(aD=wbS2rXB!YE_?Uf<(Zv0O)*qi!eiHppx4c)tlZ0=jsSY z-y42*map#jKM3ri^R&Mjx}qM31a`BkNF+*I2!D95eH2pMy%Sr#{l}^|&TIJ1EJOih z`LFKC?VZXt8OJDHj}1BIJ4Bm;%`d|nk05bi{#C^@EvG?T>5HY?Bz*XdoL+h3Ty9~U zh#!Mfz=tD|C{xP8+3=DUJErt|RWLCp@b3k-l4bqwu1*!@^eZr!S|>~^U3UM~0FPye z6ii@zcE(U9boKU^g5{z0u33ZbA0N&{9%j}lPo*!_&bDD!8&yubd*ML`7RN8Hy|OPQ zUkMh`HzsJO7}U06zj({0A?ng}#M#m84(Of=94aR$RjDUF-6f|apDv9@TFgn~lAHqn zjuol;i-?D@KyT8>0X$j-_PDQ@UtMo}S~YD;F*O`+OMy}W6T^EStPdbH4p3#k z3y@oAp*Z#2bMcFKK*hVd3R7*lSO(`sSf_H^KtUb%lm|cudBJqd`cTRFyrzzuH zw21J6+%iu;>F;M#6Y-iUjW3oKbe}VzAlSt4neDHKXHbzOw`9v0f_m1~553iAbVP>} zw0ftcPP8A+sZI04PK}G1(^Quosb)eRNGg`KIw4`GJlq&<)TlaxT~bUbxGjGaR0e>t zgoo1r0A@F^yL0K|(t^3nVCuLkp>>oXgTKFf;msjC2_g})H$8bkUTeduu{%&EQ(9sIKc!J4H^J; z-yb%-{AdoE0xLUjY7-x92IzmiIecSB+4SywkFZx%fP3GuP!k@m2cpsjGrc-OU8krUb%?oG!D# zpAtQdrynEaUl+v0>?igZ2M{~AxR3tvt3z7Y&rI2#RWG7hyvZR6JF4J4g?AKl{v=wo zyX`+dfTXJ2vv6~9bcgF*inZX^dj-{5x#c(OLh?`9#yQ+RNNeLNJC9c3d14E95OVT2 zees3o7#zk4n%I5M4;K7OVZXrnoaenzy~ey{Z-w9Iyt(FZr`;Knwe_~a<2>=>JcA47 zkz+rpP$sZY75C(QjT!!d~%=Brgf{nJ7C$L2w6y04;}rSJizW zGbXnVx|bHP@&$9lQdLL!xFp}g_Lvg*!SM^o+P!?H1H!%=?Q=f)j$-;dlIw7D<&R88 zo1f*q5qq@_!^yy>5`k0EkPtYKz`LDa6Jb#sWg)BDiw|z;+?gX@(o4&X&PiBu4c4u= zav>Xc?e|3Eh>5W!ht+HKlL*8acB2DhYSDY@iv#vyTNZcB7>-NJv&2=$aiL&|FHXc| z->>Zm*#^usBy?*zIiM=r8=;$%pdx$C%ZL22n_WzfAAMIky{b{!V(vG4V^a~Ps=R9u ztzS7lCJ>n_Dpap`n-jaw_G3iCAogfMT ze2tBaUI7-S!Rb!aiE^Z<>NPE~85Nyp=)$mYuZF?(*6^E0sMPz`kT7cNrhn5@6%7KO zWGa5`kMc7L^XDT)Y-I`@#8Z)>`tM_ih{ZoJHVFBRP6%KGePygoo$ zOAnd2GjHog&uJ<7Xz7Xa41T*uhBHVJaG!nzS1YkhR^l@WaRPy4`&{Avqh9P!Qr^ zyjP7OQs|Q}aK9~?wwK8<@WkUqdeeWS_oAl&mvzBl6s|;BTy8z5_{1mgJ6c1M zg}e(jK!L61be zqEBq>{4j?ZaW38jQ}7rBHp&mw#Q~ca*4ql~c7%MSr0d*T5tQ>@8iY#K*C+#cJzC+? z)Bt2+4a5A}WI!0Ke^lpOkX!ivb3H|8G}Y^fhnx-+gmf71Dinl(u0p6hB)2dMMqYDz@LH%FS9UlZd9I-42}X`^x?x2stBwggbM7q=v*6M`T;vT;KksgtmG`Z@*H+~ zOnj=qVj2ESKhf2k7jI6b*gr74>Ffh(Fh5d$$$8b3(5qe&@EH12^cn zbLY4qW7OIi)tw()TLlZUrnr(|h%~-TlC4Vl_ zyIJ@cz!XVE&Z_IY_*DLtN&W(JQ`ec8Z@{ST{2Pcp@M%ZCqpKp zJoA&jD8FAyFeBFoxil4E2WZbY&WRsPk~yF&&^6fIlO7hFVwzJg@@F)t3j8v9aryH$ zlnT``yoGwu&iPfBIT!_UE3}S)xfMV`eXaa@RN?x7D_?c?)HL;Vv(>}Ao?Jm?yS2^)P{}vxRex-H1Y=t6W|dR2?}eh@Q}Dd}htpenW#|i? zoqxiPLTbPrmWPGB@8^oLtvz)fg7&yMJaICg=Em%znI-G_H)SHa&wo9vK_EnBZME3?QL;a)1|N#EymZ^AMRr&nlob!)V&mg*d9`i(Lfe-;G?0CO4q&Sz ztu@c@kyMT2d5H&gjrzi;^fM(I5)|>T7KtUWJ|US16P5y+*BkjOl}~nDwdx$ zkbQllL5bah)8!)d&qDF)DmrpgWBT{o@~PE`I*voGERhFNu`I|+4f2GdLAgn~u851v zV#e)~N;VRAvdD$Nzb{%~Sp;BE5ISK3Bx6yqNOlWSWuFKXx4?O0V197Jo!#f43DC%> z2aa6zs>2>-p?(ebpkeSWli*QJ^3teydrj*hB(ZwVuZ|WUnd`<}Vuotmx6Pz#rKCyM z+*M8FMe=-FO$#ot_{>65D~Xp=GIC(f%d5tICP^N+E2K@*6uFLKL2xE;#{ZKanJfb{ zThV*xg{{Hoo{`a7;WvM^AsWtY5Y#iqym7hrL(j=Ry@rO&0t?GuEorG}U3Hc5%$t~H z?L;qk>69kr#FzBtB@;oSYrlxc;gFVld@>2~a0i>-WgGNOfX#x<+4=_!8|!pGglwgkR%Hpo zag^Wml3N?IvfXlWv{4+=02Y^%Bitz!gh73po~YW00Tw=1Q<%j(bM5XmBNy;P-=!-jw>h2h2--_^@*)>f1*lR%(KE3y)aams^=OF;sz z{nA{l`GMDE)i5X?1D5^8u$vLv)HBb#wBVkc#(aC@cj9G}9@3WkdDYj7tQ$Zrn$@$ ze|HGUO~~z~buqe&k?JB&GZKn_m#`|Oy1;sPq4m%j!+#Y@g|A&}(0U-ZG_zfKe{_9r zb9w-3XsqjpDJ`B3a<+0_e`jE7{&9O}b>tpB1V5azp=a^(-ObC>kRyA;`TXd4Vaf2J z)wc)xS5t?cXtmtTd^8_0ksoqj^WF{5Bm?=dbhTnHKFcGMa8>EYctbuPB`Zun7cuEB zU!f9M4Y$ApXYBU&*sZ6@hc^tty~=Y&b2a*+$Hl1(EOj}|6j45fZ*Mn~Ssj|eRhaxS zK6+>xG`tnPGWozVElIs*jXXxW*b?)g$ug_Bm*L<8$J`!1B*T%FD%A|;>{gq9j~C zDNNnEvYe(P=;TTaQfBADai^(k_o}Dl1ihq&5R>634((6!M-UP)*fxLJnWnW45bVuD zt71@s3cmX3v2i?soa*g~lm3)sqM@!VoV`={4E6g8Cr8-2OPT-x_X6m<7wxNpKYa+h3|J+j2yl1dAhGb27H*3MZwC5sks-u zmBgr)%u03hl=k-akW`Bq5u#j4RB0h$?K$MoELWmh%Mk1X53I*`=;#lK6q;lBg^WeP z(*t9CzprvLBpU$v3FtN2&~>2JTOq1UrQBOZD}%GP>*VH8*r+1k@k6unUtEv|JWa&%xxD-)6S<)fxp$g+Fc{YJAMi5J1tdkXM z0vnp0#n{BCYfgzznhUYtd$|(}9REv`ksY{;1F@S*mhJqP2+z(b{s>dif0N6V6l%G& z6s}P$KpJThJ9}fVhWy&vB0Dm8$`LhE_n31>D9bp(#3B7OgZ>dqLW1i{VUZw#PnOb3 zoMIuwtZ!A7C{3Ffs!;6hULGk?4ndim7;We+pj3Dk!Q+BZ)EV`;>DMQ@Cy;GAvWwxrmR`eT!=mQUd(RA|)$of#obB zXXGVZ!)5Xv<~326t}ZR1q?f%_1f@VDq!Gx8;7ixVZKnd|BLgdo;MHj+ap@^e=Am5a zQ$Ess)vd`2FRd?LZpe>mQ-Zd#g3yk2cY6M{PhP1_eYnL~y$3RAJ}66lf~uzbQY8Z) z$92lJ+YRk+D4?FifkGuDaV0rf@yUvsaF>dfB=WIwXLY#~HJxCmdU#;sd(0TacVa&i z=DYR;cGs~mS#`(7iKd;#u-j&YVE0%3c|HC!gb{p;rdFx zt(+-+q!u_2kJLe;z!f5RE_@F=RTsDRr2{k!$bD5%4zZ%V|BCz)JhAlbEWt0rJR^A7 zJQVM_!N`UrK8`X!sHj*BHY@Zse5Cf$^;7gR1RVOcgNU~|bW?bZ1zk@Cc^>C65}`>N z8&`Wz{D%IqA@ADAMQa$;(;0Uov=DdFWq6}A6nujXs z3l0fCb6den?QyLU^Gwj^;_t#-yuw0CRXMY8iG0m5VXTbW&zLq5hufZqv8L=X``kFp zHnZ!!WC(!*$20OP($2~v=%~_MGQHBV^6b_7zL;BenRb5O?f809Z3-ks{>7lD&EN(` zMe1wspR4$KQEpW=b>lIb`Q|Eb57kU__!`9uW8!a{zsjMO=lWO-lR6o_+=^j zkZh@HBA2$UJ!>_W&cPVz7&h1exQkW>+Z-_P#qSpw@KxYK-W=Yv^w%Y=sUzzd`BiRG z`09+wsN?nba}CO#i#-I5?f94$X3YKn;aZz%%mDQ8JgDW^qHy8-n?!LI|`|To8=m& z<0l5@$9JbWLjYL5_M^Sx%X*rdl&126Zu*@%gPK*`YJ_rmncc= z;goPNicC8pdo&4^dQ8IA^5JxGss-*fPh-gemZ2ttrc!>H@=t$VF93S zi1gZgb&5a`wwjBySUF2_A5XTx%uw!KrR-5cZ=_oxJdcO{z(vl}JP1}m2FUR4M zG*+fl`idpy3_@R548g&Ybnx+YU87AD8jaD`J;9oqKuQAJ9MKS9Gskwv=Hd!8tpO!puD$<>x^~a=BP0Wk-OJmC%B@HIJtO(p9TGd{ln3*p zaCG8BS?2+=<&$F`*WuJ5G0{+4ZVf(G--Zv;5(`CtM(=5M-pcU^*<;2SfiuR!PQ@|+ux17!vT8=r~~jk>EPE7~PXT=;DAQYe`25x8i^iH?Mx`2!I#n0(-&v`@*!0!=~4jE~dYC=ywAA)=y(T#8~j<(e8u)j(5Lmx&U%xBBlJ zP!O78c&{4n_1zg}CE!k>^}i4Pe+JSXGsbW#8hvDeS#`3)E_Mq1&v5*|)k`Q9fEW<+ P<38+fo%_H4U;X`G^?KoB literal 0 HcmV?d00001 diff --git a/test/tagtest.py b/test/tagtest.py new file mode 100755 index 000000000..bad5e7c62 --- /dev/null +++ b/test/tagtest.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +import unittest, sys, os, shutil +sys.path.append('..') +import beets.tag + +def MakeReadingTest(path, correct_dict, field): + class ReadingTest(unittest.TestCase): + def setUp(self): + self.f = beets.tag.MediaFile(path) + def runTest(self): + got = getattr(self.f, field) + correct = correct_dict[field] + self.assertEqual(got, correct, + field + ' incorrect (expected ' + repr(correct) + ', got ' + \ + repr(got) + ') when testing ' + os.path.basename(path)) + return ReadingTest + +def MakeWritingTest(path, correct_dict, field, testsuffix='_test'): + + class WritingTest(unittest.TestCase): + def setUp(self): + # make a copy of the file we'll work on + root, ext = os.path.splitext(path) + self.tpath = root + testsuffix + ext + shutil.copy(path, self.tpath) + + # generate the new value we'll try storing + if type(correct_dict[field]) is unicode: + self.value = u'TestValue: ' + field + elif type(correct_dict[field]) is int: + self.value = correct_dict[field] + 42 + elif type(correct_dict[field]) is bool: + self.value = not correct_dict[field] + else: + raise ValueError('unknown field type ' + \ + str(type(correct_dict[field]))) + + def runTest(self): + # write new tag + a = beets.tag.MediaFile(self.tpath) + setattr(a, field, self.value) + a.save_tags() + + # verify ALL tags are correct with modification + b = beets.tag.MediaFile(self.tpath) + for readfield in correct_dict.keys(): + got = getattr(b, readfield) + if readfield is field: + self.assertEqual(got, self.value, + field + ' modified incorrectly (changed to ' + \ + repr(self.value) + ' but read ' + repr(got) + \ + ') when testing ' + os.path.basename(path)) + else: + correct = getattr(a, readfield) + self.assertEqual(got, correct, + readfield + ' changed when it should not have (expected' + ' ' + repr(correct) + ', got ' + repr(got) + ') when ' + 'modifying ' + field + ' in ' + os.path.basename(path)) + + def tearDown(self): + os.remove(self.tpath) + + return WritingTest + +correct_dicts = { + + 'full': { + 'title': u'full', + 'artist': u'the artist', + 'album': u'the album', + 'genre': u'the genre', + 'composer': u'the composer', + 'grouping': u'the grouping', + 'year': 2001, + 'track': 2, + 'maxtrack': 3, + 'disc': 4, + 'maxdisc': 5, + 'lyrics': u'the lyrics', + 'comments': u'the comments', + 'bpm': 6, + 'comp': True + }, + + 'partial': { + 'title': u'partial', + 'artist': u'the artist', + 'album': u'the album', + 'genre': u'', + 'composer': u'', + 'grouping': u'', + 'year': 0, + 'track': 2, + 'maxtrack': 0, + 'disc': 4, + 'maxdisc': 0, + 'lyrics': u'', + 'comments': u'', + 'bpm': 0, + 'comp': False + }, + + 'min': { + 'title': u'min', + 'artist': u'', + 'album': u'', + 'genre': u'', + 'composer': u'', + 'grouping': u'', + 'year': 0, + 'track': 0, + 'maxtrack': 0, + 'disc': 0, + 'maxdisc': 0, + 'lyrics': u'', + 'comments': u'', + 'bpm': 0, + 'comp': False + } + +} + +def suite(): + s = unittest.TestSuite() + for kind in ('m4a', 'mp3'): + for tagset in ('full', 'partial', 'min'): + path = 'rsrc' + os.sep + tagset + '.' + kind + correct_dict = correct_dicts[tagset] + for field in correct_dict.keys(): + s.addTest(MakeReadingTest(path, correct_dict, field)()) + s.addTest(MakeWritingTest(path, correct_dict, field)()) + return s + +if __name__ == '__main__': + unittest.TextTestRunner(verbosity=2).run(suite()) \ No newline at end of file