From: Mark Wooding Date: Thu, 5 May 2016 09:11:17 +0000 (+0100) Subject: coverart/coverart.in: Reorder, reformat, and document. X-Git-Url: https://git.distorted.org.uk/~mdw/autoys/commitdiff_plain/64096b9c536d83c36609394f93c869af44af2416 coverart/coverart.in: Reorder, reformat, and document. Lots of other minor changes mixed in. This was a mess I found in my working tree, and didn't bother to untangle. --- diff --git a/coverart/coverart.in b/coverart/coverart.in index 9c1072c..eca9a72 100644 --- a/coverart/coverart.in +++ b/coverart/coverart.in @@ -34,6 +34,7 @@ from __future__ import with_statement from cStringIO import StringIO import errno as E import json as JS +import optparse as OP import os as OS; ENV = OS.environ import sys as SYS import urllib as U @@ -46,11 +47,45 @@ import gtk as GTK GDK = GTK.gdk ###-------------------------------------------------------------------------- +### Build-time configuration. + +VERSION = '@VERSION@' + +###-------------------------------------------------------------------------- ### Theoretically tweakable parameters. THUMBSZ = 96 ###-------------------------------------------------------------------------- +### Utilities. + +def whinge(msg): + """Tell the user of some unfortunate circumstance described by MSG.""" + dlg = GTK.MessageDialog(None, 0, GTK.MESSAGE_ERROR, GTK.BUTTONS_NONE, msg) + dlg.set_title('%s Error' % PROG) + dlg.add_button('_Bummer', 0) + dlg.run() + dlg.destroy() + +def fetch_url(url): + """Fetch the resource named by URL, returning its content as a string.""" + out = StringIO() + with U.urlopen(url) as u: + while True: + stuff = u.read(16384) + if not stuff: break + out.write(stuff) + return out.getvalue() + +def fix_background(w): + """Hack the style of the window W so that it shows white-on-black.""" + style = w.get_style().copy() + style.base[GTK.STATE_NORMAL] = BLACK + style.bg[GTK.STATE_NORMAL] = BLACK + style.text[GTK.STATE_NORMAL] = WHITE + w.set_style(style) + +###-------------------------------------------------------------------------- ### The image cache. class ImageCache (object): @@ -267,173 +302,6 @@ class FileImage (CacheableImage): """Acquire the image data.""" return GDK.pixbuf_new_from_file(me._file) -###-------------------------------------------------------------------------- -### Miscellaneous utilities. - -def fetch_url(url): - """Fetch the resource named by URL, returning its content as a string.""" - out = StringIO() - with U.urlopen(url) as u: - while True: - stuff = u.read(16384) - if not stuff: break - out.write(stuff) - return out.getvalue() - -def fix_background(w): - """Hack the style of the window W so that it shows white-on-black.""" - style = w.get_style().copy() - style.base[GTK.STATE_NORMAL] = BLACK - style.bg[GTK.STATE_NORMAL] = BLACK - style.text[GTK.STATE_NORMAL] = WHITE - w.set_style(style) - -###-------------------------------------------------------------------------- -### The windows. - -class BaseCoverViewer (GTK.ScrolledWindow): - """ - I represent a viewer for a collection of cover images, shown as thumbnails. - - The image objects should have the following attributes. - - img The actual image, as a CacheableImage. - - text Some text to associate with the image, as a Python - string. - - I will store an `iterator' in the image object's `it' attribute, which will - let others identify it later to the underlying Gtk machinery. - - Subclasses should implement two methods: - - activate(COVER) The COVER has been activated by the user (e.g., - double-clicked). - - select(COVER) The COVER has been selected by the user (e.g., - clicked) in a temporary manner; if COVER is None, - then a previously selected cover has become - unselected. - """ - - ## Useful attributes: - ## iv = an IconView widget used to show the thumbnails - ## list = a ListStore object used to keep track of the cover images; each - ## item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF - ## and TEXT are extracted from the cover object in the obvious way - - def __init__(me): - """Initialize a BaseCoverViewer.""" - - ## Initialize myself, as a scrollable thingy. - GTK.ScrolledWindow.__init__(me) - me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC) - - ## Set up an IconView to actually show the cover thumbnails. - me.iv = GTK.IconView() - me.iv.connect('item-activated', - lambda iv, p: me.activate(me._frompath(p))) - me.iv.connect('selection-changed', me._select) - me.iv.set_pixbuf_column(0) - me.iv.set_text_column(1) - me.iv.set_orientation(GTK.ORIENTATION_VERTICAL) - me.iv.set_item_width(THUMBSZ + 32) - fix_background(me.iv) - me.add(me.iv) - - ## Clear the list ready for cover images to be added. - me.reset() - - def reset(me): - """ - Clear the viewer of cover images. - - This does /not/ clear the `it' attribute of previously attached cover - object. - """ - me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT) - me.iv.set_model(me.list) - me.iv.unselect_all() - - def addcover(me, cov): - """ - Add the cover image COV to the viewer. - - `COV.it' is filled in with the object's iterator. - """ - cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov]) - - def _frompath(me, path): - """Convert a PATH to a cover image, and return it.""" - return me.list[path][2] - - def _select(me, iv): - """Handle a selection event, calling the subclass `select' method.""" - sel = me.iv.get_selected_items() - if len(sel) != 1: me.select(None) - else: me.select(me._frompath(sel[0])) - -class SearchCover (object): - """ - A base class for images in the SearchViewer window. - """ - def __init__(me, img, width = None, height = None, marker = ''): - """ - Initialize a SearchCover object. - - The IMG is a CacheableImage of some kind. If the WIDTH and HEIGHT are - omitted, the image data will be acquired and both dimensions calculated - (since, after all, if we have to go to the bother of fetching the image, - we may as well get an accurate size). - """ - me.img = img - if width is None or height is None: - pix = img.pixbuf - width = pix.get_width() - height = pix.get_height() - me.text = '%d×%d%s' % (width, height, marker) - -class SearchViewer (BaseCoverViewer): - """ - I'm a BaseCoverViewer subclass for showing search results. - - I'll be found within a CoverChooser window, showing the thumbnails - resulting from a search. I need to keep track of my parent CoverChooser, - so that I can tell it to show a large version of a selected image. - """ - - ## Useful attributes: - ## _chooser = the containing CoverChooser object - - def __init__(me, chooser): - """ - Initialize the SearchViewer, associating it with its parent CoverChooser. - """ - BaseCoverViewer.__init__(me) - me._chooser = chooser - - def switch(me, current): - """ - Switch to a different album chosen in the CoverChooser. - - CURRENT is either None, in which case the viewer is simply cleared, or - the current cover image (some CacheableImage object) for the newly chosen - album, which should be shown as the initial selection. - """ - me.reset() - if current: - cov = SearchCover(current, marker = '*') - me.addcover(cov) - me.iv.select_path(me.list.get_path(cov.it)) - - def activate(me, cov): - """Inform the CoverChooser that the user activated COV.""" - me._chooser.activated(cov) - - def select(me, cov): - """Inform the CoverChooser that the user selected COV.""" - me._chooser.selected(cov) - class RemoteImage (CacheableImage): """ I represent an image whose data can be fetched over the network. @@ -516,6 +384,12 @@ class RemoteImage (CacheableImage): try: ld.close() except G.GError: pass + @property + def raw(me): + """The raw image data fetched from the remote source.""" + me._fetch() + return me._data + def _acquire(me): """ Return a decoded image from the image data. @@ -523,13 +397,13 @@ class RemoteImage (CacheableImage): If this isn't going to work, return a dummy image. """ try: - me._fetch() ld = GDK.PixbufLoader() - try: ld.write(me._data) + try: ld.write(me.raw) finally: ld.close() return ld.get_pixbuf() except Exception, e: - print e + SYS.stderr.write("%s: failed to decode image from `%s': %s'" % + (PROG, me._url, str(e))) return me.ERRIMG.pixbuf @property @@ -567,6 +441,34 @@ class SearchImage (RemoteImage): if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl)) return me._thumb +###-------------------------------------------------------------------------- +### The windows. + +class SearchCover (object): + """ + A base class for icons in the SearchViewer window. + """ + + ## Useful attributes: + ## img = a CacheableImage for the image to display + ## text = a string containing the text to show + + def __init__(me, img, width = None, height = None, marker = ''): + """ + Initialize a SearchCover object. + + The IMG is a CacheableImage of some kind. If the WIDTH and HEIGHT are + omitted, the image data will be acquired and both dimensions calculated + (since, after all, if we have to go to the bother of fetching the image, + we may as well get an accurate size). + """ + me.img = img + if width is None or height is None: + pix = img.pixbuf + width = pix.get_width() + height = pix.get_height() + me.text = '%d×%d%s' % (width, height, marker) + class SearchResult (SearchCover): """ I represent information about an image found while searching the web. @@ -585,6 +487,165 @@ class SearchResult (SearchCover): SearchCover.__init__(me, SearchImage(url, ref, tburl), width = w, height = h) +class ViewCover (object): + """ + I represent an album cover image found already in the filesystem. + + I can be found in MainViewer windows. + """ + + ## Useful attributes: + ## img = a CacheableImage for the image to display + ## text = the text (directory name) associated with the cover + ## path = the full pathname to this directory + ## leaf = the basename of the cover image, or None + + ## An error image, for use when there isn't a usable image. + NULLIMG = NullImage(THUMBSZ, '?') + + def __init__(me, dir, path, leaf): + """ + Initialize a new image. + + DIR is the directory containing the image, relative to the root of the + search; it's presented to the user to help them distinguish the similar + icons (or identical ones, if multiple directories lack cover images). + PATH is the full pathname to the directory, and is used when installing a + replacement cover. LEAF is the basename of the cover image in that + directory, or None if there currently isn't an image there. + """ + me.text = dir + me.path = path + me.leaf = leaf + if me.leaf: + me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf)) + else: + me.img = me.NULLIMG + me.covimg = None + +class BaseCoverViewer (GTK.ScrolledWindow): + """ + I represent a viewer for a collection of cover images, shown as thumbnails. + + The image objects should have the following attributes. + + img The actual image, as a CacheableImage. + + text Some text to associate with the image, as a Python + string. + + I will store an `iterator' in the image object's `it' attribute, which will + let others identify it later to the underlying Gtk machinery. + + Subclasses should implement two methods: + + activate(COVER) The COVER has been activated by the user (e.g., + double-clicked). + + select(COVER) The COVER has been selected by the user (e.g., + clicked) in a temporary manner; if COVER is None, + then a previously selected cover has become + unselected. + """ + + ## Useful attributes: + ## iv = an IconView widget used to show the thumbnails + ## list = a ListStore object used to keep track of the cover images; each + ## item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF + ## and TEXT are extracted from the cover object in the obvious way + + def __init__(me): + """Initialize a BaseCoverViewer.""" + + ## Initialize myself, as a scrollable thingy. + GTK.ScrolledWindow.__init__(me) + me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC) + + ## Set up an IconView to actually show the cover thumbnails. + me.iv = GTK.IconView() + me.iv.connect('item-activated', + lambda iv, p: me.activate(me._frompath(p))) + me.iv.connect('selection-changed', me._select) + me.iv.set_pixbuf_column(0) + me.iv.set_text_column(1) + me.iv.set_orientation(GTK.ORIENTATION_VERTICAL) + me.iv.set_item_width(THUMBSZ + 32) + fix_background(me.iv) + me.add(me.iv) + + ## Clear the list ready for cover images to be added. + me.reset() + + def reset(me): + """ + Clear the viewer of cover images. + + This does /not/ clear the `it' attribute of previously attached cover + object. + """ + me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT) + me.iv.set_model(me.list) + me.iv.unselect_all() + + def addcover(me, cov): + """ + Add the cover image COV to the viewer. + + `COV.it' is filled in with the object's iterator. + """ + cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov]) + + def _frompath(me, path): + """Convert a PATH to a cover image, and return it.""" + return me.list[path][2] + + def _select(me, iv): + """Handle a selection event, calling the subclass `select' method.""" + sel = me.iv.get_selected_items() + if len(sel) != 1: me.select(None) + else: me.select(me._frompath(sel[0])) + +class SearchViewer (BaseCoverViewer): + """ + I'm a BaseCoverViewer subclass for showing search results. + + I'll be found within a CoverChooser window, showing the thumbnails + resulting from a search. I need to keep track of my parent CoverChooser, + so that I can tell it to show a large version of a selected image. + """ + + ## Useful attributes: + ## _chooser = the containing CoverChooser object + + def __init__(me, chooser): + """ + Initialize the SearchViewer, associating it with its parent CoverChooser. + """ + BaseCoverViewer.__init__(me) + me._chooser = chooser + + def switch(me, current): + """ + Switch to a different album chosen in the CoverChooser. + + CURRENT is either None, in which case the viewer is simply cleared, or + the current cover image (some CacheableImage object) for the newly chosen + album, which should be shown as the initial selection. + """ + me.reset() + if current: + cov = SearchCover(current, marker = '*') + me.addcover(cov) + me.iv.select_path(me.list.get_path(cov.it)) + + def activate(me, cov): + """Inform the CoverChooser that the user activated COV.""" + me._chooser.activated(cov) + + def select(me, cov): + """Inform the CoverChooser that the user selected COV.""" + me._chooser.selected(cov) + class SearchFail (Exception): """An exception found while trying to search for images.""" pass @@ -678,7 +739,7 @@ class CoverChooser (object): fix_background(evb) scr.add_with_viewport(evb) panes.pack2(scr, True, True) - panes.set_position(THUMBSZ + 64) + panes.set_position(THUMBSZ + 48) box.pack_start(panes, True, True, 0) me.win.add(box) @@ -745,7 +806,7 @@ class CoverChooser (object): ## Maybe that didn't work. except SearchFail, e: - print e.args[0] + whinge('search failed: %s' % e.args[0]) def makequery(me, path): """Construct a default search query string for the chosen album.""" @@ -781,41 +842,6 @@ class CoverChooser (object): ## There's currently no chooser. CHOOSER = None -class ViewCover (object): - """ - I represent an album cover image found already in the filesystem. - - I can be found in MainViewer windows. - """ - - ## Useful attributes: - ## text = the text (directory name) associated with the cover - ## path = the full pathname to this directory - ## leaf = the basename of the cover image, or None - - ## An error image, for use when there isn't a usable image. - NULLIMG = NullImage(THUMBSZ, '?') - - def __init__(me, dir, path, leaf): - """ - Initialize a new image. - - DIR is the directory containing the image, relative to the root of the - search; it's presented to the user to help them distinguish the similar - icons (or identical ones, if multiple directories lack cover images). - PATH is the full pathname to the directory, and is used when installing a - replacement cover. LEAF is the basename of the cover image in that - directory, or None if there currently isn't an image there. - """ - me.text = dir - me.path = path - me.leaf = leaf - if me.leaf: - me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf)) - else: - me.img = me.NULLIMG - me.covimg = None - class MainViewer (BaseCoverViewer): """ I'm a top-level cover viewer, showing thumbnails for the albums I can find @@ -880,8 +906,7 @@ class MainViewer (BaseCoverViewer): Allow the user to choose a replacement cover. """ global CHOOSER - if not CHOOSER: - CHOOSER = CoverChooser() + if not CHOOSER: CHOOSER = CoverChooser() CHOOSER.update(me, cov, cov.text, cov.covimg) def replace(me, cov, img): @@ -893,8 +918,7 @@ class MainViewer (BaseCoverViewer): leaf = 'cover.%s' % img.ext out = OS.path.join(cov.path, leaf) new = out + '.new' - with open(new, 'wb') as f: - f.write(img._data) + with open(new, 'wb') as f: f.write(img.raw) OS.rename(new, out) if cov.leaf not in [None, leaf]: OS.unlink(OS.path.join(cov.path, cov.leaf)) @@ -908,6 +932,9 @@ class MainViewer (BaseCoverViewer): if __name__ == '__main__': + ## Set the program name. + PROG = OS.path.basename(SYS.argv[0]) + ## Try to find an API key for searching. CONFROOT = ENV.get('XDG_CONFIG_HOME', None) if CONFROOT is None: @@ -923,8 +950,18 @@ if __name__ == '__main__': apikey = f.readline().strip() CoverChooser.set_apikey(apikey) + ## Parse the command line. + op = OP.OptionParser(prog = PROG, version = VERSION, + usage = '%prog ROOT', + description = """\ +Browse the cover-art images in the directory tree headed by ROOT, and allow +the images to be replaced by others found by searching the Internet. +""") + opts, args = op.parse_args(SYS.argv[1:]) + if len(args) != 1: op.error('wrong number of arguments') + ROOT, = args + ## Set things up. - ROOT = SYS.argv[1] LOOP = G.MainLoop() BLACK = GDK.Color(0, 0, 0) WHITE = GDK.Color(65535, 65535, 65535) @@ -933,7 +970,7 @@ if __name__ == '__main__': WIN = GTK.Window() VIEW = MainViewer(ROOT) WIN.add(VIEW) - WIN.set_default_size(814, 660) + WIN.set_default_size(6*(THUMBSZ + 48), 660) WIN.set_title('coverart') WIN.connect('destroy', lambda _: LOOP.quit()) WIN.show_all()