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
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.
"""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.
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.
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
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.
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
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)
## 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."""
## 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
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):
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))
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:
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)
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()