musiclibrarian-1.6/0000755000175000017500000000000010164627237015106 5ustar danieldaniel00000000000000musiclibrarian-1.6/musiclibrarian/0000755000175000017500000000000010164627237020112 5ustar danieldaniel00000000000000musiclibrarian-1.6/musiclibrarian/__init__.py0000644000175000017500000000036307746615646022242 0ustar danieldaniel00000000000000# Package initialization for musiclibrary __all__=['config', 'configfile', 'filecollection', 'filelist', 'library', 'libraryview', 'organize', 'progress', 'serialize', 'sort', 'status', 'tags', 'treemodel', 'undo'] musiclibrarian-1.6/musiclibrarian/cache.py0000644000175000017500000001031010137057762021522 0ustar danieldaniel00000000000000# cache.py # # Copyright 2004 Daniel Burrows # # This code maintains a cache of file metadata that is used to speed the # program startup. import cPickle import gzip import os import serialize import stat import sys def fingerprint(st): """Return a 'fingerprint' based on the given stat info.""" return st[stat.ST_MTIME],st[stat.ST_CTIME],st[stat.ST_SIZE] # Cache readers. The readers support various cache formats (see # below); the Cache class only produces the most "recent" format. def read_pickled_cache(file): """Reads a cache stored using pickle from the given file.""" return cPickle.load(file) cache_protocols={ 'pickle' : read_pickled_cache } class Cache: """Encapsulates the on-disk cache of file metadata. Files are indexed by name, and stat fingerprinting is used to detect changes to a file. Note that it is never *required* to modify the cache as files are changed (assuming that the (mtime,ctime,size) triple is enough to detect changes to a file); the cache is only used when loading a new file, and outdated entries will be corrected. However, it *is* recommended that you update the cache via Cache.put after writing tags back to a file, in order to avoid unnecessary recomputation of cache entries.""" def __init__(self, fn): """Load the cache from the given file.""" self.__dirty=False self.fn=fn try: isGZ=False if os.path.exists('%s.gz'%fn): try: f=gzip.GzipFile('%s.gz'%fn, 'r') isGZ=True # Remove any uncompressed cache. os.unlink(fn) except: pass if not isGZ: f=open(fn, 'r') protocol,format=map(lambda x:x.strip(), f.readline().split(',')) if format <> '0': raise 'Unsupported cache format %s'%format self.__cache=cache_protocols[protocol](f) except IOError: # If any error occurs, bail. self.__cache={} except: # Unexpected errors get printed. apply(sys.excepthook, sys.exc_info()) self.__cache={} def get(self, fn, st=None): """Return the cache entry for the given filename. st should be the stat information for the corresponding file; if it is not supplied, it will be determined via the stat() system call. If the file is not cached, or if its cached data is no longer valid (due to a changed fingerprint), this method will return None.""" try: if st == None: st=os.stat(fn) info=self.__cache.get(fn, None) if info==None: return None oldfp,cached=info if fingerprint(st) <> oldfp: return None else: return cached except: apply(sys.excepthook, sys.exc_info()) return None def put(self, file, st=None): """Replace the entry for the selected file. st should be the file's stat information; if it is not supplied, it will be determined using the stat() system call. If the file's new information is different from its old information, the cache will be dirtied.""" if st == None: st=os.stat(file.fn) new_cache_entry=fingerprint(st),file.get_cache() if new_cache_entry <> self.__cache.get(file.fn, None): self.__dirty=True self.__cache[file.fn]=new_cache_entry def write(self): """Unconditionally write the cache to disk.""" dn=os.path.dirname(self.fn) if not os.path.isdir(dn): os.makedirs(dn) f=gzip.GzipFile('%s.gz'%self.fn, 'w') f.write('pickle,0\n') cPickle.dump(self.__cache, f, cPickle.HIGHEST_PROTOCOL) # Zap any old uncompressed data. try: os.unlink(self.fn) except OSError: pass self.__dirty=False def flush(self): """Write the cache to disk iff it has not been updated.""" if self.__dirty: self.write() musiclibrarian-1.6/musiclibrarian/config.py0000644000175000017500000001350510162334734021730 0ustar danieldaniel00000000000000# config.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Handles configuration of musiclibrarian. This has a structure that # allows distributed registration of options with defaults. # # It is not an error for an unknown option to exist in the # configuration file; however, it is an error for an unknown option to # be *requested*. import configfile import exceptions import listenable import os import sys from warnings import warn # This can be externally modified: if true, every call to set_option # rewrites the configuration file. sync=True # Configuration problems due to input errors # # Not currently used -- instead I print a message to stdout and # fall back to the default value. class ConfigError(exceptions.Exception): pass # Configuration problems due to internal program errors. class ConfigInternalError(exceptions.Exception): pass # The known configuration options for a section are stored in a # dictionary mapping the option name to information about it. The # information is currently just a tuple (default, validator). The # validator is a function which takes a string and returns a boolean # value. # # This is a dictionary mapping section names to dictionaries mapping # configuration names to configuration options sections={} # This is the stored configuration itself: a dictionary mapping # section names to a dictionary mapping option names to option values. # # This is separate from the logical configuration because it's a # different beast: it represents what was found in the configuration # file, rather than the list of "known" configuration options. config={} for fn in [os.path.expanduser('~/.musiclibrarian/config')]: try: f=open(fn) except: continue configfile.read_config_file(f, config) # A dictionary mapping pairs (section name, option value) to # Listenable objects. listenables={} # Add a new configuration option. def add_option(section, name, default=None, validator=lambda x:True): sect=sections.setdefault(section, {}) if sect.has_key(name): warn('Option %s/%s was multiply defined, overriding old definition'%(section,name)) if not validator(default): raise ConfigInternalError,'Inconsistent option definition: %s does not pass the validator for %s/%s'%(default,section,name) sect[name]=(default,validator) def lookup_option(section, name): if not sections.has_key(section): raise ConfigInternalError,'Configuration section %s does not exist'%section sect=sections[section] if not sect.has_key(name): raise ConfigInternalError,'No configuration option %s in section %s'%(name,section) return sect[name] def add_listener(section, name, listener, as_weak=True): """ Add a listener on a configuration option. Returns an opaque token associated with the listener. Listeners are called with this signature: listener(section, name, old_val, new_val)""" l = listenables.get((section, name), None) if l == None: l = listenable.Listenable() listenables[(section, name)] = l return l.add_listener(listener, as_weak) def remove_listener(section, name, listener): """Remove a listener, given the id returned by add_listener or a listener reference (as for Listenable.remove_listener).""" l = listenables.get((section, name), None) if l == None: warn('No listener object for configuration item %s:%s'%(section,name)) else: l.remove_listener(listener) def get_option(section, name): """Return the option 'name' in the section 'section'. If the value has not been set by the user, the default value for this option is returned.""" default,validator=lookup_option(section, name) optval=config.get(section, {}).get(name, default) if not validator(optval): sys.stderr.write('%s is not a valid setting for %s/%s.\nReverting to default %s\n'%(optval, section, name, str(default))) optval=default return optval def set_option(section, name, val): """Set the option 'name' in the section 'section' to 'val', calling listeners as appropriate.""" default,validator=lookup_option(section, name) oldval=config.get(section, {}).get(name, default) if not validator(val): raise ConfigInternalError,'%s is not a valid setting for %s/%s'%(val, section, name) config.setdefault(section, {})[name]=val if sync: save_options() # To save time here, we don't compare oldval and val... l = listenables.get((section, name), None) if l <> None: l.call_listeners(section, name, oldval, val) # Saves options. # # Changes to defaults are only preserved if set_option has never been # called for that setting. Simply being equal to the default isn't # enough. (should I change this?) def save_options(): progdir=os.path.expanduser(os.path.join('~','.musiclibrarian')) if not os.path.isdir(progdir): os.mkdir(progdir) tmpname=os.path.join(progdir, 'config.%s'%os.getpid()) configfile.write_config_file(open(os.path.join(progdir, tmpname), 'w'), config) os.rename(tmpname, os.path.join(progdir, 'config')) musiclibrarian-1.6/musiclibrarian/configfile.py0000644000175000017500000000602207742255517022577 0ustar danieldaniel00000000000000# configfile.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA from serialize import skip_ws import serialize # A configuration file is simply a bunch of nested dictionaries. # However, the syntax is a bit different, for human-readability: # # ConfigFile ::= Section* # Section ::= { ( = PEXP)* } # # Nested sections are not presently supported, although it wouldn't be # hard to support them. # "dict" here is a dictionary which will be read into. If it is not # specified, a new dictionary is created and returned. def read_config_file(f, rval={}, reader=serialize.Reader()): # Return True iff EOF was *not* reached. def read_section(name): loc,c=skip_ws(f) while c <> '}': option='' if not c.isalpha(): raise IOError,'Bad character in identifier name' while c.isalnum(): option+=c c=f.read(1) if c.isspace(): loc,c=skip_ws(f) if c <> '=': raise IOError,'In configuration file: expected \'=\', got \'%s\''%c val=reader.read(f) rval.setdefault(name, {})[option]=val loc,c=skip_ws(f) loc,c=skip_ws(f) while c <> '': if not c.isalpha(): raise IOError,'Bad character in section name: %s'%c else: section='' while c.isalnum(): section+=c loc=f.tell() c=f.read(1) if c.isspace(): loc,c=skip_ws(f) if c <> '{': raise IOError, 'Parsing configuration file: expected \'{\', got \'%s\''%c read_section(section) loc,c=skip_ws(f) return rval # Inverse of the above. Assumes its input is in the correct # format. (a dictionary of dictionaries, with appropriate keys) def write_config_file(f, cfg, writer=serialize.Writer()): sections=cfg.items() sections.sort(lambda a,b:cmp(a[0],b[0])) for section,optionsdict in sections: f.write('%s {\n'%section) options=optionsdict.items() options.sort(lambda a,b:cmp(a[0], b[0])) for name, val in options: f.write(' %s = '%name) writer.write(f, val, 5+len(name)) f.write('\n') f.write('}\n') musiclibrarian-1.6/musiclibrarian/filecollection.py0000644000175000017500000001321310152763762023460 0ustar danieldaniel00000000000000# filecollection.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import genreset import listenable # This is a base class for objects that contain some number of files # and need to keep track of how many of the files they contain have # particular (key, value) mappings. Children are responsible for # calling inc_count and dec_count as values change or files are removed. # # This actually implements part of the list-item interface. class FileCollection(listenable.Listenable): def __init__(self, gui): listenable.Listenable.__init__(self) # This contains a dictionary mapping tags to dictionaries # mapping values to their occurance count. self.contained_values={} self.gui=gui # Returns the visible comments as a dictionary. Part of the # list-item interface. def get_comments(self): rval={} for tag,countdict in self.contained_values.items(): if len(countdict)==1: rval[tag]=[countdict.keys()[0]] return rval # So we can add TagCollections to TagCollections. def items(self): rval=[] for tag,countdict in self.contained_values.items(): for val,count in countdict.items(): rval.append((tag,[val],count)) return rval # Used to determine whether the title should be editable. The # idea is that you almost never want to set multiple songs to have # an identical title, so the interface shouldn't encourage you to # do it accidentally. # # The title could be editable iff this collection contains exactly # one item, but that gets sketchy if items are added and removed. # Subclasses can of course override this. def title_editable(self): return False # Adjusts the contained count to remove the given counts. The # counts are given as a set of tuples (key, val, count), or as a # set of tuples (key, val). If there is no count, it is assumed # that the count is 1. Note that values --must-- be lists of # strings. def dec_count(self, items): oldcomments=self.get_comments() for item in items: tag=item[0] val=item[1] if len(item)>2: count=item[2] else: count=1 uptag=tag.upper() countdict=self.contained_values[uptag] if len(val)>0: val0=val[0] assert(countdict[val0]>=count) countdict[val0]=countdict[val0]-count if countdict[val0]==0: del countdict[val0] if len(countdict)==0: del self.contained_values[uptag] if oldcomments <> self.get_comments(): self.call_listeners(self, oldcomments) # Adjusts the contained count to add the given dictionary (given # as items) def inc_count(self, items): oldcomments=self.get_comments() for item in items: tag=item[0] val=item[1] if len(item)>2: count=item[2] else: count=1 uptag=tag.upper() countdict=self.contained_values.setdefault(uptag, {}) if len(val)>0: countdict[val[0]]=countdict.get(val[0], 0)+count if oldcomments <> self.get_comments(): self.call_listeners(self, oldcomments) # This is used for sorting and generating column data; it works by # checking for information which is identical across all children # using contained_values. # # Part of the list-item interface def get_tag(self, tag): countdict=self.contained_values.get(tag.upper(), {}) if len(countdict)==1: return [countdict.keys()[0]] else: return [] # Part of the list-item interface def set_tag(self, tag, val): self.gui.open_undo_group() for item in self.children(): item.set_tag(tag, val) self.gui.close_undo_group() def valid_genres(self): """Returns the intersection of the valid genre sets of all the members of this collection.""" rval=genreset.GenreSet(True) # Note: if more complex set operations are needed, this may be a bit # inefficient; can anything be done to maintain this # information incrementally? (right now this is very # efficient because of the optimization of intersect() for # the special case when we have either the set of all strings # or a unique object containing a finite list of strings) for child in self.children(): rval=rval.intersect(child.valid_genres()) return rval # Tests if this node is empty. Part of the list-item interface # # Note: this is a little bit of a hack -- it assumes that every # file has at least one tag. def empty(self): return len(self.children())==0 musiclibrarian-1.6/musiclibrarian/filelist.py0000644000175000017500000000443307745271475022315 0ustar danieldaniel00000000000000# filelist.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import filecollection # A class that provides a simple implementation of a file collection, just # based on a list of files. class FileList(filecollection.FileCollection): def __init__(self, gui, items=[]): filecollection.FileCollection.__init__(self, gui) self.__contents=[] for item in items: self.add_file(item) def children(self): return self.__contents def add_file(self, file): # yuck, expensive. Cheat and use a dictionary? if not file in self.__contents: self.inc_count(file.items()) self.__contents.append(file) file.add_listener(self.file_updated) def remove_file(self, file): if file in self.__contents: self.dec_count(file.items()) self.__contents.remove(file) file.remove_listener(self.file_updated) def file_updated(self, file, olddict): self.dec_count(olddict.items()) self.inc_count(file.items()) def title_editable(self): return len(self.__contents)==1 # Editable interface -- return a string or None if no label is # appropriate. def get_label(self): if len(self.__contents) <> 1: return None else: titles=self.__contents[0].get_tag('TITLE') if len(titles)>0: return titles[0] elif hasattr(self.__contents[0], 'filename'): return self.__contents[0].filename else: return None musiclibrarian-1.6/musiclibrarian/filestore.py0000644000175000017500000002067610162337360022464 0ustar danieldaniel00000000000000# filestore.py # Copyright 2004 Daniel Burrows # # # This maps filenames to active file objects. It is responsible for # managing file objects that exist during the program's execution (as # opposed to the cache). # # This could be merged with the cache, but then I'd have to write # custom picklers to only pickle the cache data, which doesn't work # with cPickle, and the only real advantage is that you don't have to # maintain the cache data separately from the store. Given that # invalid cache info is explicitly allowed (it just has to be # recalculated on startup), this is not a huge deal. # # Circular symlinks will be broken. If a file has multiple names, # some of the names will be arbitrarily discarded. import cache import musicfile import os import os.path import sets import sys class FileStoreFileOperationError(Exception): """An error that indicates that an operation failed on some files. The recommended (multi-line) error message is stored in the strerror attribute.""" def __init__(self, failed, strerror): self.failed=failed self.__strerror=strerror def __getattr__(self, name): if name == 'strerror': def make_error(x): fn,strerror=x if strerror == None: return fn else: return '%s (%s)'%(fn,strerror) self.strerror='%s%s'%(self.__strerror,'\n'.join(map(make_error, self.failed))) return self.strerror else: raise AttributeError, name class SaveError(FileStoreFileOperationError): """An error that indicates that some files failed to be saved.""" def __init__(self, failed): FileStoreFileOperationError.__init__(self, failed, 'Changes to the following files could not be saved:\n') def __str__(self): # Return a list of the failed files return 'Failed to save files: %s'%','.join(map(lambda x:x[0], self.failed)) class LoadError(FileStoreFileOperationError): """An error that indicates that some files failed to be saved.""" def __init__(self, failed): FileStoreFileOperationError.__init__(self, failed, 'The following files could not be read:\n') def __str__(self): # Return a list of the failed files return 'Failed to load files: %s'%','.join(map(lambda x:x[0], self.failed)) class NotDirectoryError(Exception): """This error is raised when a non-directory is passed to the add_dir method.""" def __init__(self, dir): Exception.__init__(self, dir) self.strerror='%s is not a directory'%dir def fname_ext(fn): "Returns the extension of the given filename, or None if it has no extension." if not '.' in fn or fn.rfind('.')==len(fn)-1: return None return fn[fn.rfind('.')+1:] class FileStore: """A collection of music files, indexed by name. Files are added to the store using the add_dir and add_file functions. Each file has exactly one corresponding 'file object' in the store, whose lifetime is equal to that of the store itself.""" def __init__(self, cache): """Initializes an empty store attached to the given cache.""" self.files={} self.modified_files=sets.Set() self.inodes=sets.Set() self.cache=cache def add_dir(self, dir, callback=lambda cur,max:None, set=None): """Adds the given directory and any files recursively contained inside it to this file store. 'set' may be a mutable set; file objects added as a result of this operation will be placed in 'set'.""" if not os.path.isdir(dir): raise NotDirectoryError(dir) candidates=[] self.__find_files(dir, sets.Set(), candidates, callback) cur=0 max=len(candidates) failed=[] for fn in candidates: callback(cur, max) cur+=1 try: self.add_file(fn, set) except LoadError,e: failed+=e.failed callback(max, max) self.cache.flush() if failed <> []: raise LoadError(failed) # Finds all files in the given directory and subdirectories, # following symlinks and avoiding cycles. def __find_files(self, dir, seen_dirs, output, callback=lambda cur,max:None): """Returns a list of all files contained in the given directory and subdirectories which have an extension that we recognize. The result is built in the list 'output'.""" assert(os.path.isdir(dir)) dir_ino=os.stat(dir).st_ino callback(None, None) if dir_ino not in seen_dirs and os.access(dir, os.R_OK|os.X_OK): seen_dirs.add(dir_ino) for name in os.listdir(dir): fullname=os.path.join(dir, name) if os.path.isfile(fullname) and musicfile.file_types.has_key(fname_ext(fullname)): output.append(fullname) elif os.path.isdir(fullname): self.__find_files(fullname, seen_dirs, output, callback) return output def add_file(self, fn, set=None): """Adds the given file to the store. If an exception is raised when we try to open the file, print it and continue. 'set' may be a mutable set, in which case any file object which is successfully created will be added to it.""" fn=os.path.normpath(fn) if self.files.has_key(fn): # We've already got one! (it's very nice, too) set.add(self.files[fn]) return st=os.stat(fn) file_ino=st.st_ino if file_ino not in self.inodes and os.access(fn, os.R_OK): self.inodes.add(file_ino) # Find the file extension and associated handler ext=fname_ext(fn) if musicfile.file_types.has_key(ext): try: try: cacheinf=self.cache.get(fn, st) except: cacheinf=None new_file=musicfile.file_types[ext](self, fn, cacheinf) self.files[fn]=new_file self.cache.put(new_file, st) if set <> None: set.add(new_file) except EnvironmentError,e: raise LoadError([(fn, e.strerror)]) except musicfile.MusicFileError,e: raise LoadError([(fn, e.strerror)]) except: raise LoadError([(fn, None)]) def commit(self, callback=lambda cur,max:None, S=None): """Commit all changes to files in the set S to the store. If S is not specified or None, it defaults to the entire store.""" if S == None: modified=self.modified_files else: modified=S & self.modified_files cur=0 max=len(modified) failed=[] for f in modified: callback(cur, max) cur+=1 try: f.commit() except EnvironmentError,e: failed.append((f.fn,e.strerror)) except musicfile.MusicFileError,e: failed.append((f.fn,e.strerror)) except: failed.append((f.fn,None)) try: cache.put(f) except: pass callback(max, max) if failed <> []: raise SaveError(failed) def revert(self, callback=lambda cur,max:None, S=None): """Revert all modifications to files in the set S. If S is not specified or None, it defaults to the entire store.""" if S == None: modified=self.modified_files else: modified=S & self.modified_files cur=0 max=len(modified) for f in modified: callback(cur, max) cur+=1 f.revert() callback(max, max) def set_modified(self, file, modified): """Updates whether the given file is known to be modified.""" if modified: self.modified_files.add(file) else: self.modified_files.remove(file) def modified_count(self, S=None): """Returns the number of files in the set S that are modified. If S is not specified or None, it defaults to the entire store.""" if S == None: modified=self.modified_files else: modified=S & self.modified_files return len(modified) musiclibrarian-1.6/musiclibrarian/genreset.py0000644000175000017500000000353110137276653022304 0ustar danieldaniel00000000000000# genreset.py - represents a "set" of valid genres. # # Copyright 2004 Daniel Burrows import sets class GenreSet: """Represents a set of genres. It can be either a finite listing of genres, or an infinite set containing all genres.""" def __init__(self, genres): """Instantiate a GenreSet. If genres is True, every genre is contained in this set; otherwise, it must be a list of strings enumerating the genres in the set or a dictionary whose keys are the set members.""" if genres == True or isinstance(genres, sets.ImmutableSet): self.genres=genres else: assert(type(genres)==list) self.genres=sets.ImmutableSet(map(lambda x:x.upper(), genres)) def member(self, genre): """Returns true if the given genre is contained in this set of genres.""" return self.genres == True or genre.upper() in self.genres def members(self): """Returns a list of all genres in this set, or True.""" if self.genres == True: return True else: return list(self.genres) def union(self, other): """Returns the union of this set with another set of genres.""" # Special case when the object identities are the same. if self == other: return self elif self.genres == True: return self elif other.genres == True: return other else: return GenreSet(self.genres | other.genres) def intersect(self, other): """Returns the intersection of this set with another set of genres.""" if self == other: return self elif self.genres == True: return other elif other.genres == True: return self else: return GenreSet(self.genres & other.genres) musiclibrarian-1.6/musiclibrarian/library.py0000644000175000017500000000477410152637155022141 0ustar danieldaniel00000000000000# library.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Code to manage a library. A library is just a collection of files # that happen to live under one or more directories. import filestore import sets class MusicLibrary: def __init__(self, store, dirs, callbacks): """Initialize a library for the given list of directories. The list of callbacks corresponds to the list of directories.""" self.dirs=dirs self.files=sets.Set() # We use the add_dir function in this way because, frankly, # it's fast enough and a lot easier than making a fancy # database that's indexed by filename *and* efficient; this # also avoids the issue of changes on-disk for now. # # In the future, I may want to have a full mirror of the # on-disk structure, though, so that I can use fam to detect # added and removed files. (?) self.store=store self.add_dirs(dirs, callbacks) def add_dirs(self, dirs, callbacks): """Add the given directories to this library. Mainly meant to be used to set the directory list of the library after initializing the library object for better exception handling.""" failed=[] for dir,callback in zip(dirs, callbacks): try: self.store.add_dir(dir, callback, self.files) self.dirs.append(dir) except filestore.LoadError,e: failed+=e.failed if failed <> []: raise filestore.LoadError(failed) def commit(self, callback): self.store.commit(callback, self.files) def revert(self, callback): self.store.revert(callback, self.files) def modified_count(self): return self.store.modified_count(self.files) musiclibrarian-1.6/musiclibrarian/libraryeditor.py0000644000175000017500000004245210164603652023341 0ustar danieldaniel00000000000000# libraryeditor.py # # Copyright (C) 2004 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Code to edit the list of directories that is contained within a # library. import config import listenable import gobject import gtk import operator import os import sets from warnings import warn def valid_library(lib): """Tests if lib is a valid library: a string or a list of strings.""" if isinstance(lib, basestring): return True if isinstance(lib, list) and reduce(operator.__and__, map(lambda x:isinstance(x, basestring), lib), True): return True return False # Defined libraries: config.add_option('General', 'DefinedLibraries', {}, lambda x:reduce(operator.__and__, map(lambda (key,val):isinstance(key, basestring) and valid_library(val), x.items()), True)) # The library to load on startup. If None, load no library on startup. config.add_option('General', 'DefaultLibrary', None, lambda x:x == None or isinstance(x, basestring)) library_edits=listenable.Listenable() """This signal is emitted when a library edit is committed. Its single argument is a list of tuples (old_name, new_name) where each old_name is the name of a library, and the new_name is the library's new name or None if it has been deleted.""" class LibraryEditor: """Wraps the dialog that edits the library definitions. Internal use, don't touch.""" def __init__(self, glade_location): """Initializes the dialog from Glade and sets up bookkeeping information.""" xml=gtk.glade.XML(glade_location, root='library_dialog') xml.signal_autoconnect({'new_library' : self.new_library, 'delete_library' : self.delete_library, 'rename_library' : self.rename_library, 'add_directory' : self.handle_add_directory, 'remove_directory' : self.remove_directory}) # Widget extraction self.new_button=xml.get_widget('new') self.delete_button=xml.get_widget('delete') self.rename_button=xml.get_widget('rename') self.add_button=xml.get_widget('add') self.remove_button=xml.get_widget('remove') self.libraries_list=xml.get_widget('libraries_list') self.directories_list=xml.get_widget('directories_list') self.directories_label=xml.get_widget('directories_label') self.gui=xml.get_widget('library_dialog') self.add_button.set_sensitive(0) self.remove_button.set_sensitive(0) self.delete_button.set_sensitive(0) self.rename_button.set_sensitive(0) # Get the shared list of libraries. self.libraries={} for key,val in config.get_option('General', 'DefinedLibraries').iteritems(): self.libraries[key]=sets.Set(val) # Used to "remember" what the currently selected library is, # to avoid unnecessary adjustments of other widgets. self.selected_library=None self.default_library=config.get_option('General', 'DefaultLibrary') self.filesel=None # Used to track the history of each library that originally existed. self.library_new_names={} self.library_orig_names={} for key in self.libraries.keys(): self.library_new_names[key]=key self.library_orig_names[key]=key self.libraries_model=gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_BOOLEAN) self.directories_model=gtk.ListStore(gobject.TYPE_STRING) renderer=gtk.CellRendererText() renderer.set_property('editable', 1) renderer.connect('edited', self.handle_library_name_edited) col=gtk.TreeViewColumn('Library', renderer, text=0) self.libraries_list.append_column(col) renderer=gtk.CellRendererToggle() renderer.set_radio(1) renderer.set_property('activatable', 1) renderer.connect('toggled', self.handle_default_library_toggled) col=gtk.TreeViewColumn('Default', renderer, active=1) self.libraries_list.append_column(col) col=gtk.TreeViewColumn('Directories', gtk.CellRendererText(), text=0) self.directories_list.append_column(col) self.libraries_list.set_model(self.libraries_model) self.directories_list.set_model(self.directories_model) def sort_by_column_data(model, iter1, iter2): return cmp(model[iter1][0], model[iter2][0]) self.libraries_model.set_default_sort_func(sort_by_column_data) self.libraries_model.set_sort_column_id(-1, gtk.SORT_ASCENDING) self.directories_model.set_default_sort_func(sort_by_column_data) self.directories_model.set_sort_column_id(-1, gtk.SORT_ASCENDING) libraries_selection=self.libraries_list.get_selection() libraries_selection.connect('changed', lambda *args:self.library_changed(libraries_selection)) directories_selection=self.directories_list.get_selection() directories_selection.connect('changed', lambda *args:self.directory_changed(directories_selection)) self.update_library_list() self.gui.connect('response', self.handle_response) def handle_response(self, dialog, response_id): """Deal with our own response, by pumping the changed value back into the configuration system.""" assert(dialog == self.gui) if response_id == gtk.RESPONSE_OK: # Trigger library edit events: library_edits.call_listeners(self.library_new_names.items()) # Copy and convert back to lists. new_libraries={} for key,val in self.libraries.iteritems(): new_libraries[key]=list(val) config.set_option('General', 'DefinedLibraries', new_libraries) config.set_option('General', 'DefaultLibrary', self.default_library) dialog.destroy() # Call this when the list of libraries needs to be regenerated # from scratch: def update_library_list(self): selected=self.selected_library model=gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_BOOLEAN) names=self.libraries.keys() names.sort() selected_iter=None for name in names: isdefault=(name == self.default_library) my_iter=model.append([name, isdefault]) if name == selected: selected_iter = my_iter self.libraries_list.set_model(model) self.libraries_model=model if selected_iter <> None: self.libraries_list.get_selection().select_iter(selected_iter) # Similarly def update_directory_list(self): """Synchronizes the displayed list of directories to be consistent with the directories associated with self.selected_library.""" selected=self.selected_library self.directories_model.clear() if selected == None: self.directories_label.set_markup('No library selected') self.add_button.set_sensitive(0) else: self.directories_label.set_markup('Contents of %s'%selected) for d in self.libraries[selected]: self.directories_model.append([d]) self.add_button.set_sensitive(1) def library_changed(self, selection): """Handles the selection of a row in the 'library' list.""" if self.filesel <> None: self.filesel.destroy() if selection.count_selected_rows() == 0: self.selected_library=None self.directories_model.clear() self.delete_button.set_sensitive(0) self.rename_button.set_sensitive(0) self.update_directory_list() else: model,paths=selection.get_selected_rows() assert(len(paths)==1) new_selection=model[paths[0]][0] if new_selection <> self.selected_library: self.selected_library=new_selection self.update_directory_list() self.delete_button.set_sensitive(1) self.rename_button.set_sensitive(1) def directory_changed(self, selection): """Handles the selection of a row in the 'directory' list.""" if selection.count_selected_rows() == 0: self.remove_button.set_sensitive(0) else: self.remove_button.set_sensitive(1) def new_library(self, widget=None, name=None, start_editing=True, *args): """Handles clicks on the 'New Library' button by generating a new library. Returns a TreeIter corresponding to the location of the new library in the model tree.""" if name == None: name='New Library' x=2 while self.libraries.has_key(name): name='New Library %d'%x x+=1 # The first library you create is the default library (by default). isdefault=(len(self.libraries)==0) self.libraries[name]=sets.Set() if isdefault: self.default_library=name self.library_orig_names[name]=None iter=self.libraries_model.append([name, isdefault]) self.libraries_list.get_selection().select_iter(iter) path=self.libraries_model.get_path(iter) self.libraries_list.set_cursor(path, self.libraries_list.get_column(0), start_editing) return path,iter def delete_library(self, *args): selection=self.libraries_list.get_selection() assert(self.selected_library <> None) assert(selection.count_selected_rows() == 1) model,paths=selection.get_selected_rows() curr_iter=model[paths[0]] assert(self.selected_library == curr_iter[0]) if self.selected_library == self.default_library: self.default_library = None if self.library_new_names.has_key(self.selected_library): self.library_new_names[self.selected_library]=None del self.library_orig_names[self.selected_library] del self.libraries[self.selected_library] del curr_iter del model[paths[0]] def rename_library(self, *args): model,paths=self.libraries_list.get_selection().get_selected_rows() assert(len(paths)==1) self.libraries_list.grab_focus() self.libraries_list.set_cursor(paths[0], self.libraries_list.get_column(0), True) def handle_library_name_edited(self, cell, path, new_text): row=self.libraries_model[path] old_text=row[0] assert(self.libraries.has_key(old_text)) if new_text == old_text: # Do nothing to avoid unnecessary recomputation. return if self.libraries.has_key(new_text): m=gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format='The library "%s" already exists.'%new_text) m.connect('response', lambda *args:m.destroy()) m.show() return self.libraries[new_text]=self.libraries[old_text] self.library_orig_names[new_text]=self.library_orig_names[old_text] orig_name=self.library_orig_names[new_text] if orig_name <> None: self.library_new_names[orig_name]=new_text if old_text == self.selected_library: self.selected_library=new_text if old_text == self.default_library: self.default_library=new_text del self.libraries[old_text] del self.library_orig_names[old_text] self.libraries_model[path]=(new_text,row[1]) self.libraries_model.sort_column_changed() def handle_default_library_toggled(self, cell, path): row=self.libraries_model[path] old_value=row[1] if old_value: self.default_library=None self.libraries_model[path]=(row[0],0) else: if self.default_library <> None: # Just wipe out all default settings. Inefficient but # safe, and should be OK if only a few libraries # exist. for tmprow in self.libraries_model: tmprow[1]=0 # Commit the new default. self.libraries_model[path]=(row[0], 1) self.default_library=row[0] def __do_add_directories(self, fns): assert(reduce(operator.__and__, map(lambda x:os.path.isdir(x), fns), True)) dirs=self.libraries[self.selected_library] for d in fns: if d not in dirs: dirs.add(d) self.directories_model.append([d]) self.directories_model.sort_column_changed() def add_directories(self, iter, dirs): """Add some directories to a library. iter is the TreeIter of the library to be modified, in the library list TreeView.""" self.libraries_list.get_selection().select_iter(iter) self.__do_add_directories(dirs) def handle_add_directory(self, *args): """Prompt the user and a new directory to a library.""" assert(self.selected_library <> None) if self.filesel <> None: self.filesel.show() return def on_response(dialog, response_id): if response_id <> gtk.RESPONSE_OK: # Do nothing. self.filesel.destroy() return fns=self.filesel.get_filenames() self.filesel.destroy() self.__do_add_directories(fns) def on_destroy(*args): self.filesel=None self.filesel=gtk.FileChooserDialog( title='Choose a directory to add to %s'%self.selected_library, action=gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER, buttons=(gtk.STOCK_OK, gtk.RESPONSE_OK, gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)) self.filesel.connect('response', on_response) self.filesel.connect('destroy', on_destroy) self.filesel.set_select_multiple(True) self.filesel.show() def remove_directory(self, *args): selected=self.selected_library assert(selected) lib=self.libraries[selected] selection=self.directories_list.get_selection() model,paths=selection.get_selected_rows() for p in paths: d=model[p][0] if d not in lib: warn('Warning: trying to remove %s from library %s, but it\'s not there!'%(d, selected)) self.libraries[selected].remove(d) # Future-proofing for the possibility of enabling multi-selections. # # We need to get all iterators first, because iterators don't # go bad when you delete them. iters=map(lambda x:self.directories_model.get_iter(x), paths) for i in iters: self.directories_model.remove(i) # Don't need to signal a sort update, since removing rows # never affects the sorted order. active_library_editor=None """Internal use, don't touch.""" def show_library_editor(glade_location, name=None, dirs=None): """Create and display the dialog that edits library definitions. If a dialog is already open, just bring it to the front. glade_location is the location from which the glade file should be loaded. If 'name' is not None, a new library named 'name' (possibly mangled to make it unique) will be created. If, in addition, 'dirs' is not None, the new library will initially contain the directories specified in 'dirs'. This function prevents a situation where two editors are running at once and Bad Stuff happens.""" global active_library_editor def zap_library_editor(*args): global active_library_editor active_library_editor=None if active_library_editor <> None: active_library_editor.gui.present() else: active_library_editor=LibraryEditor(glade_location) active_library_editor.gui.connect('destroy', zap_library_editor) if name <> None: p,i=active_library_editor.new_library(name=name, start_editing=False) if dirs <> None: active_library_editor.add_directories(i, dirs) musiclibrarian-1.6/musiclibrarian/libraryview.py0000644000175000017500000010426110164621366023024 0ustar danieldaniel00000000000000# libraryview.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # A class that handles the main window of the program. Currently, it # modifies and stores configuration data based on the assumption that # it is the only "view" that is open, meaning that you cannot easily # create more than one instance without Weird Stuff happening. import gtk import gtk.glade import gtk.gdk import gobject import operator import os import string import sys import config import filecollection import filelist import filestore # For filestore.SaveError import library import organize import libraryeditor import progress import status import tags import treemodel import toplevel import types import undo # A list of hooks which are called for each new library view. new_view_hooks=[] def add_new_view_hook(f): """Add a new function to be called whenever a library view is created. The view is passed as an argument to the function. If any views have already been created, the function will immediately be called on them.""" new_view_hooks.append(f) # Tags that are visible in the tree view. They are stored as a # colon-separated list. # # adjust some tag visibilities with organization changes? config.add_option('LibraryView', 'VisibleColumns', ['tracknumber', 'genre'], lambda x: type(x)==types.ListType and reduce(lambda a,b:a and b, map(lambda y:tags.known_tags.has_key(y), x))) # The current organization of the view. config.add_option('LibraryView', 'Organization', 'ArtistAlbum', lambda x: type(x)==types.StringType and organize.CANNED_ORGANIZATIONS.has_key(x)) # The last few (5?) libraries that were loaded. config.add_option('LibraryView', 'PastLibraries', [], lambda x: type(x)==types.ListType and reduce(operator.__and__, map(lambda y:(isinstance(y, tuple) and len(y) == 1 and isinstance(y[0], basestring)) or libraryeditor.valid_library(y), x), True)) # Column generators compartmentalize the generation of columns. They # contain information about how to create a column in the model. The # get_type() method on a column generator returns the GType which is # appropriate for the column, and the get_value() method returns a # value of the appropriate type. # A class to generate a column from a string tag. If several entries # are available, the first one is arbitrarily chosen. (a Tag object # (as below) is stored) class StringTagColumn: def __init__(self, tag): self.tag=tag def get_title(self): return self.tag.title def get_tag(self): return self.tag.tag def get_type(self): return gobject.TYPE_STRING def get_value(self, f): vals=f.get_tag(self.tag.tag) if len(vals)>0: return vals[0] else: return None def set_value(self, f, val): f.set_tag(self.tag.tag, val) class MusicLibraryView: def __init__(self, store, glade_location): toplevel.add(self) self.store=store self.current_organization=organize.CANNED_ORGANIZATIONS[config.get_option('LibraryView', 'Organization')] self.glade_location=glade_location xml=gtk.glade.XML(glade_location, root='main_window') # Connect up the signals. # This (ab)uses the undo-manager to manage other things that need # to be updated every time a file's state changes. self.undo_manager=undo.UndoManager(self.__cleanup_after_change, self.__cleanup_after_change, self.__cleanup_after_change) xml.signal_autoconnect({'close' : self.close, 'quit_program' : self.quit_program, 'on_row_activated' : self.handle_row_activated, 'on_open_directory1_activate' : self.handle_open_directory, 'on_edit_libraries_activate' : self.handle_edit_libraries, 'on_create_library_activate' : self.handle_create_library, 'on_revert_changes1_activate' : self.handle_revert_changes, 'on_save_changes' : self.handle_save_changes, 'on_undo1_activate' : self.undo_manager.undo, 'on_redo1_activate' : self.undo_manager.redo, 'on_list_button_press' : self.handle_button_press}) # Extract widgets from the tree self.music_list=xml.get_widget('music_list') self.status=status.Status(xml.get_widget('statusbar1')) self.status_progress=xml.get_widget('status_progress') self.status_progress.hide() self.toolbar=xml.get_widget('toolbar') self.main_menu=xml.get_widget('main_menu') self.file_menu=xml.get_widget('file_menu').get_submenu() self.edit_menu=xml.get_widget('edit_menu').get_submenu() self.view_menu=xml.get_widget('view_menu').get_submenu() self.file_open_directory_item=xml.get_widget('open_directory_item') self.file_open_library_item=xml.get_widget('open_library_menu') self.file_open_recent_item=xml.get_widget('open_recent_menu') self.file_save_item=xml.get_widget('save_changes_item') self.file_revert_item=xml.get_widget('discard_changes_item') self.undo_item=xml.get_widget('undo_item') self.redo_item=xml.get_widget('redo_item') self.organizeview_item=xml.get_widget('organize_view') self.showhide_item=xml.get_widget('show/hide_columns') self.main_widget=xml.get_widget('main_window') # FIXME: the text on toolbar buttons doesn't match the text in # the menu for space reasons, but it's ugly to have this # discrepancy. self.open_button=xml.get_widget('toolbar_open') self.save_button=xml.get_widget('toolbar_save') self.revert_button=xml.get_widget('toolbar_revert') self.undo_button=xml.get_widget('toolbar_undo') self.redo_button=xml.get_widget('toolbar_redo') self.update_undo_sensitivity() # Make sure the recent items start out hidden/insensitive. self.file_open_recent_item.hide() self.file_open_recent_item.set_sensitive(0) # Set up the context menu. self.context_menu=gtk.Menu() self.tooltips=gtk.Tooltips() self.library_name=None self.new_library_name=None self.past_libraries=[] pl=config.get_option('LibraryView', 'PastLibraries') # This line is needed so that the list of past libraries # doesn't flip every time it's loaded: pl.reverse() for x in pl: self.add_past_library(x) config.add_listener('General', 'DefinedLibraries', self.update_library_menu) self.update_library_menu() self.selection=self.music_list.get_selection() # Allow multiple selection self.selection.set_mode(gtk.SELECTION_MULTIPLE) self.model=None self.library=None self.building_tree=False self.restart_build_tree=False self.destroyed=False self.toolbar_customized=False self.update_organizations() self.update_known_tags() selection=self.music_list.get_selection() # Allow multiple selection selection.set_mode(gtk.SELECTION_MULTIPLE) # Call hooks for f in new_view_hooks: f(self) # set_column_drag_function isn't wrapped -- use this once it # is: # #def handledrop(view, col, prev, next): # return prev <> None # #self.music_list.set_column_drag_function(handledrop) for x in [self.file_save_item, self.save_button, self.file_revert_item, self.revert_button]: x.set_sensitive(False) self.status.set_message('You have not opened a library') config.add_listener('General', 'DefinedLibraries', self.handle_library_edited) libraryeditor.library_edits.add_listener(self.handle_library_name_changes) def handle_library_name_changes(self, changes): for old_name,new_name in changes: if old_name == self.new_library_name: self.new_library_name = new_name def handle_button_press(self, treeview, event): if event.type == gtk.gdk.BUTTON_PRESS and event.button == 3 and len(self.context_menu.get_children())>0: loc=treeview.get_path_at_pos(int(event.x), int(event.y)) if not loc: return False path,col,x,y=loc treeview.grab_focus() treeview.set_cursor(path, col, False) model=treeview.get_model() iter=model.get_iter(path) obj=model.get_value(iter, treemodel.COLUMN_PYOBJ) # Disgusting, but pygtk provides no way to pass data at # popup time :-( self.context_menu.popup_data=obj,col,path self.context_menu.popup(None, None, None, event.button, event.time) return True return False def handle_row_activated(self, treeview, path, column): if not path: return False treeview.grab_focus() treeview.set_cursor(path, column, False) model=treeview.get_model() iter=model.get_iter(path) obj=model.get_value(iter, treemodel.COLUMN_PYOBJ) self.do_edit_cell(obj, column, path) return True def add_menubar_item(self, item): """Adds the given menu item to the menu bar.""" return self.main_menu.append(item) def add_edit_menu_item(self, item): """Adds the given menu item to the end of the edit menu.""" return self.edit_menu.append(item) def add_toolbar_item(self, text, tooltip_text, tooltip_private_text, icon, callback, *args): """Adds a new item to the toolbar in the main window. The arguments to this function are identical to the arguments to gtk.Toolbar.append_item().""" if self.toolbar_customized == False: self.toolbar.insert(gtk.SeparatorToolItem(), -1) self.toolbar_customized=True rval=gtk.ToolButton(icon, text) self.toolbar.insert(rval, -1) rval.connect('clicked', callback, *args) self.tooltips.set_tip(rval, tooltip_text, tooltip_private_text) return rval def add_context_menu_item(self, title, tooltip, icon, callback): """Adds a new item to the music list's popup context menu. The callback is called with three arguments: the object representing the row that was clicked on, an object (a GtkCellRenderer) representing the column was clicked on, and a tree path representing the tree item that was clicked on.""" if icon: item=gtk.ImageMenuItem(title) im=gtk.Image() im.set_from_stock(icon, gtk.ICON_SIZE_MENU) im.show() item.set_image(im) else: item=gtk.MenuItem(title) self.tooltips.set_tip(item, tooltip, '') item.connect('activate', lambda item:apply(callback,self.context_menu.popup_data)) item.show() self.context_menu.append(item) # Maybe this should be done by inheriting from a widget and using # standard GTK signal connection? def add_selection_listener(self, f): """Registers f as a callback for changes to the selection. Whenever the selection changes, f will be called with the view as a parameter.""" self.selection.connect('changed', lambda *args:f(self)) def purge_empty(self): """Removes any empty groups from the tree. This should be done after changing tags. (it is not done automatically because it is expensive, so it should only be called once if you update lots of files)""" self.toplevel.purge_empty() def get_selected_objects(self): """Return a list of the UI objects which are currently selected.""" if self.model==None: return [] else: rval=[] self.selection.selected_foreach(lambda model,path,iter:rval.append(self.model.get_value(iter, treemodel.COLUMN_PYOBJ))) return rval def get_selected_files(self): """Return a list of the files which are currently selected. This is a list of file objects from the library. They are not guaranteed to be unique.""" rval=[] for obj in self.get_selected_objects(): obj.add_underlying_files(rval) return rval def create_library_menu(self): menu=gtk.Menu() defined_libraries=config.get_option('General', 'DefinedLibraries') keys=defined_libraries.keys() if len(keys)>0: keys.sort() count=0 for name in keys: count+=1 w=gtk.MenuItem('_%d. %s'%(count, name)) w.connect('activate', lambda w, name: self.open_library((name,)), name) w.show() menu.append(w) w=gtk.SeparatorMenuItem() w.show() menu.append(w) w=gtk.MenuItem('_Create Library From View...') self.tooltips.set_tip(w, 'Create a library containing the files that are visible in this window.') w.connect('activate', self.handle_create_library) w.show() menu.append(w) return menu def update_library_menu(self, *args): """Regenerates the 'Libraries' menu using the current configuration setting.""" menu=self.create_library_menu() self.file_open_library_item.set_submenu(menu) # Adds the given library to the stored set, and the File menu # if appropriate. def add_past_library(self, lib): # Backwards compatibility. if isinstance(lib, basestring): lib=[lib] if not isinstance(lib, tuple): # Support non-list mutable sequences for i in range(0, len(lib)): lib[i]=os.path.realpath(lib[i]) def libname(lib): if isinstance(lib, tuple): assert(len(lib)==1) return '"%s"'%lib[0] else: rval=','.join(lib) # semi-arbitrary limit if len(rval)>30: rval=','.join(map(lambda x:'.../%s'%os.path.basename(x), lib)) if len(rval)>30: rval='%s,...'%lib[0] if len(rval)>30: rval='../%s,...'%os.path.basename(lib[0]) return rval menu=gtk.Menu() self.past_libraries=([lib]+filter(lambda x:x<>lib, self.past_libraries))[:6] self.file_open_recent_item.set_sensitive(1) self.file_open_recent_item.show() count=0 for x in self.past_libraries: count+=1 w=gtk.MenuItem('_%d. %s'%(count, libname(x))) w.connect('activate', lambda w, l:self.open_library(l), x) w.show() menu.append(w) self.file_open_recent_item.set_submenu(menu) config.set_option('LibraryView', 'PastLibraries', self.past_libraries) # Should these be here? Would deriving the GUI window from # UndoManager be better? def open_undo_group(self): self.undo_manager.open_undo_group() def close_undo_group(self): self.undo_manager.close_undo_group() def __cleanup_after_change(self): self.update_undo_sensitivity() self.update_saverevert_sensitivity() self.toplevel.purge_empty() # Sometimes does slightly more than necessary, but safer than # trying to be overly clever. def update_undo_sensitivity(self): self.undo_item.set_sensitive(self.undo_manager.has_undos()) self.undo_button.set_sensitive(self.undo_manager.has_undos()) self.redo_item.set_sensitive(self.undo_manager.has_redos()) self.redo_button.set_sensitive(self.undo_manager.has_redos()) def update_saverevert_sensitivity(self): sensitive=self.library.modified_count()>0 self.file_save_item.set_sensitive(sensitive) self.save_button.set_sensitive(sensitive) self.file_revert_item.set_sensitive(sensitive) self.revert_button.set_sensitive(sensitive) # TODO: check for modifications and pop up a dialog as appropriate # For now, just quit the program when a window is closed (need to # be cleverer, maybe even kill file->quit) def close(self, *args): self.destroyed=True toplevel.remove(self) # main_quit is just broken and doesn't behave. def quit_program(self,*args): sys.exit(0) def column_toggled(self, menu_item, tag): nowvisible=menu_item.get_active() currcols=config.get_option('LibraryView', 'VisibleColumns') if nowvisible: if not tag in currcols: currcols.append(tag) else: currcols.remove(tag) config.set_option('LibraryView', 'VisibleColumns', currcols) self.guicolumns[tag].set_visible(menu_item.get_active()) # Handles a change to the set of organizations. Used to construct # the "organizations" menu up front, and to select the appropriate # initial value (from "config"). def update_organizations(self): # Can I sort them in a better way? organizations=organize.CANNED_ORGANIZATIONS.keys() organizations.sort(lambda a,b:cmp(organize.CANNED_ORGANIZATIONS[a][2], organize.CANNED_ORGANIZATIONS[b][2])) menu=gtk.Menu() curr_organization=config.get_option('LibraryView', 'Organization') # taking this from the pygtk demo -- not sure how it works in # general :-/ group=None for o in organizations: title=organize.CANNED_ORGANIZATIONS[o][2] menuitem=gtk.RadioMenuItem(group, title) if o == curr_organization: menuitem.set_active(o == curr_organization) group=menuitem menuitem.show() menu.add(menuitem) menuitem.connect('activate', self.choose_canned_organization, o) self.organizeview_item.set_submenu(menu) # Handles a change to the set of known tags def update_known_tags(self): self.extracolumns=map(lambda x:StringTagColumn(x), tags.known_tags.values()) self.extracolumns.sort(lambda a,b:cmp(a.get_title(),b.get_title())) self.guicolumns={} self.tagmenuitems={} # Create a menu for toggling column visibility. menu=gtk.Menu() renderer=gtk.CellRendererText() renderer.connect('edited', self.handle_edit, None) renderer.set_property('editable', False) col=gtk.TreeViewColumn('Title', renderer, cell_background=treemodel.COLUMN_BACKGROUND, text=treemodel.COLUMN_LABEL) self.music_list.append_column(col) initial_visible_columns=config.get_option('LibraryView', 'VisibleColumns') # Add the remaining columns for n in range(0, len(self.extracolumns)): col=self.extracolumns[n] # Set up a menu item for this. is_visible=(col.get_tag() in initial_visible_columns) menuitem=gtk.CheckMenuItem(col.get_title()) menuitem.set_active(is_visible) menuitem.show() menuitem.connect('activate', self.column_toggled, col.get_tag()) menu.add(menuitem) src=n+treemodel.COLUMN_FIRST_NONRESERVED renderer=gtk.CellRendererText() renderer.connect('edited', self.handle_edit, col) renderer.connect('editing_canceled', self.handle_edit_cancel, col) renderer.set_property('editable', False) gcol=gtk.TreeViewColumn(col.get_title(), renderer, text=src, cell_background=treemodel.COLUMN_BACKGROUND) gcol.set_visible(is_visible) gcol.set_reorderable(True) self.guicolumns[col.get_tag()]=gcol self.music_list.append_column(gcol) self.showhide_item.set_submenu(menu) def set_organization(self, organization): if organization <> self.current_organization: self.current_organization=organization self.build_tree() # signal handler: def choose_canned_organization(self, widget, name): # GTK+ bug: "activate" is emitted for a widget when it's # either activated OR deactivated. if widget.get_active(): # FIXME: show an error dialog if the name doesn't exist. self.set_organization(organize.CANNED_ORGANIZATIONS[name]) config.set_option('LibraryView', 'Organization', name) def handle_edit(self, cell, path_string, new_text, column): """Handle an edit of a field. 'column' is a column generator or None for the label column.""" cell.set_property('editable', 0) iter=self.model.get_iter(path_string) rowobj=self.model.get_value(iter, treemodel.COLUMN_PYOBJ) # Handle the label specially if column==None: if new_text <> '' and not rowobj.parent.validate_child_label(rowobj, new_text): return rowobj.parent.set_child_label(rowobj, new_text) else: tag=tags.known_tags[column.get_tag()] # hm, should '' be handled specially? ew. if new_text <> '' and not tag.validate(rowobj, new_text): return column.set_value(rowobj, new_text) # find the root and purge empty groups from it # # why not just use toplevel? root=rowobj while root.parent: root=root.parent root.purge_empty() self.model.sort_column_changed() def handle_edit_cancel(self, cell): """Handle a cancelled edit of a cell, mainly by making it un-editable again.""" cell.set_property('editable', 0) def do_edit_cell(self, row, col, path): col.get_cell_renderers()[0].set_property('editable', 1) self.music_list.set_cursor(path, col, True) # Handle the "open directory" menu function. def handle_open_directory(self, *args): def on_ok(widget): fn=filesel.get_filename() filesel.destroy() if not os.path.isdir(fn): msgdlg=gtk.MessageDialog(None, 0, gtk.MESSAGE_WARNING, gtk.BUTTONS_OK, 'You must pick a directory' ) msgdlg.show() msgdlg.connect('response', lambda *args:self.handle_open_directory()) msgdlg.connect('response', lambda *args:msgdlg.destroy()) else: self.open_library(fn) filesel=gtk.FileSelection("Choose a directory") filesel.ok_button.connect('clicked', on_ok) filesel.cancel_button.connect('clicked', lambda *args:filesel.destroy()) filesel.show() # Handle the "edit libraries" menu function. # # Right now you can only edit the "defined" libraries; there's no # way to get at an "implicitly defined" library. That would be # nice but a pain to code. def handle_edit_libraries(self, *args): libraryeditor.show_library_editor(self.glade_location) # Handle the "create library" menu function. def handle_create_library(self, *args): if self.library == None: dirs=[] else: dirs=self.library.dirs libraryeditor.show_library_editor(self.glade_location, 'New Library', dirs) # Handle the "revert changes" menu function. def handle_revert_changes(self, widget): self.status.push() try: self.library.revert(progress.ProgressUpdater("Discarding changes", self.show_progress)) self.toplevel.purge_empty() finally: self.status_progress.hide() self.status.pop() self.__cleanup_after_change() # Handle the "save changes" menu function. def handle_save_changes(self, widget): self.status.push() try: try: self.library.commit(progress.ProgressUpdater("Saving changes", self.show_progress)) except filestore.SaveError, e: m=gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format=e.strerror) m.connect('response', lambda *args:m.destroy()) m.show() finally: self.status_progress.hide() self.status.pop() self.__cleanup_after_change() # Set the progress display to the given amount, with the given # percentage and message. # # Assumes it needs to call gtk_poll() def show_progress(self, message, percent): if self.destroyed: return self.status_progress.show() if percent <> None: self.status.set_message('%s: %d%%'%(message, int(percent*100))) self.status_progress.set_fraction(percent) else: self.status.set_message('%s'%message) self.status_progress.pulse() while(gtk.events_pending()): gtk.main_iteration_do(False) # makegroup takes a model argument and returns the base group. def build_tree(self, callback=None): if not self.library: return # pygtk is disgustingly broken; this "restart_build_tree" # business is an attempt to fake what would happen if it # correctly passed exceptions through gtk.mainiter(). if self.building_tree: self.restart_build_tree=True return self.status.push() try: success=False self.building_tree=True while not success and not self.destroyed: self.restart_build_tree=False # Needed here because self isn't bound in the argument list: if callback==None: callback=progress.ProgressUpdater("Building music tree", self.show_progress) self.model=apply(gtk.TreeStore,[gobject.TYPE_PYOBJECT, gobject.TYPE_STRING, gobject.TYPE_STRING]+map(lambda x:x.get_type(), self.extracolumns)) self.toplevel=self.current_organization[0](self.model, self, self.extracolumns) cur=0 max=len(self.library.files) for file in self.library.files: callback(cur, max) if self.restart_build_tree or self.destroyed: break cur+=1 self.toplevel.add_file(file) if self.restart_build_tree or self.destroyed: continue callback(max, max) if self.restart_build_tree or self.destroyed: continue success=True # I'd like to have these up above in order to provide more # visual feedback while building the tree. Unfortunately, # adding items to a tree with a sorting function is # hideously expensive, so I have to do this here (IMO this # is a bug in the TreeStore) # # eg: with 660 items, if I set the sorter before building # the tree, it is called 26000 times; if I set it here, it # is called 2300 times. For 96 items it is called 3975 # and 445 times, respectively. self.model.set_sort_func(0, self.current_organization[1]) self.model.set_sort_column_id(0, gtk.SORT_ASCENDING) self.music_list.set_model(self.model) # Changing the organization can change the optimal # column width: self.music_list.columns_autosize() self.status_progress.hide() self.building_tree=False finally: if not self.destroyed: self.status.pop() def __do_add_undo(self, file, olddict): # Only add an undo item if it actually changed. This avoids # adding undo items for, eg, changes to the "modified" flag. if olddict <> file.comments: self.undo_manager.add_undo(undo.TagUndo(file, olddict, file.comments)) def handle_library_edited(self, section, name, old_val, new_val): """When a user-defined library is edited, this routine will regenerate the main tree model as appropriate.""" if self.library_name == None: return assert(old_val.has_key(self.library_name)) if self.new_library_name == None: # FIXME: test this case, make it robust. self.model=None self.music_list.set_model(self.model) self.library_name = None self.new_library_name = None self.library = None elif old_val[self.library_name] <> new_val[self.new_library_name]: self.open_library((self.new_library_name,)) else: self.library_name=self.new_library_name def open_library(self, lib): """Open the given list of directories as a library. If lib is a tuple of one element, it is the name of a defined library to open.""" if isinstance(lib, tuple): assert(len(lib)==1 and isinstance(lib[0], basestring)) key=lib[0] defined_libraries=config.get_option('General', 'DefinedLibraries') if not defined_libraries.has_key(key): m=gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format='The library "%s" doesn\'t seem to exist!'%key) m.connect('response', lambda *args:m.destroy()) m.show() return self.add_past_library(lib) lib=defined_libraries[key] self.library_name=key self.new_library_name=key elif isinstance(lib, basestring): # Backwards compatibility: lib=[lib] self.add_past_library(lib) self.library_name=None self.new_library_name=None else: self.add_past_library(lib) self.library_name=None self.new_library_name=None self.status.push() try: # TODO: do I need to recover sensitivity if an exception occurs? for x in [self.file_open_library_item, self.file_open_directory_item, self.file_open_recent_item, self.file_save_item, self.file_revert_item, self.open_button, self.save_button, self.revert_button]: x.set_sensitive(False) self.library=library.MusicLibrary(self.store, [], []) try: self.library.add_dirs(lib, map(lambda loc:progress.ProgressUpdater("Indexing music files in %s"%loc, self.show_progress), lib)) except filestore.LoadError,e: m=gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format=e.strerror) m.connect('response', lambda *args:m.destroy()) m.show() except filestore.NotDirectoryError,e: m=gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK, message_format=e.strerror) m.connect('response', lambda *args:m.destroy()) m.show() if self.destroyed: return self.store.cache.flush() self.build_tree() if self.destroyed: return for file in self.library.files: file.add_listener(self.__do_add_undo) finally: if not self.destroyed: for x in [self.file_open_library_item, self.file_open_directory_item, self.file_open_recent_item, self.open_button]: x.set_sensitive(True) self.status.pop() self.status.set_message('Editing %s'%(','.join(lib))) musiclibrarian-1.6/musiclibrarian/listenable.py0000644000175000017500000000571010162335334022601 0ustar danieldaniel00000000000000# listenable.py # # Copyright (C) 2004 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # A generic class that you can "listen" to events on. You can derive # from this, or you can instantiate it as a field (eg, if multiple # events are available). import sets import weak class Connection: """Represents a single conenction between a Listenable and a callback. Used mainly to make an easy unique ID (the object identity).""" def __init__(self, listener, as_weak): if as_weak: self.__wrapper=weak.WeakCallableRef(listener) else: self.__wrapper=lambda:listener def live(self): """Returns the underlying function if it is live, None otherwise.""" return self.__wrapper() class Listenable: """An object with which callbacks can be registered. The callbacks are weakly held (with object methods being handled in the expected way) and invoked when the 'comments' of this object change.""" def __init__(self): self.listeners=sets.Set() def add_listener(self, listener, as_weak=True): """Register a new callback with this object. If as_weak is False, the callback will be strongly held.""" conn=Connection(listener, as_weak) self.listeners.add(conn) return conn def remove_listener(self, conn): """Remove a callback based on its connection id, or on its target.""" if isinstance(conn, Connection): self.listeners.remove(conn) else: for conn in list(self.listeners): f=conn.live() if f == None or conn == f: self.listeners.remove(conn) def call_listeners(self, *args, **kw): """Calls all the listeners with the given arguments.""" # NOTE: copy the set of listeners so that if some get deleted # while we're iterating, we don't do weird things. for conn in list(self.listeners): f=conn.live() if f == None: # Use "discard", not "remove", because conn might have # been removed already by a callback; this avoids # throwing a KeyError. self.listeners.discard(conn) else: f(*args, **kw) musiclibrarian-1.6/musiclibrarian/musicfile.py0000644000175000017500000001350510152764006022441 0ustar danieldaniel00000000000000# musicfile.py # Copyright 2004 Daniel Burrows # # Core classes for storing information about a music file. import listenable class MusicFileError(Exception): def __init__(self, strerror): self.strerror=strerror def __str__(self): return self.strerror # The set of known file extensions and associated classes. It is # assumed that None is not included in this dictionary. file_types = { } def register_file_type(ext, cls): """Add a new file type. 'cls' is a class or other callable object which takes a single argument (the name of the file to load) and returns a file instance.""" file_types[ext]=cls class File(listenable.Listenable): """This is the most generic abstraction of a file. Actual files must implement additional methods like get_tags, set_tags, etc.""" def __init__(self, store): listenable.Listenable.__init__(self) self.store=store # A file with a dict interface. This is an abstract class; subclasses # need to implement the write_to_file() and get_file() methods and # initialize "comments" in their constructor. It is ASSUMED that the # keys in "comments" are upper-case. # # write_to_file() is responsible for actually writing out the current # data to the file. get_file() returns a "file-like" object: # specifically, one with a read() method. This method will return a # tuple (data,amt) when called. class DictFile(File): """This class represents a file whose attributes 'look' like a dictionary. Most files will fall into this category. All keys of the dictionary are assumed to be upper-case. Subclasses are responsible for implementing write_to_file().""" def __init__(self, store, comments): """Set this instance up with the given initial store pointer and comment dictionary.""" File.__init__(self, store) self.comments=comments self.__rationalize_comments() self.backup_comments=self.comments.copy() self.modified=0 def __rationalize_comments(self): """Purge empty comments from the comments dictionary.""" for key,val in self.comments.items(): if val == []: del self.comments[key] def get_tag(self, key): """Returns a list of zero or more strings associated with the given case-insensitive key.""" return self.comments.get(key.upper(), []) def set_tags(self, dict): """Sets all tags of this object to the values stored in the given dictionary.""" if self.comments <> dict: oldcomments=self.comments self.comments=dict.copy() self.__rationalize_comments() self.modified = (self.comments <> self.backup_comments) self.store.set_modified(self, self.modified) self.call_listeners(self, oldcomments) def set_tag(self, key, val): """Sets a single tag to the given value.""" if self.get_tag(key) <> val: upkey=key.upper() # used to handle updating structures in the GUI: oldcomments=self.comments.copy() if val==[]: del self.comments[upkey] else: self.comments[upkey]=val # careful here. (could just always compare dictionaries, # but this is a little more efficient in some common # cases) if (not self.modified): # if it wasn't modified, we can just compare the new value # to the old value. self.modified=(val <> self.backup_comments.get(upkey, [])) else: # it was modified; if this key is now the same as its # original value, compare the whole dictionary. (no # way around this right now) Note that if it isn't the # same, you might as well just leave it modified. if val == self.backup_comments.get(upkey, []): self.modified = (self.comments <> self.backup_comments) self.store.set_modified(self, self.modified) self.call_listeners(self, oldcomments) def get_cache(self): """Returns a dictionary whose members are the comments attached to this file.""" return self.backup_comments.copy() def tags(self): """Returns a list of the tags of this file.""" return self.comments.keys() def values(self): """Returns a list of the values associated with tags of this file.""" return self.comments.values() def items(self): """Returns a list of pairs (tag,value) representing the tags of this file.""" return self.comments.items() def set_tag_first(self, key, val): """Sets only the first entry of the given tag, leaving the rest of the entries (if there are any) unmodified.""" cur=self.get_tag(key) if cur==[]: if val <> None: self.set_tag(key, [val]) elif val==None: new=list(cur) del new[0] self.set_tag(key, new) elif cur[0] <> val: new=list(cur) new[0]=val self.set_tag(key, new) def commit(self): """Commit any changes to the backing file.""" if self.modified: self.write_to_file() self.modified=0 self.store.set_modified(self, False) self.call_listeners(self, self.comments) self.backup_comments=self.comments.copy() def revert(self): """Revert any modified comments to their original values.""" if self.modified: # no copy, we aren't modifying them. oldcomments=self.comments self.comments=self.backup_comments.copy() self.modified=0 self.store.set_modified(self, False) self.call_listeners(self, oldcomments) musiclibrarian-1.6/musiclibrarian/organize.py0000644000175000017500000000752110164567445022313 0ustar danieldaniel00000000000000# Organization-related stuff. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import sort import treemodel # some default organizations. Organizations are a tuple # (root-group-generator, sort-method, name) CANNED_ORGANIZATIONS = { 'ArtistAlbum' : (lambda model,gui,extracolumns: treemodel.TagGroup(None, None, None, model, gui, extracolumns, "Artist", lambda iter,parent,label: treemodel.TagGroup(iter, parent, label, model, gui, extracolumns, "Album", lambda iter, parent, label: treemodel.NullGroup(iter, parent, label, model, gui, extracolumns))), sort.make_basic_sort(2), 'By Artist and Album'), 'GenreArtistAlbum' : (lambda model,gui,extracolumns: treemodel.TagGroup(None, None, None, model, gui, extracolumns, "Genre", lambda iter,parent,label: treemodel.TagGroup(iter, parent, label, model, gui, extracolumns, "Artist", lambda iter,parent,label: treemodel.TagGroup(iter, parent, label, model, gui, extracolumns, "Album", lambda iter, parent, label:treemodel.NullGroup(iter, parent, label, model, gui, extracolumns)))), sort.make_basic_sort(3), 'By Genre, Artist and Album'), 'Artist' : (lambda model,gui,extracolumns: treemodel.TagGroup(None, None, None, model, gui, extracolumns, "Artist", lambda iter,parent,label: treemodel.NullGroup(iter, parent, label, model, gui, extracolumns)), sort.sort_by(sort.column(treemodel.COLUMN_LABEL)), 'By Artist'), 'Album' : (lambda model,gui,extracolumns: treemodel.TagGroup(None, None, None, model, gui, extracolumns, "Album", lambda iter,parent,label: treemodel.NullGroup(iter, parent, label, model, gui, extracolumns)), sort.make_basic_sort(1), 'By Album'), 'Title' : (lambda model,gui,extracolumns: treemodel.NullGroup(None, None, None, model, gui, extracolumns), sort.sort_by(sort.column(treemodel.COLUMN_LABEL)), 'By Title') } musiclibrarian-1.6/musiclibrarian/plugins.py0000644000175000017500000000462610137305046022144 0ustar danieldaniel00000000000000"""Manages loadable plugins Plugins should be loaded via load_plugin(), even from other plugins (using 'import' is discouraged, as it might not work). Plugins are stored in a separate dictionary from modules, in order to prevent plugins from accidentally (or intentionally) overriding modules in musiclibrarian, or even core Python modules. It's basically a matter of namespace separation. When this module is initially imported, it will load all the plugins it can find in the standard locations.""" import imp import os import string import sys plugins={} plugin_path=[os.path.expanduser('~/.musiclibrarian/plugins'), os.path.join(os.path.dirname(sys.argv[0]), 'musiclibrarian-plugins'), os.path.join(sys.prefix, 'lib/musiclibrarian/plugins'), os.path.join(sys.prefix, 'share/musiclibrarian/plugins')] # Load one level of hierarchy of a plugin def load_plugin_step(fqname, namepart, path): if plugins.has_key(fqname): return plugins[fqname] file, pathname, description = imp.find_module(namepart, path) try: rval=imp.load_module('musiclibrarian.plugins.'+fqname, file, pathname, description) plugins[fqname]=rval return rval finally: if file: file.close() def load_plugin(name): parts=name.split('.') path=plugin_path fqname='' m=None for part in parts: if fqname <> '' and part <> '': fqname='%s.%s'%(fqname,part) else: fqname=fqname+part try: m=load_plugin_step(fqname, part, path) except: print 'Failed to load plugin %s:'%name apply(sys.excepthook, sys.exc_info()) return None # Be sure to bomb out if the user tries to hierarchically load # a non-package. path=getattr(m, '__path__', '') return m # Load all plugins: def load_all_plugins(): for dir in plugin_path: if os.path.isdir(dir): for fn in os.listdir(dir): if (os.path.isdir(os.path.join(dir, fn)) and os.path.isfile(os.path.join(dir, fn, '__init__.py'))): load_plugin(fn) elif (os.path.isfile(os.path.join(dir, fn)) and fn.endswith('.py')): load_plugin(fn[:-3]) musiclibrarian-1.6/musiclibrarian/progress.py0000644000175000017500000000315510133101502022306 0ustar danieldaniel00000000000000# progress.py # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import time # Given a message and a callback, calls the callback as # callback(message, percent) when it itself is called, but at regular # time intervals. class ProgressUpdater: # "update_interval" is in seconds def __init__(self, message, callback, update_interval=0.1): self.message=message self.callback=callback self.update_interval=update_interval self.last_update=None def __call__(self, cur, max): t=time.time() if self.last_update==None or cur==0 or (cur==max and cur <> None) or t-self.last_update>self.update_interval: self.last_update=t if cur==None: percent=None elif max==0: percent=1 else: percent=float(cur)/float(max) self.callback(self.message, percent) musiclibrarian-1.6/musiclibrarian/serialize.py0000644000175000017500000002664010164574355022465 0ustar danieldaniel00000000000000# serialize.py - serialize a restricted set of Python data objects. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # The file format of ConfigParser is poorly specified and tends to be # a lossy way to store Python values. It's also rather unpleasant to # put structured data in it. # # These routines read and write a well-defined file format which can # losslessly represent a subset of Python objects (a plus over # ConfigParser); they *only* handle a particular subset of Python # objects, so they can be safely used on arbitrary input files (a plus # over pickle -- sometimes there's such a thing as being TOO # expressive!), and they are extensible: you can add your own object # types and syntax. # # This is sort of an "s-expressions on steroids for Python". It is # distinguished from XML mainly in that the input files are meant to # be read-write without the aid of a specialized editor. # # Note: if you pass in a file object generated by codecs.open(), the # underlying file will have the appropriate encoding (eg, utf8). import re import types # Helpers: # Skip leading whitespace on a seekable file, return the next # non-whitespace character and the location immediately preceding it. def skip_ws(f): loc=f.tell() c=f.read(1) while c.isspace(): loc=f.tell() c=f.read(1) return loc,c # A class which can read Python expressions from files. # # The method "read" returns the next Python expression in the file. # # This class recognizes the following syntax: # # PEXP ::= # | # | None | True | False # | # | \( (PEXP ,?)* \) # | [ (PEXP ,?)* ] # | { (PEXP : PEXP ,?)* } # | \( PEXP ,?)* \) # # All tokens MUST be whitespace-separated. The only exception: the # delimiters of tuples, lists, and dictionaries are exempt from this # requirement, as are commas. # # is a string bracketed by single quotes ('). # Presently known escape sequences are \\, \', \b, and \n. Unknown # escape sequences are handled as in Python (the backslash is left # in). # # The last case is what allows extension by plugging in a new parser. # Currently no support is provided for cross-references, so graphs # cannot be directly represented (but of course you can convert a # graph to a sequence of tuples) # # The idea is to allow all non-executable (=safe) data types. Of # course, since the extensions can be general functions, the burden is # on the extension writer to ensure their safety. # # A given Reader can apply to any file which supports # seeking. (handling lookahead internally would allow this to # generalize to any file) # # FIXME: handle more of the many types of integers that Python knows # about. (mainly requires more tests when "starting" a number?) # # FIXME: This is dreadfully inefficient. Could regexes be used? # # FIXME: Don't require a seekable file. I only need one character of # lookahead, so this shouldn't be too hard. (OTOH, how often is # seeking necessary and how expensive is it?) class Reader: # Each extension is a function which takes a tuple of arguments # and returns a Python object. def __init__(self, extensions = {}): self.extensions=extensions # Read one Python expression from the given file. def read(self, file): # Get the first character. loc,c=skip_ws(file) # Dispatch. if c == '': raise IOError, 'Unexpected end of file' elif (c >= '0' and c <='9') or c=='-' or c == '.': file.seek(loc) return self.__read_number(file) elif c == '\'': return self.__read_string_tail(file) elif c == '(': return self.__read_tuple_tail(file) elif c == '[': return self.__read_list_tail(file) elif c == '{': return self.__read_dict_tail(file) elif c.isalpha(): file.seek(loc) return self.__read_extension_or_constant(file) else: raise IOError, 'Can\'t parse token starting with \'%s\''%c def __read_number(self, file): s=file.read(1) if s == '': raise IOError, 'Unexpected end of file' loc=file.tell() c=file.read(1) while not c.isspace() and not c in ['', '(','{','[',']','}',')',',']: s+=c loc=file.tell() c=file.read(1) file.seek(loc) # Make integers by default try: return int(s) except ValueError: try: return long(s) except ValueError: return float(s) def __read_string_tail(self, file): s='' c=file.read(1) while c <> '\'': if c == '\\': c=file.read(1) if c == '\\' or c == '\'': s+=c elif c == 'n': s+='\n' elif c == 'b': s+='\b' else: s+='\\' s+=c elif c == '': raise IOError, 'EOF inside string' else: s+=c c=file.read(1) return s def __read_tuple_tail(self, file): loc,c=skip_ws(file) lst=[] while c <> ')': if c == '': raise IOError, 'EOF inside tuple' file.seek(loc) lst.append(self.read(file)) loc,c=skip_ws(file) if c == ',': loc,c=skip_ws(file) return tuple(lst) def __read_list_tail(self, file): loc,c=skip_ws(file) lst=[] while c <> ']': if c == '': raise IOError, 'EOF inside list' file.seek(loc) lst.append(self.read(file)) loc,c=skip_ws(file) if c == ',': loc,c=skip_ws(file) return lst def __read_dict_tail(self, file): loc,c=skip_ws(file) dict={} while c <> '}': if c == '': raise IOError, 'EOF inside dictionary' file.seek(loc) key=self.read(file) loc,c=skip_ws(file) if c <> ':': raise IOError, 'Parse error: expected \':\'' val=self.read(file) dict[key]=val loc,c=skip_ws(file) if c == ',': loc,c=skip_ws(file) return dict def __read_extension_or_constant(self, file): # Get the identifier: name='' loc=file.tell() c=file.read(1) # Assume that the first character was already found to be # alphabetic. while c.isalnum(): name+=c loc=file.tell() c=file.read(1) if name == 'None': return None elif name == 'True': return True elif name == 'False': return False elif not self.extensions.has_key(name): raise IOError, 'Parse error: "%s" is not a function'%name if c.isspace(): loc,c=skip_ws(file) if c <> '(': raise IOError, 'Expected \'(\' after call of function "%s"'%name args=self.__read_tuple_tail(file) return apply(self.extensions[name], args) # Converse to the above. # # Extensions are supported only under the condition that there is a # simple test for their applicability. class Writer: # Each extension is a tuple (applies, name, writer): applies takes a # Python object and returns a boolean value; if it returns True, # the writer is called with a single argument (obj); it should return # a tuple indicating the "arguments" to the extension. def __init__(self, extensions=[]): self.extensions=extensions # convenience for debugging def writeln(self, file, obj, indent=0): self.write(file, obj, indent) file.write('\n') def write(self, file, obj, indent=0): t=type(obj) if obj == None: file.write('None') elif obj == True: file.write('True') elif obj == False: file.write('False') elif isinstance(obj, basestring): self.__write_string(file, obj) elif t == types.IntType or t == types.LongType or t == types.FloatType: file.write(`obj`) elif t == types.ListType: self.__write_list(file, obj, indent) elif t == types.TupleType: self.__write_tuple(file, obj, indent) elif t == types.DictType: self.__write_dict(file, obj, indent) else: for test, name, writer in self.extensions: if test(obj): output=writer(obj) if type(output) <> types.TupleType: raise IOError,'Extension tried to write a non-tuple: %s'%obj file.write(name) self.__write_tuple(file, output, indent+len(name)) return raise IOError,'I don\'t know how to serialize %s'%`obj` # What to escape __escapere=re.compile('([\\\\\'\n\b])') def __write_string(self, file, obj): def doescape(g): c=g.group(0) if c == '\\' or c == '\'': return '\\'+c elif c == '\n': return '\\n' elif c == '\b': return '\\b' else: raise IOError, 'No inverse to escape %s'%c file.write('\'') file.write(re.sub(Writer.__escapere, doescape, obj)) file.write('\'') # Very aggressive about linewrapping here: perhaps I should buffer # things instead? def __write_seq(self, file, obj, indent, start, end): file.write(start) first=True for x in obj: if not first: file.write(',\n'+(' '*(indent+1))) else: first=False self.write(file, x, indent+1) file.write(end) def __write_list(self, file, obj, indent): self.__write_seq(file, obj, indent, '[', ']') def __write_tuple(self, file, obj, indent): self.__write_seq(file, obj, indent, '(', ',)') def __write_dict(self, file, obj, indent): file.write('{') items=obj.items() # Sort the items, to make the dictionary 'nicer' items.sort(lambda a,b:cmp(a[0], b[0])) first=True for key,val in items: if not first: file.write(',\n'+(' '*(indent+1))) else: first=False self.write(file, key, indent+1) file.write('\n'+ (' '*(indent+5))+': ') self.write(file, val, indent+7) file.write('}') musiclibrarian-1.6/musiclibrarian/sort.py0000644000175000017500000000610507742255721021460 0ustar danieldaniel00000000000000# Sorting utilities for musiclibrarian. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import treemodel import types # like cmp, but case-insensitive for strings. Could be more efficient? def caseicmp(a, b): if type(a)==types.StringType: a=a.upper() if type(b)==types.StringType: b=b.upper() return cmp(a,b) # Returns a function to sort using casecmp(), given a function on # iterators. The function should take two arguments (model, iter) and # return the data used for comparison. def sort_by(f, invert=0): if not invert: return lambda model, a, b, *rest:caseicmp(f(model, a), f(model, b)) else: return lambda model, a, b, *rest:-caseicmp(f(model, a), f(model, b)) # data fetcher for the above; reads the raw column data def column(column): def f(model, iter): val=model.get_value(iter, column) if type(val)==types.StringType: return val.lower() else: return val return f # data fetcher to read a tag def tag(tag): def get_tag(model, iter): val=model.get_value(iter, treemodel.COLUMN_PYOBJ).get_tag(tag) if len(val)==0: return None else: return val[0].lower() return get_tag # helper to convert a string to an integer, passing None through. def safeint(val): if val==None: return None else: return int(val) # Given some number of procedures with the signature of a tree sorter, # returns a new sorter which uses them to sort lexicographically. def make_lexicographic_sorter(*sorters): def sorter(model,a,b,*rest): for f in sorters: val=apply(f, (model, a, b)+rest) if val<>0: return val return 0 return sorter # Returns a sorter to sort by track number if available, then by # label. The track number will only be used if both iterators are at # depth "max_depth". def make_basic_sort(max_depth): doublesort=make_lexicographic_sorter(sort_by(lambda model, iter:safeint(tag('tracknumber')(model, iter))), sort_by(column(treemodel.COLUMN_LABEL))) singlesort=sort_by(column(treemodel.COLUMN_LABEL)) def sorter(model, a, b): if model.iter_depth(a)==max_depth and model.iter_depth(b)==max_depth: return doublesort(model, a, b) else: return singlesort(model, a, b) return sorter musiclibrarian-1.6/musiclibrarian/status.py0000644000175000017500000000326207742255726022022 0ustar danieldaniel00000000000000# A wrapper for a status bar that makes some simple but common # operations easier. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA class Status: def __init__(self, widget): self.widget=widget self.context_id=widget.get_context_id('Library status') self.msg_id=[] # Pushes the new message before removing the old one. Not sure # that really makes a difference. def set_message(self, text): new_msg_id=self.widget.push(self.context_id, text) if len(self.msg_id)>0: self.widget.remove(self.context_id, self.msg_id[-1]) del self.msg_id[-1] self.msg_id.append(new_msg_id) # Pushes a new level of message. def push(self, text=""): self.msg_id.append(self.widget.push(self.context_id, text)) # Pops a level of message. def pop(self): if len(self.msg_id)>0: self.widget.remove(self.context_id, self.msg_id[-1]) del self.msg_id[-1] musiclibrarian-1.6/musiclibrarian/tags.py0000644000175000017500000000610410023161543021406 0ustar danieldaniel00000000000000# tags.py - Keeps a representation of the tags the program knows about. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import gtk # Information about known tags. class Tag: # tag: the string associated with the tag (in dictionaries and so forth) # title: the string to be displayed for the tag. # validator: a function to check if the given string is valid for # the given list item. It should return None if the # string is *VALID* and a string describing the problem # otherwise. (an alternative is to throw an exception # for invalid tags, but that has other issues and is # hard to get right) def __init__(self, tag, title, validator=lambda li,s:None): self.tag=tag self.title=title self.validator=validator # returns True if the input is ok. def validate(self, li, s): err=self.validator(li, s) if err==None: return True else: # FIXME: set the parent properly. dlg=gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, err) dlg.connect('response', lambda *args:dlg.destroy()) dlg.show_all() return False def validate_tracknum(li, s): try: int(s) return None except ValueError: return 'The track number must be a whole number' def validate_genre(li, s): if li.valid_genres().member(s): return None else: return 'The genre "%s" is not valid for all of the selected files.\nPerhaps it is not a known MP3 genre?'%s # Tags that are known. Indexed by strings to provide a # reasonably simple way of referring to them in config files # (eventually). "title" is special, since it doubles as the # label column (it can never be activated or deactivated) # # FIXME: uses lower-case when most stuff uses upper-case known_tags={ 'album' : Tag('album', 'Album'), 'artist' : Tag('artist', 'Artist'), 'comment' : Tag('comment', 'Comment'), 'genre' : Tag('genre', 'Genre', validate_genre), 'title' : Tag('title', 'Title'), 'tracknumber' : Tag('tracknumber', 'Track Number', validate_tracknum), 'year' : Tag('year', 'Year') } musiclibrarian-1.6/musiclibrarian/toplevel.py0000644000175000017500000000055010137060157022306 0ustar danieldaniel00000000000000# toplevel.py # Copyright 2004 Daniel Burrows # # This module just provides a global place to store top-level windows. # When all top-level windows are removed, the worl^H^H^H^Hprogram # ends. import gtk import sets toplevel=sets.Set() def add(w): toplevel.add(w) def remove(w): toplevel.remove(w) if len(toplevel)==0: gtk.main_quit() musiclibrarian-1.6/musiclibrarian/treemodel.py0000644000175000017500000003731710164605667022462 0ustar danieldaniel00000000000000# treemodel.py - This module contains code to maintain the tree seen # in the GUI. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import filecollection import genreset import tags import types # A real functional if. Used below. # # The first argument is the condition; the other two are thunks. def ifelse(cond, thenf, elsef): if cond: return thenf() else: return elsef() # Comments on the tree: # # The tree always has at least four columns. Column 0 is the Python # object represented by the row; it is an object from the music # library database or a group constructor. Column 1 is the name to be # placed in the tree for this row. Column 2 is the column which # determines whether cells are editable (always true, but..) Column 3 # is like column 2, but is only true for rows which contain an actual # file (not just a group header) The remaining columns are # user-definable and will generally have to do with tags and so forth. # # Editing tags proceeds as follows: # # 1. when editing a group label, the group label is changed first. # If there is another group with the same label in the parent, revert # the change. (this will result in the group being emptied later) # Alternatively, test first and don't perform the operation if the # group already exists. # # 2. For each affected file, set the tag appropriately. Then query # each parent of the file to see if the file still belongs in the # parent. If not, remove the file and re-add it at the root. (when # files are removed, empty groups should be purged, although this can # wait until the end -- it might be easier to do that way) # # Reverting changes proceeds as follows: # # First, the underlying tags are reverted. Then, each file is checked # to see if it needs to be moved, in the same manner as (2) above. COLUMN_PYOBJ=0 COLUMN_LABEL=1 COLUMN_BACKGROUND=2 COLUMN_FIRST_NONRESERVED=3 # Fills in a row in the model based on the given row object. The object # must be compatible with all columns in extracolumns. # # Move this to a shared base class of Group and FileProxy? # # FIXME: THIS FUNCTION IS THE BOTTLENECK FOR BUILDING THE TREE! # # It seems that model.set is a very expensive operation. I'm not sure # why; it may have to do with sorting, or something else entirely. def fill_row(row, iter, model, label, extracolumns): # model.set is VERY expensive, call it as few times as possible args=[iter, COLUMN_PYOBJ, row, COLUMN_LABEL, label, COLUMN_BACKGROUND, ifelse(row.modified(), lambda:"lightpink", lambda:"white")] for col in range(0, len(extracolumns)): args+=[COLUMN_FIRST_NONRESERVED+col, extracolumns[col].get_value(row)] apply(model.set, args) return # Group classes maintain a "shadow" of the model that's easier to access. # The tree is stored based on three types of classes: # # Groups are just a shadow of the GTK+ group. They are stored in the GTK+ # row and define various operations on the row. Their parent gives them a # fixed label, but they know how to generate their column information. # # The subclasses of Group should implement the following methods: # add_file() must be overridden to actually add the file. # (maybe this should be a different name?) # remove_child() must be created. # belongs_to() must be created. # children() must be created and must return a copy of a list (ie, one # which is secure against deletion) # # set_child_label() must be created # FileProxies are proxies for the files in the GUI. They obey a similar # interface to Groups. # # GroupGenerators are responsible for populating a group. Given a file, # they know how to add it to the group they are attached to. class Group(filecollection.FileCollection): def __init__(self, iter, parent, label, model, gui, extracolumns): filecollection.FileCollection.__init__(self, gui) self.iter=iter self.parent=parent self.label=label self.model=model self.extracolumns=extracolumns self.__fill_row() # Part of the list-item interface def get_label(self): return self.label def __fill_row(self): if self.label <> None: fill_row(self, self.iter, self.model, self.label, self.extracolumns) # TODO: track modified state of files in this group # # Part of the list-item interface. def modified(self): return False # should this be done by children for symmetry? (removal is # handled by children) def add_file(self, file): # update contained count self.inc_count(file.items()) # The row might need to be updated since it depends on the # contents of this subtree self.__fill_row() # Purges any empty children of this node. TODO: cache the size of # each subtree for great optimization! def purge_empty(self): for child in self.children(): child.purge_empty() if child.empty(): self.remove_child(child) # Indicates that the given file was removed from this tree or a # subtree. def file_removed(self, file, olddict): # question: we pass the items around everywhere; could these # be used to start with? (saves lots of copying, but is it # really worth it?) self.dec_count(olddict.items()) # The row might need to be updated since it depends on the # contents of this subtree self.__fill_row() if self.parent: self.parent.file_removed(file, olddict) # Indicates that the given file in this tree/subtree was updated. # # Like file_removed, but also adds it back in. def file_updated(self, file, olddict): self.dec_count(olddict.items()) self.inc_count(file.items()) # the row might need to be updated self.__fill_row() if self.parent: self.parent.file_updated(file, olddict) def add_underlying_files(self, lst): child=self.model.iter_children(self.iter) while child <> None: self.model.get_value(child, COLUMN_PYOBJ).add_underlying_files(lst) child=self.model.iter_next(child) # A rather expensive way of doing this...but it is efficient in the # special case that the sets are either the set of all strings or the # set of ID3 tags. def valid_genres(self): rval=genreset.GenreSet(True) for child in self.children(): rval=rval.intersect(child.valid_genres()) return rval # A group with subtrees based on a given tag. TODO: make tags visible and # editable if they're the same for all members of a group. # # Groups keep track of how many files they contain, which may be # useful for output purposes, in addition to helping purge empty # groups. class TagGroup(Group): # next is a function of two arguments (the new item's iterator and the # parent Python object) # which will generate an empty group the next level down (use # lambda to wrap the real constructor) def __init__(self, iter, parent, label, model, gui, extracolumns, tag, next): Group.__init__(self, iter, parent, label, model, gui, extracolumns) self.tag=tag self.next=next self.__contents={} def __val_of(self, file): # ignore all but the first value here values=file.get_tag(self.tag) if len(values)==0: # FIXME: Here I assume that nothing has this as a tag value: return 'No %s entered'%self.tag.lower() else: return values[0] # Used to check if a file still belongs in the sub-group it has # been assigned to. def belongs_to(self, file, child): val=self.__val_of(file) return self.__contents.get(val, None)==child def add_file(self, file): Group.add_file(self, file) val=self.__val_of(file) if self.__contents.has_key(val): self.__contents[val].add_file(file) else: # make a new group, add it, and add the file newiter=self.model.append(self.iter) newgrp=self.next(newiter, self, val) self.__contents[val]=newgrp newgrp.add_file(file) # Test whether the given value is valid for a child. def validate_child_label(self, child, newlabel): return tags.known_tags[self.tag.lower()].validate(child, newlabel) # Set the label of a child to the given value unless it would # result in a duplication of tags, then sets the corresponding tag # (possibly removing the child from the tree in the process!) # # This routine has to handle validation. def set_child_label(self, child, newlabel): oldlabel=self.model.get_value(child.iter, COLUMN_LABEL) # sanity-check assert(self.__contents.get(oldlabel, None)==child) # Check for duplicates -- if there would be a duplicate, don't # change the label. if not self.__contents.has_key(newlabel): # Change the label in the tree, and move the child in the # dictionary self.model.set(child.iter, COLUMN_LABEL, newlabel) # FIXME: where should this be set? Should the UI call a # set_label() on the object itself, and have that call this? child.label=newlabel del self.__contents[oldlabel] self.__contents[newlabel]=child # now set the tag child.set_tag(self.tag, newlabel) # FIXME: update the row of the child and my row? def remove_child(self, child): label=self.model.get_value(child.iter, COLUMN_LABEL) assert(self.__contents.get(label, None)==child) del self.__contents[label] self.model.remove(child.iter) def children(self): return self.__contents.values() # A group which adds its members with the title in column 1. Note: if # there are multiple titles, it does NOT create multiple entries. On # the other hand, multiple songs with the same title do get multiple # entries. # # Should this just be a tag group on "title"? But it does a few more # things. Could it be a subclass? sibling? A lot of functionality is # shared... class NullGroup(Group): def __init__(self, iter, parent, label, model, gui, extracolumns): Group.__init__(self, iter, parent, label, model, gui, extracolumns) self.__contents=[] def add_file(self, file): Group.add_file(self, file) newiter=self.model.append(self.iter) # create a FileProxy; this will fill in the column data for # the file. f=FileProxy(file, newiter, self, self.model, self.gui, self.extracolumns) self.__contents.append(f) def remove_child(self, file, olddict): assert(self.model.get_value(self.model.iter_parent(file.iter), COLUMN_PYOBJ)==self) self.__contents.remove(file) self.model.remove(file.iter) self.file_removed(file, olddict) # hm. def children(self): return list(self.__contents) # always true (hm, could I check if it's in items? that wouldn't # do the right thing, though) def belongs_to(*args): return 1 def validate_child_label(self, child, newlabel): return tags.known_tags['title'].validate(child, newlabel) # unconditionally set the child's label. Should I check that it really # is a child? def set_child_label(self, child, newlabel): self.model.set(child.iter, COLUMN_LABEL, newlabel) # this should actually regenerate the column anyway: child.set_tag('title', newlabel) # A proxy for files in the GUI. Mostly passthrough, but stores # GUI-specific info, and has hooks to send updates to file tags to the # GUI. # # Currently, this also hides the ability to set multiple tags from the # rest of the GUI. class FileProxy: def __init__(self, file, iter, parent, model, gui, extracolumns): self.file=file self.iter=iter self.parent=parent self.extracolumns=extracolumns self.model=model self.__fill_in_row() self.file.add_listener(self.__reposition_if_necessary) # So we can add it to a FileCollection def items(self): return self.file.items() # Part of the list-item interface def get_label(self): titles=self.file.get_tag('title') if len(titles)==0: return self.file.fn else: return titles[0] def __fill_in_row(self): fill_row(self, self.iter, self.model, self.get_label(), self.extracolumns) # Remove this item entirely from the tree and re-add it. def __reposition(self, olddict): assert(self.parent <> None) # remove first. Local operation. self.parent.remove_child(self, olddict) # we might be able to let the weak references reap this # object, but don't count on it. self.file.remove_listener(self.__reposition_if_necessary) root=self while root.parent <> None: root=root.parent # Now root is the root of the tree; re-add ourselves. (this # object will be discarded and a new proxy created) root.add_file(self.file) # If this item is in the wrong place, remove it from the tree and # re-add it. Otherwise, update our column in the tree. def __reposition_if_necessary(self, file, olddict): assert(self.parent <> None) # Note that this is guaranteed to have at least one parent # (grandparent may be None) parent=self.parent grandparent=parent.parent while grandparent <> None: if not grandparent.belongs_to(self, parent): self.__reposition(olddict) return parent=grandparent grandparent=grandparent.parent # no repositioning needed, update the display to reflect # changes. self.__fill_in_row() # signal to the parent that we have changed (eg, to allow the # updating of counts of tag values) self.parent.file_updated(self.file, olddict) # Part of the interface for list items. def title_editable(self): return True def modified(self): return self.file.modified def add_listener(self, listener): self.file.add_listener(listener) def remove_listener(self, listener): self.file.remove_listener(listener) def get_comments(self): return self.file.comments def valid_genres(self): return self.file.valid_genres() # A file node always has one element, so is not empty. def empty(self): return False # Purging the empty children of a file node is a no-op def purge_empty(self): pass # A file node has no children. def children(self): return [] def get_tag(self, key): return self.file.get_tag(key) def set_tag(self, key, val): assert(type(val)==types.StringType) if val=='': val=None rval=self.file.set_tag_first(key, val) return rval def add_underlying_files(self, lst): lst.append(self.file) musiclibrarian-1.6/musiclibrarian/undo.py0000644000175000017500000001660610137271755021442 0ustar danieldaniel00000000000000"""A generic, unlimited undo mechanism.""" # undo.py - Undo-related code. # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA class TagUndo: """An undo item for a change to a file's tag. The file given to the constructor must be a \"real\" underlying file, not a file proxy.""" # "file" really should be an underlying file, or this might not # work, for two reasons: # # (a) file proxies are discarded when the file moves in the # hierarchy, and accessing a "dead" one is not guaranteed to work # except in special circumstances, and # # (b) accessing a file proxy could trigger adding an undo! def __init__(self, file, fromdict, todict): self.file=file # is there a way to avoid this copy? self.fromdict=fromdict.copy() self.todict=todict.copy() def undo(self): """Set the tags of the enclosed file based on \"fromdict\".""" self.file.set_tags(self.fromdict) def redo(self): """Set the tags of the enclosed file based on \"todict\".""" self.file.set_tags(self.todict) class UndoManager: """A class that encapsulates the logic for undoing and redoing actions. In addition to being able to store a stacks of actions-to-undo and a stack of actions-to-redo, an undo manager is capable of temporarily suppressing the generation of new undos, and of grouping multiple undo events which are related to the same user action. Actions that can be undone are represented as objects with undo() and redo() methods.""" # The two hooks are called immediately after undoing or redoing an # action. def __init__(self, post_undo_hook=lambda:None, post_redo_hook=lambda:None, post_add_undo_hook=lambda:None): """Create an UndoManager. post_undo_hook is called immediately after undoing an action, post_redo_hook is called immediately after redoing an action, and post_add_undo_hook is called immediately after adding a new undo item.""" # "undo" holds the stack of things to undo; "redo" holds a # stack of things to redo. self.undo_stack=[] self.redo_stack=[] self.active_undo_groups=0 self.active_undo_group=None self.undos_suppressed=0 self.post_undo_hook=post_undo_hook self.post_redo_hook=post_redo_hook self.post_add_undo_hook=post_add_undo_hook def suppress_undos(self): """Suppress adding new undos. This is meant to be used to guard undo() and redo(), to prevent extraneous items from being added to the undo stack. However, it may be useful for other purposes. This method may be called multiple times; each call must be balanced by a call to unsuppress_undos.""" assert(self.active_undo_groups==0) self.undos_suppressed+=1 def unsuppress_undos(self): """Cancel a single call to suppress_undos.""" assert(self.undos_suppressed>0) self.undos_suppressed-=1 def add_undo(self, to_undo): """Add a new action to the top of the undo stack. If new undos are suppressed, this is a no-op. If there is an open group, the addition of the undo item is deferred until the group is closed. This implicitly empties the redo stack.""" if not self.undos_suppressed: if self.active_undo_groups>0: self.active_undo_group.append(to_undo) else: self.undo_stack.append(to_undo) # kill the stuff to redo. self.redo_stack=[] self.post_add_undo_hook() def undo(self, *dummy): """Activates the top object on the undo stack. This calls the undo() method of the object on top of the undo stack, then moves that object to the top of the redo stack.""" self.suppress_undos() if len(self.undo_stack) <> 0: self.undo_stack[-1].undo() self.redo_stack.append(self.undo_stack[-1]) del self.undo_stack[-1] self.unsuppress_undos() self.post_undo_hook() def redo(self, *dummy): """Activates the top object on the redo stack. This calls the redo() method of the object on top of the redo stack, then moves that object to the top of the undo stack.""" self.suppress_undos() if len(self.redo_stack) <> 0: self.redo_stack[-1].redo() self.undo_stack.append(self.redo_stack[-1]) del self.redo_stack[-1] self.unsuppress_undos() self.post_redo_hook() def open_undo_group(self): """Begin a group of undo items. Any items added between a call to this method and a matching call to close_undo_group will be collected into a single action. (ie, calling undo() once will undo them all) This is meant to indicate the beginning of a computation which may produce many undo items, but was triggered by a single user action. Calls to open_undo_group/close_undo_group may be nested.""" if not self.undos_suppressed: if self.active_undo_groups==0: assert(self.active_undo_group==None) self.active_undo_group=[] self.active_undo_groups=1 else: self.active_undo_groups+=1 def close_undo_group(self): """End a group of undo items. This balances a single active call to open_undo_group. If there are no more outstanding open groups AND at least one undo item was added while the group was open, a new undo item will be created for the group.""" if not self.undos_suppressed: assert(self.active_undo_groups>0) self.active_undo_groups-=1 if self.active_undo_groups==0: class GroupUndo: def __init__(self, items): self.__contents=items def undo(self): for item in self.__contents: item.undo() def redo(self): for item in self.__contents: item.redo() if len(self.active_undo_group)>0: self.add_undo(GroupUndo(self.active_undo_group)) self.active_undo_group=None def has_undos(self): """Returns true if and only if there is at least one item in the undo stack.""" return len(self.undo_stack)>0 def has_redos(self): """Returns true if and only if there is at least one item in the redo stack.""" return len(self.redo_stack)>0 musiclibrarian-1.6/musiclibrarian/weak.py0000644000175000017500000000410610152760562021410 0ustar danieldaniel00000000000000# weak.py # # Copyright (C) 2004 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # This file contains code to work around a leak in the Python weak # reference abstraction. import weakref def WeakCallableRef(f, callback=lambda *args:None): """This works around a Python bug: bound methods are instantiated as tuple-like objects that point to the object, so a weak reference to a bound method dies instantly. A WeakCallableRef holds a weak reference to both an object and its method, and \"dies\" at exactly the same time as the object. The routine callback is invoked when the object dies.""" class WeakWrapper: """This wraps the transient method object.""" def __init__(self, obj, method): self.obj=weakref.ref(obj, self.__kill) self.method=method def __kill(self, deadman): self.method=lambda *args:None callback(self) def __call__(self, *args): obj=self.obj() if obj == None: return None else: return lambda *args:apply(self.method, (obj,)+args) # Is it a method? try: f.im_self except AttributeError: # No, so the bug doesn't apply; use a standard weak reference. return weakref.ref(f, callback) # Ok, return a true wrapper. return WeakWrapper(f.im_self, f.im_func) musiclibrarian-1.6/musiclibrarian-plugins/0000755000175000017500000000000010164627237021571 5ustar danieldaniel00000000000000musiclibrarian-1.6/musiclibrarian-plugins/id3file.py0000644000175000017500000000545310137277004023462 0ustar danieldaniel00000000000000# id3file.py - Read tags from ID3 files. import ID3 from musiclibrarian import genreset import musiclibrarian.musicfile # Fix a glaring omission in the id3 module: map strings -> numbers backgenres={'Unknown Genre' : 255} genre=0 for name in ID3.ID3.genres: backgenres[name.upper()]=genre genre=genre+1 del genre del name # This works around a bug in some versions of python-id3, where only # trailing NULs are stripped. (some broken software produced # incorrectly formatted tags, where values weren't padded with NULs -- # only one NUL was used to terminate) def strip_padding(s): nulloc=s.find('\0') if nulloc <> -1: return s[:nulloc].rstrip() else: return s.rstrip() class ID3File(musiclibrarian.musicfile.DictFile): ID3Genres=genreset.GenreSet(ID3.ID3.genres) """This class represents a file with id3 tags (ie, an mp3 file)""" def __init__(self, library, fn, cache): self.fn=fn if cache == None: self.__init_from_file(library, fn) else: self.__init_from_cache(library, cache) def __init_from_file(self, library, fn): try: id3file=ID3.ID3(fn) except ID3.InvalidTagError, e: raise musicfile.MusicFileError(e.msg) # it's not clear what modifying the return value of as_dict() # will do, so it's copied below d=id3file.as_dict() # This works around a bug in some versions of python-id3, # where only trailing NULs are stripped. (some broken # software produced incorrectly formatted tags, where values # weren't padded with NULs) dict={} for key,value in d.items(): # Convert to a list to be consistent with ogg dict[key]=[strip_padding(value)] musiclibrarian.musicfile.DictFile.__init__(self, library, dict) def __init_from_cache(self, library, cache): musiclibrarian.musicfile.DictFile.__init__(self, library, cache) def write_to_file(self): """Write the tags back to the MP3 file.""" id3file=ID3.ID3(self.fn) for key,val in self.comments.items(): if key=='GENRE': if len(val)>0: id3file.genre=backgenres[val[0].upper()] else: id3file[key]=val[0] try: id3file.write() except ID3.InvalidTagError,e: # Suppress the stupid useless duplicate errors that occur because # id3file tries to write itself automatically on deletion when # modified=1. id3file.modified=0 raise musiclibrarian.musicfile.MusicFileError(e.msg) def valid_genres(self): """Only some genres are valid for MP3 files.""" return ID3File.ID3Genres musiclibrarian.musicfile.register_file_type('mp3', ID3File) musiclibrarian-1.6/musiclibrarian-plugins/oggfile.py0000644000175000017500000000340510137057762023561 0ustar danieldaniel00000000000000# oggfile.py - read .ogg files into the library. from musiclibrarian import genreset import musiclibrarian.musicfile import ogg.vorbis class OggFile(musiclibrarian.musicfile.DictFile): """This class represents an Ogg file.""" def __init__(self, library, fn, cache): self.fn=fn if cache == None: musiclibrarian.musicfile.DictFile.__init__(self, library, ogg.vorbis.VorbisFile(fn).comment().as_dict()) else: musiclibrarian.musicfile.DictFile.__init__(self, library, cache) def write_to_file(self): """Write the comments back to the Vorbis file.""" class FooExcept: """This class exists solely to work around a bug in past versions of pyvorbis.""" def __init__(self): pass try: c=ogg.vorbis.VorbisComment(self.comments) # work around a pyvorbis bug by trashing exception information: raise FooExcept() except FooExcept: pass c.write_to(self.fn) def get_file(self): class VorbisWrapper: def __init__(self, vf): self.vf=vf def read(self, amt=-1): buf,amt,link=self.vf.read(amt) if amt == 0: return None,0,0,16,0 else: info=self.vf.info(link) return buf,amt,info.rate,16,info.channels try: return VorbisWrapper(ogg.vorbis.VorbisFile(self.fn)) except: # Maybe it isn't present any more? return None def valid_genres(self): """All genres are valid for Vorbis files.""" return genreset.GenreSet(True) musiclibrarian.musicfile.register_file_type('ogg', OggFile) musiclibrarian-1.6/musiclibrarian-plugins/player.py0000644000175000017500000001415610137301567023441 0ustar danieldaniel00000000000000# player.py - Basic music player based on musiclibrarian. # # Copyright 2004 Daniel Burrows from musiclibrarian import libraryview import ao import gtk import Queue import threading # Threads seem like a good idea here. The model is to use a single # thread that knows how to read data from an audio file and send it to # the audio device. It also provides some read-only values which the # GUI can use (eg, in an idle callback or a timeout) to view its # current status. # The communication protocol is simple: the player thread passes def autodetect_device(*args, **kwargs): """Tries to choose a 'good' ao device. Never chooses the null driver.""" devices=['alsa', 'oss', 'irix', 'sun', 'esd', 'arts'] for device in devices: try: return apply(ao.AudioDevice, (device,)+args, kwargs) except ao.aoError: pass return None class PlayerPlayThread(threading.Thread): """A thread which continuously pulls tuples of (buf, amt, samplerate, bits) off a queue and plays them via ao.""" def __init__(self, q, **kwargs): threading.Thread.__init__(self) self.setDaemon(True) self.q=q self.samplerate=None self.bits=None self.channels=None self.cancelled=threading.Event() def set_params(self, samplerate, bits, channels): if samplerate <> self.samplerate or bits <> self.bits or channels <> self.channels: self.samplerate=samplerate self.bits=bits self.channels=channels # Try to guess the device self.device=autodetect_device(bits=bits, rate=samplerate, channels=channels) if self.device == None: print 'Unable to detect which audio device should be used, giving up.' def run(self): while not self.cancelled.isSet(): msg=self.q.get() if msg <> None: buf, amt, samplerate, bits, channels=msg self.set_params(samplerate, bits, channels) self.device.play(buf, amt) def cancel(self): self.cancelled.set() self.q.put(None) class PlayerDecodeThread(threading.Thread): """A thread whose sole purpose in life is to decode a list of files and chain blocks from them onto a queue.""" def __init__(self, files, q): threading.Thread.__init__(self) self.files=files self.q=q self.setDaemon(True) self.cancelled=threading.Event() def run(self): for curr in self.files: if not hasattr(curr, 'get_file'): continue print 'Playing %s'%curr.fn # Implicitly uses the fact that it is always safe to call # get_file() from any thread because the filename is invariant. # # In the future this thread should be given the file object # directly, this is just test code. f=curr.get_file() if f == None: continue buf,amt,rate,bits,channels=f.read(4096) while buf <> None and amt > 0: self.q.put((buf, amt, rate, bits, channels)) buf,amt,rate,bits,channels=f.read(4096) if self.cancelled.isSet(): return def cancel(self): self.cancelled.set() # This class is attached to a view. class PlayerControl: def __init__(self): self.q=None self.play_thread=None self.decode_thread=None def play(self, files): if self.play_thread <> None: self.stop() lst=[] for obj in files: obj.add_underlying_files(lst) self.q=Queue.Queue(500) self.decode_thread=PlayerDecodeThread(lst, self.q) self.play_thread=PlayerPlayThread(self.q) self.decode_thread.start() self.play_thread.start() def stop(self): if self.decode_thread == None: return assert(self.q <> None) assert(self.play_thread <> None) # Note that it's important to stop the decoding thread first # to avoid a deadlock when it tries to write to a full queue. self.decode_thread.cancel() self.decode_thread.join() self.play_thread.cancel() self.play_thread.join() self.q=None self.play_thread=None self.decode_thread=None def setup_view(view): control=PlayerControl() def play(*args): control.play(view.get_selected_objects()) stopitem.set_sensitive(True) stopbutton.set_sensitive(True) def stop(*args): control.stop() stopitem.set_sensitive(False) stopbutton.set_sensitive(False) view.add_context_menu_item('Play', 'Start playing the selected files', gtk.STOCK_GO_FORWARD, play) playmenuitem=gtk.MenuItem('Play') menu=gtk.Menu() playmenuitem.set_submenu(menu) playitem=gtk.ImageMenuItem('Play') im=gtk.Image() im.set_from_stock(gtk.STOCK_GO_FORWARD, gtk.ICON_SIZE_MENU) playitem.set_image(im) playitem.connect('activate', play) stopitem=gtk.ImageMenuItem('Stop') im=gtk.Image() im.set_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_MENU) stopitem.set_image(im) stopitem.connect('activate', stop) menu.append(playitem) menu.append(stopitem) playmenuitem.show_all() view.add_menubar_item(playmenuitem) im=gtk.Image() im.set_from_stock(gtk.STOCK_GO_FORWARD, gtk.ICON_SIZE_LARGE_TOOLBAR) playbutton=view.add_toolbar_item('Play', 'Play the selected files', None, im, play) im=gtk.Image() im.set_from_stock(gtk.STOCK_STOP, gtk.ICON_SIZE_LARGE_TOOLBAR) stopbutton=view.add_toolbar_item('Stop', 'Stop playing music', None, im, stop) def selection_changed(view): active=len(filter(lambda x:hasattr(x, 'get_file'), view.get_selected_files())) playitem.set_sensitive(active) playbutton.set_sensitive(active) view.add_selection_listener(selection_changed) selection_changed(view) stopitem.set_sensitive(False) stopbutton.set_sensitive(False) libraryview.add_new_view_hook(setup_view) musiclibrarian-1.6/musiclibrarian-plugins/playmp3.py0000644000175000017500000000160010137057762023525 0ustar danieldaniel00000000000000# playmp3.py - replace the core MP3 file class with one that allows playback. # # Copyright 2004 Daniel Burrows from musiclibrarian import musicfile from musiclibrarian import plugins import mad id3file=plugins.load_plugin('id3file') class PlayableMP3File(id3file.ID3File): def get_file(self): class MP3Reader: def __init__(self, mf): self.mf=mf def read(self, amt=-1): buf=self.mf.read(amt) if self.mf.mode() == mad.MODE_SINGLE_CHANNEL: channels=1 else: channels=2 if buf == None: amt=None else: amt=len(buf) return buf,amt,self.mf.samplerate(),16,channels return MP3Reader(mad.MadFile(self.fn)) musicfile.register_file_type('mp3', PlayableMP3File) musiclibrarian-1.6/musiclibrarian-plugins/tageditor.py0000644000175000017500000001443210137277203024123 0ustar danieldaniel00000000000000# tageditor.py - a GTK+ dialog for editing tags # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import gtk import string from musiclibrarian import filelist from musiclibrarian import libraryview from musiclibrarian import tags # Wraps a tag editor window. class TagEditor: def __init__(self, file, view): self.file=file self.view=view self.file.add_listener(self.file_updated) label=file.get_label() genres=file.valid_genres().members() if label == None: title="Edit tags" else: title="Edit tags of %s"%label self.widget=gtk.Dialog(title, None, gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_OK, 1, gtk.STOCK_CANCEL, 0)) alltags=tags.known_tags.keys() alltags.sort() table=gtk.Table(len(alltags), 2, False) self.widget.vbox.add(table) # tag_entries is a list of tuples (tag, entry, original_value) # which is used to test whether the value changed (in which # case the tag will be set) self.tag_entries=[] # build the table for row in range(0,len(alltags)): tag=tags.known_tags[alltags[row]] # FIXME: handle multiple values properly label=gtk.Label(tag.title) table.attach(label, 0, 1, row, row+1, 0, 0) entry_text=self.__get_entry_text(tag.tag) if tag.tag.upper() <> 'GENRE' or genres==True: entry=gtk.Entry() table.attach(entry, 1, 2, row, row+1, gtk.EXPAND|gtk.FILL, 0) else: genres.sort() genres=['']+map(string.capwords, genres) combo=gtk.combo_box_entry_new_text() for g in genres: combo.append_text(g) #combo.set_value_in_list(True, True) #combo.set_case_sensitive(False) #combo.set_item_string(combo.list.get_children()[0], '') entry=combo.child table.attach(combo, 1, 2, row, row+1, gtk.EXPAND|gtk.FILL, 0) entry.set_text(entry_text) self.tag_entries.append((tag.tag,entry,entry_text)) self.widget.connect('response', self.response) # common code from the constructor and file_updated -- get the # text to be placed in an entry for the file. def __get_entry_text(self, tag): current_value=self.file.get_tag(tag) if len(current_value)==0: current_value=[''] return current_value[0] def show(self): self.widget.show_all() def file_updated(self, file, oldcomments): new_entries=[] for tag,entry,orig_val in self.tag_entries: new_text=self.__get_entry_text(tag) if entry.get_text()==orig_val: entry.set_text(new_text) new_entries.append((tag, entry, new_text)) self.tag_entries=new_entries def response(self, widget, response): if response==1: # Ok was pressed self.view.open_undo_group() try: # Validate everything up-front. (does it make sense to # test this when a field is changed? I find those # 'instant popups' annoying..) for tag,entry,orig_val in self.tag_entries: text=entry.get_text() if text <> '' and not tags.known_tags[tag].validate(self.file, text): return modified=0 # could this be more efficient? eg, setting a bunch of # tags all at once. for tag,entry,orig_val in self.tag_entries: if entry.get_text() <> orig_val: modified=1 # FIXME: this relies heavily on the behavior of # FileList, and the fact that we place # real files in it, not GUI file proxies. # # (ie: GUI file proxies take strings in set_tag, and # the underlying files take lists) self.file.set_tag(tag, [entry.get_text()]) if modified: self.view.purge_empty() finally: self.view.close_undo_group() self.widget.destroy() self.file.remove_listener(self.file_updated) def create_tageditor(view): selected=view.get_selected_files() if len(selected)>0: TagEditor(filelist.FileList(view, selected), view).show() def setup_view(view): edittags_item=gtk.ImageMenuItem('Edit tags') im=gtk.Image() im.set_from_stock(gtk.STOCK_PROPERTIES, gtk.ICON_SIZE_MENU) im.show() edittags_item.set_image(im) view.add_edit_menu_item(edittags_item) edittags_item.connect('activate', lambda w:create_tageditor(view)) edittags_item.show() im=gtk.Image() im.set_from_stock(gtk.STOCK_PROPERTIES, gtk.ICON_SIZE_LARGE_TOOLBAR) edittags_button=view.add_toolbar_item('Edit Tags', 'Edit the tags of the selected files', None, im, lambda w:create_tageditor(view)) del im def selection_changed(view): active=len(view.get_selected_files()) edittags_item.set_sensitive(active) edittags_button.set_sensitive(active) view.add_selection_listener(selection_changed) selection_changed(view) libraryview.add_new_view_hook(setup_view) musiclibrarian-1.6/COPYING0000644000175000017500000004311007742113751016137 0ustar danieldaniel00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc. 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Library General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Library General Public License instead of this License. musiclibrarian-1.6/NEWS0000644000175000017500000000613610164625713015610 0ustar danieldaniel00000000000000[12/29/2004] Version 1.6 "Christmas Carol" * music-librarian now works with and requires libglade2 version 2.4.1 or greater. * You can now pre-define several different libraries of music (a "library" is now a collection of several different directories) and choose one to be loaded by default when the program starts. * The list of files can be organized by genre, artist, and album. * As a first step towards drag&droppability of music files, it's possible to highlight a music file/group without initiating editing of its tags: tags are only edited if you double-click the file/group or press Enter while it's selected. * The program no longer fails catastrophically when you try to load a non-directory. Other errors encountered when loading a library should also be handled more cleanly. [10/25/04] Version 1.5 "Non-Partisan" * Add a basic plugin framework. This is currently mainly used to split the program into "core" and "optional" parts; if the libraries for the "optional" parts are missing, the main program should run. Documentation is sub-par, but enterprising users could probably use this mechanism to, eg, add new file types. * Implement a cache of file tag information. This dramatically improves the load time of large directories, even if they're already cached by the OS; if they aren't, program startup can go from several *minutes* to several *seconds*. * When saving or loading a file fails, a (currently rather ugly) dialog box is shown to the user to indicate which file failed, rather than dumping text to stderr and aborting. * The edit tags dialog now displays a dropdown list of genres when an MP3 file is being edited. * Basic support for libraries composed of multiple directories. * A very simple music player is included in this release, as a plugin. If you have python-pyao installed, you can play Vorbis files; if you also install python-pymad, you can play MP3 files too. * The main window now has a progress bar. [10/23/03] Version 1.2 "Get the leather gloves out, I think it's teething" * Fix a bug which prevented you from opening an 'edit tags' dialog box for a file without a TITLE tag. * Discard python-id3 files after using them, so that the program doesn't run out of file descriptors. * Added a workaround for how python-id3 loads id3 tags. Some older, broken ID3-writing software creates files in which tags were terminated with a single NUL (rather than the field being padded with NULs). Hopefully this will be fixed in python-id3 eventually, but it's cheap to do this myself, and old versions of python-id3 are likely to exist for the forseeable future. * Don't reverse the list of previously loaded libraries every time the program runs. Credit for finding all 3 MP3 bugs goes to Malcolm Parsons [10/12/03] Version 1.1 "Did I just do that?" * Fix a really stupid bug in 1.0. [10/12/03] Version 1.0 "Once more into the breach" * Initial public release of the program. * Yes, I really did label it 1.0. musiclibrarian-1.6/README0000644000175000017500000000701310164624575015771 0ustar danieldaniel00000000000000 Music Librarian 0) Overview Music Librarian is a tool for organizing a collection, large or small, of music. It is presently designed to "do one thing and do it well". Music Librarian was created after I tried several "tagging" programs. These programs had many powerful features, but I found their interfaces to be very awkward for performing simple operations, while their more powerful features were not particularly useful for me. Music Librarian provides a sensible (for me :-) ) point-and-click interface from which to manipulate tags. As a bonus, it has a name which indicates what it is, rather than being a clever play on musical terminology. The only music file formats presently supported by Music Librarian are .ogg and .mp3. Additional formats could be added with a little work. In theory one just needs to write a new file class in library.py, but if the new file type doesn't conform to the assumptions made elsewhere, more changes might be necessary. Music Librarian is free software licensed under the GNU GPL. A copy of the GPL should have been distributed with this copy of the program. 1) Installation Note that installation is not necessary: Music Librarian runs just fine from its source directory. The executable name is "music-librarian". To install Music Librarian under the same prefix as your Python installation, run "./setup.py install" as root. While the setup script allows you to specify a different installation prefix, *this is not presently a supported configuration*. Because musiclibrarian contains some data files, you will have to edit the main program and tell it where its data files are. This is obviously not ideal, and I will allow arbitrary installations in a future release (this may require moving away from Python's distutils, which don't appear to provide hooks for rewriting files based on the installation prefix) 2) Usage Music Librarian works on "libraries". A library is simply a directory; Music Librarian will search that directory and any subdirectories for music files of a known type (file types are recognized by their extension). You can provide a library as a command-line argument, or open one after the program starts. Once the library is opened, you will see a list of all the files contained in it. Initially, this list will be organized into categories based on the artist and album of each file, but you can select alternate organization rules from the "View" menu. You can also store a definition for a library, by selecting "Create Library From View" (or from the "Edit Libraries" dialog). If you create a library and set it to be the "default", that library will be loaded when the program starts (unless a different library is requested on the command-line, of course). To edit tags, either double-click on the tags you want to change in the list of files, or select one or more files and press the "Edit Tags" button. Music Librarian does not presently offer the ability to set tags based on filenames, or to rename files based on tags. If you need these features, a program such as "cantus" might be useful for you -- or if you speak Python, you could add them to Music Librarian. 3) Additional information The Music Librarian website is http://alioth.debian.org/musiclibrarian. The code for the project is kept in a Subversion repository stored at http://svn.debian.org. The author of the code (and this README) is Daniel Burrows To see some of the future plans for the program, see the TODO file included in this distribution. musiclibrarian-1.6/README.plugins0000644000175000017500000003541410152764734017456 0ustar danieldaniel00000000000000 Music Librarian plugin interface 0) Overview Music Librarian can be extended through the use of "plugins": pieces of Python code which are distributed separately from the program, and loaded into it at run-time. This document describes the interface provided to plugins. Because Python is a rather "promiscuous" language, you may find that there are additional interfaces and variables which are not described in this document. **DO NOT USE THESE INTERNAL INTERFACES!** Any interfaces not described in this document are subject to change without notice in any release of the program. While you are not required to inform me when you write plugins, I would appreciate hearing about plugins which are publically distributed, so I can list them in the package and/or on the Web site...if I create a Web site. 1) Plugin basics A plugin is simply a Python file which is loaded and executed when Music Librarian starts. Plugins may be placed in ~/.musiclibrarian/plugins or in [sys.prefix]/share/musiclibrarian/plugins. Any file in one of these directories which ends in '.py', and any subdirectory containing "__init__.py" (ie, a package), will be loaded as a plugin. A plugin in a user's home directory will override a plugin of the same name in the system directory. To create a plugin containing several files, place an entire Python package under one of these directories. Plugins should not depend on the order in which they are loaded. While a plugin is allowed to import other plugins as modules, doing so will not respect the behavior described above, and will not work if the other plugin is in a different search path. Instead, plugins are encouraged to use the function musiclibrarian.plugins.load_plugin, which loads a plugin given its name. There is presently no mechanism for unloading and reloading plugins at runtime. 2) The core library interface 2.1) Files The lowest level of organization in Music Librarian is the "file". A file object, in this context, is a representation of a music file and its tags. Files provide the following operations: - f.get_tag(key) - f.set_tag(key, val) These manipulate the file's tags. The value of a tag is a list of zero or more strings. - f.set_tag_first(key, val) Sets the first entry in the given tag to the given value. - f.set_tags(dict) Replaces the current tags of the file object with the tags stored in "dict". - f.items() Returns a list of tuples (TAG, VALUE), where "value" is represented as described above. - f.add_listener(listener, as_weak=True) -> id - f.remove_listener(listener | id) Add a "listener" to the file, or remove a listener from the file. The listener will be called whenever one of the file's tags is modified. It will be passed the following arguments: (file, olddict). The first argument is the file which was modified; the second is a dictionary representing its tags prior to the modification. By default, the listener is held "mostly weakly": this is like a normal weak reference, but in the case of methods, the object is held weakly rather than the method. (this means that the listener stays alive as long as there is a reference to the object, and is removed when the object dies) The return value of add_listener is an id which can be used to remove the listener using remove_listener. - f.valid_genre(genre) Returns True if genre (a string) is a valid genre for this type of file. (MP3 files are limited to a small number of genres from an arbitrarily defined (and poorly standardized) list; Ogg Vorbis files are not thus limited) - f.modified This member is set to True if the file has been modified since it was last saved, False otherwise. 2.2) Genre Sets A "genre set" is a set of musical genres. It can be either a finite set of strings, or the infinite set of all strings. genreset.GenreSet(genres) If genres is True, the resulting set is the infinite set of all strings. Otherwise, genres should be a list of strings, and the new genre set will include exactly those strings. If g is a genreset, the following expressions are valid: - g.member(genre) Returns True if genre is a member of g, False otherwise. - g.members() Returns a list of the genres in g, or True if g is infinite. - g.union(g2) Returns a set which is the union of g and g2. - g.intersect(g2) Returns a set which is the intersection of g and g2. 2.3) The File Store The file store is an object which maintains information about all the files loaded by the system. Usually stores are only accessed via MusicLibrary objects. The following interface functions may be useful: - store.set_modified(file, modified) modified should be True or False; this tells the store whether "file" was modified. This information is displayed to the user, and is used to determine which files should be saved or reverted. 2.4) Libraries Libraries of music are represented by MusicLibrary objects. A library is a subset of the store that is contained in one or more directories. A MusicLibrary provides the following interface: - MusicLibrary(store, directories, callbacks) Creates a new MusicLibrary to access the library in the given directories. Each element of "callbacks" is a progress notification function that is called as the load progresses. (see the section "progress functions") The progress functions are matched up to the corresponding directory in "directories". - library.add_dirs(dirs, callbacks) Adds some directories to the library. dirs is a list of directories and callbacks is a list of corresponding progress notification functions. - library.commit() Saves all changes to files in this library. - library.revert() Reverts all changes to files in this library. - library.set_modified(file, modified) Sets the modified state of the file. Only modified files will be affected by a commit() or revert() call. 2.5) Progress functions Several long-running operations allow the user to pass in a function which will be called as the operation progresses. A function which is used in this way is called a "progress function". Progress functions have the following signature: def progress_fun(cur, max): [...] When the function is called, "cur" is the current amount of progress that has been made, and "max" is the amount of progress needed to complete the operation. "cur" and "max" may both be None, indicating that an operation is progressing but no information is available on how close to completion it is. A convenience class is available to perform basic preprocessing on progress messages: - progressupdater.ProgressUpdater(message, callback, update_interval=0.5) This creates a new ProgressUpdater. An instance of ProgressUpdater can be used as a progress function; it will call "callback" with two arguments (message, percent) periodically. It will intelligently decide when to activate the callback to make certain key updates always visible, but to hide "unimportant" updates (to avoid wasting CPU time with lots of unnecessary redraws). 2.6) Music files A music file contains a mapping from tags (strings) to tag values (lists of strings). Tags are case-insensitive: 'title', 'TITLE', 'Title', and 'TiTlE' are all the same tag. Music file classes should provide the following interface: - f.add_listener(listener, weak=True) - f.remove_listener(listener) - f.call_listeners(oldcomments) These functions are used to detect and act on changes to the tags of a file. call_listeners is given the old tags (comments) of the file as a dictionary. The class musicfile.File provides an implementation of these functions: - musicfile.File(store) "store" is the file store with which this file is associated, and is stored in the "store" attribute of the File object. - f.get_tag(key) Returns a list of all values associated with "key" in this file. If no values are associated with this file, returns []. - f.set_tags(key, dict) Sets all the tags of the file as specified in "dict". If the tags have changed, invokes self.call_listeners appropriately. - f.set_tag(key, value) Sets a single tag to a new value. "val" can be either a list of values or a single string value. - f.set_tag_first(key, value) Sets the first value associated with "key" in this file, leaving the other values (if any) unchanged. - f.tags() - f.values() - f.items() Analogous to dict.keys(), dict.values(), and dict.items(): lists of the keys, values, and (key,value) pairs. - f.commit() - f.revert() Save changes to this file to disk, or revert the file to its on-disk state. - f.get_cache() Returns information that should be cached about this file. The return value of get_cache() should contain *all* information about the file, to the point that opening the underlying file is unnecessary. - f.comments The tags of the file, as a dictionary. These methods and attributes are provided by the class musicfile.DictFile: - musicfile.DictFile(store, comments) store is the file store with which this file is associated; comments is an initial dictionary of tag -> value associations (typically read off of the disk). The get_cache() method of a DictFile returns the comments dictionary of the file. - f.fn The actual filename associated with this file. - f.write_to_file() Subclasses of DictFile should implement this to actually write out changes; it will be invoked by DictFile.commit. - f.valid_genres() Returns a genreset which is the set of valid values for the Genre tag of f. The constructor of a music file class should have the following signature: - C(store, fn, cache) store is the file store with which this file is associated. fn is the filename of the file to load. cache is either None, or information associated with the file (as returned by get_cache()). The file named by fn should only be user to load information if cache is None. 2.7) Playable music files The standard "player" plugin allows the user to play music files that support it. To become playable, a music file must implement the get_file method: f.get_file() Returns an object with a read() method that can be used to retrieve decoded audio data. The return value of read() is a tuple (buf,amt,rate,bits,channels) where buf is an object representing the raw audio data, amt is the size of buf, and rate, bits, channels represent the sample rate, bit width, and number of channels of the raw data. See player.py, oggfile.py, and playmp3.py for examples. 2.8) Registering music file types To make a new type of music file known to the program, use the musiclibrarian.musicfile.register_file_type function: - register_file_type(ext, cls) ext is the extension associated with this file type, such as "mp3" or "ogg", and cls is a callable object with the interface of a music file constructor, returning a music file instance. 2.9) Configuration options The musiclibrarian.config module is the interface to the configuration file. - config.add_option(section, name, default=None, validator=lambda x:True) Adds a new option to the configuration file. The option is added to the section "section" under the name "name"; "default" is its default value, and "validator" is a sanity-checking function that returns True if the given value is a valid setting for that option. - config.get_option(section, name) Returns the current value of the configuration option specified by section and name. The option must previously have been registered by config.add_option. - config.set_option(section, name, value) Sets the configuration option with the given name to the given value. 2.10) Plugin system The module musiclibrarian.plugins is the interface to the plugin system. - plugins.load_plugin(name) Attempts to load the named plugin, returning the newly loaded module. If the plugin cannot be loaded, an error message will be printed and None will be returned. 2.11) The Undo system The module musiclibrarian.undo contains utility code to handle undoing and redoing actions. The main class of the module is undo.UndoManager: UndoManager(post_undo_hook=lambda:None, post_redo_hook=lambda:None, post_add_undo_hook=lambda:None) Create a new, empty UndoManager. The hooks in the constructor are called when an undo or redo is invoked, or when a new undo is added, respectively. An UndoManager maintains a stack of Undoable objects and a "current location" within the stack. If m is an UndoManager, the following expressions are valid: - m.add_undo(u) Add a new Undoable object to the "undo" stack. If the current location is not the top of the stack, all objects above the current location are removed. - m.undo() Executes the "undo" action of the Undoable object at the current location, and moves the current location one step down the stack. If the current location is beyond the bottom of the stack, nothing happens. Undos are suppressed (as with suppress_undos) while this method is executing. - m.redo() Executes the "redo" action of the Undoable object above the current location, and moves the current location one step up the stack. If the current location is the top of the stack, nothing happens. Undos are suppressed (as with suppress_undos) while this method is executing. - m.open_undo_group() - m.close_undo_group() These calls bracket a group of add_undo calls. All objects added between an open_undo_group() call and a close_undo_group() call will be placed into a single location on the stack. Calls to open_undo_group()/close_undo_group() may be nested. The undo stack will not be modified until all open_undo_group() calls have been closed by a close_undo_group() call. - m.suppress_undos() - m.unsuppress_undos() Temporarily suppress the addition of new objects to the stack; calls to add_undo are silently ignored. Calls to suppress_undos() may be nested, and each call to suppress_undos() should be balanced with a call to unsuppress_undos(). An Undoable object must implement two methods: - u.undo() Reverse the action corresponding to this Undoable, assuming that all actions corresponding to later Undoables have already been reversed. - u.redo() Perform the action corresponding to this Undoable, assuming that it has been reversed by undo() and that the actions corresponding to all previous Undoables have been performed. musiclibrarian-1.6/TODO0000644000175000017500000000226310137301465015571 0ustar danieldaniel00000000000000Columns: * Make it easier to show/hide columns? (eg: Windows Media Player pops up a menu when you right-click on the column headers) * [POST-1.0] Add the ability for users to add more columns (eg, for new tags) A canned set of columns should be good enough for now. Editing tags: * [POST-1.0] Support multiple values for a given tag in the tag editor dialog. Tree UI: * [POST-1.0] Show the number of items in a tree? (how does this interact with editing labels?) General: * [POST-1.0] Multiple windows for different libraries; kill Exit in favor of Close. Esoteric feature; even I don't really need it. This also wreaks havoc with the present configuration scheme. NOTE: the better music player might be easier to implement if I have support for multiple views in the underlying modules. * [POST-1.0] Move files around to match the virtual organization * [POST-1.0] Better music player. A queue-based player based on Music Librarian would eat XMMS' breakfast. (but probably not its lunch or dinner) * [POST-1.0] Better plugin interface It would be nice if the user could choose not to load particular plugins, maybe. musiclibrarian-1.6/music-librarian0000755000175000017500000000455710164620711020116 0ustar danieldaniel00000000000000#!/usr/bin/python # # Copyright (C) 2003 Daniel Burrows # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import pygtk pygtk.require('2.0') import gtk from musiclibrarian import cache from musiclibrarian import config from musiclibrarian import filestore from musiclibrarian import libraryview from musiclibrarian import plugins import os import sys gtk.threads_init() gtk.threads_enter() plugins.load_all_plugins() if len(sys.argv)>=2: library_locations=sys.argv[1:] else: default_library=config.get_option('General', 'DefaultLibrary') if default_library == None: library_locations=None else: library_locations=(default_library,) if os.access(os.path.join(sys.path[0], 'musiclibrarian.glade'), os.R_OK): glade_location=os.path.join(sys.path[0], 'musiclibrarian.glade') else: glade_location=os.path.join(sys.prefix, 'share', 'musiclibrarian', 'musiclibrarian.glade') # Create a global cache; should this be done elsewhere..? mycache=cache.Cache(os.path.expanduser('~/.musiclibrarian/cache')) mystore=filestore.FileStore(mycache) gui=libraryview.MusicLibraryView(mystore, glade_location) gui.main_widget.show_all() def boot(): # Avoid deadlock: the stupid gdk lock is held by normal callbacks, # but not by idle callbacks. Calling gtk.main_iteration_do hangs # if we don't own the gdk lock, so it's necessary to take the lock # here so we have a consistent environment for the rest of the # code. gtk.threads_enter() try: if library_locations <> None: gui.open_library(library_locations) finally: gtk.threads_leave() return gtk.FALSE import gobject gobject.idle_add(boot) gtk.main() gtk.threads_leave() musiclibrarian-1.6/musiclibrarian.glade0000644000175000017500000006773610164613441021123 0ustar danieldaniel00000000000000 True Music Librarian GTK_WINDOW_TOPLEVEL GTK_WIN_POS_NONE False 800 600 True False True False False GDK_WINDOW_TYPE_HINT_NORMAL GDK_GRAVITY_NORTH_WEST True False 0 True GTK_SHADOW_OUT GTK_POS_LEFT GTK_POS_TOP True True _File True True Create, edit, and rename libraries Open _Library True True Display music files located in a directory on your computer _Open Directory... True True gtk-open 1 0.5 0.5 0 0 True Open _Recent True True True Create a library containing the files that are visible in this window. _Create Library From View... True True Create, edit, and delete libraries. _Edit Libraries... True True True Save changes you have made to this library _Save Changes True True gtk-save 1 0.5 0.5 0 0 True Discard all changes to this library _Discard Changes True True gtk-revert-to-saved 1 0.5 0.5 0 0 True True Quit the program gtk-quit True True _Edit True True gtk-undo True True gtk-redo True True True _View True True Change how the displayed music is organized _Organize View True True Show or hide columns of information _Show/hide Columns True 0 False False True GTK_SHADOW_OUT GTK_POS_LEFT GTK_POS_TOP True GTK_ORIENTATION_HORIZONTAL GTK_TOOLBAR_BOTH True True True gtk-open True True False False True True gtk-save True True False False True True gtk-revert-to-saved True True False False True True True True True False False True gtk-undo True True False False True True gtk-redo True True False False True 0 False False True True GTK_POLICY_ALWAYS GTK_POLICY_ALWAYS GTK_SHADOW_NONE GTK_CORNER_TOP_LEFT True True True False False True 0 True True True False 0 True True 0 True True True GTK_PROGRESS_LEFT_TO_RIGHT 0 0.10000000149 0 False False 0 False False True Edit Libraries GTK_WINDOW_TOPLEVEL GTK_WIN_POS_NONE False True False True False False GDK_WINDOW_TYPE_HINT_DIALOG GDK_GRAVITY_NORTH_WEST True True False 0 True GTK_BUTTONBOX_END True True True gtk-cancel True GTK_RELIEF_NORMAL True -6 True True True gtk-ok True GTK_RELIEF_NORMAL True -5 0 False True GTK_PACK_END 10 True True 12 True 0.5 0.5 GTK_SHADOW_IN 5 True False 12 True GTK_BUTTONBOX_DEFAULT_STYLE 5 True True True gtk-new True GTK_RELIEF_NORMAL True True True True gtk-delete True GTK_RELIEF_NORMAL True True True True _Rename True GTK_RELIEF_NORMAL True 0 False True True True GTK_POLICY_ALWAYS GTK_POLICY_ALWAYS GTK_SHADOW_IN GTK_CORNER_TOP_LEFT 150 True True True False False True 0 True True True <b>Library</b> False True GTK_JUSTIFY_LEFT False False 0.5 0.5 0 0 label_item 0 True True True 0.5 0.5 GTK_SHADOW_IN 5 True False 12 True True GTK_POLICY_ALWAYS GTK_POLICY_ALWAYS GTK_SHADOW_IN GTK_CORNER_TOP_LEFT True True True False False True 0 True True True GTK_BUTTONBOX_DEFAULT_STYLE 5 True True True gtk-add True GTK_RELIEF_NORMAL True True True True gtk-remove True GTK_RELIEF_NORMAL True 0 False True True <b>No library selected</b> False True GTK_JUSTIFY_LEFT False False 0.5 0.5 0 0 label_item 0 True True 0 True True musiclibrarian-1.6/setup.py0000755000175000017500000000156710164625733016633 0ustar danieldaniel00000000000000#!/usr/bin/env python from distutils.core import setup setup(name="musiclibrarian", version="1.6", description="Music organization software", author="Daniel Burrows", author_email="dburrows@debian.org", url="http://alioth.debian.org/projects/musiclibrarian", packages=['musiclibrarian'], scripts=['music-librarian'], data_files=[('share/musiclibrarian', ['musiclibrarian.glade']), ('share/musiclibrarian/plugins', ['musiclibrarian-plugins/id3file.py', 'musiclibrarian-plugins/oggfile.py', 'musiclibrarian-plugins/player.py', 'musiclibrarian-plugins/playmp3.py', 'musiclibrarian-plugins/tageditor.py'])] ) musiclibrarian-1.6/PKG-INFO0000644000175000017500000000041210164627237016200 0ustar danieldaniel00000000000000Metadata-Version: 1.0 Name: musiclibrarian Version: 1.6 Summary: Music organization software Home-page: http://alioth.debian.org/projects/musiclibrarian Author: Daniel Burrows Author-email: dburrows@debian.org License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN