2 ### -*- mode: python; coding: utf-8 -*-
4 ### Manage and update cover art for a music collection
6 ### (c) 2014 Mark Wooding
9 ###----- Licensing notice ---------------------------------------------------
11 ### This file is part of the `autoys' audio tools collection.
13 ### `autoys' is free software; you can redistribute it and/or modify
14 ### it under the terms of the GNU General Public License as published by
15 ### the Free Software Foundation; either version 2 of the License, or
16 ### (at your option) any later version.
18 ### `autoys' is distributed in the hope that it will be useful,
19 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
20 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 ### GNU General Public License for more details.
23 ### You should have received a copy of the GNU General Public License
24 ### along with `autoys'; if not, write to the Free Software Foundation,
25 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
27 ###--------------------------------------------------------------------------
28 ### External dependencies.
31 from __future__ import with_statement
33 ## Standard Python libraries.
34 from cStringIO import StringIO
38 import os as OS; ENV = OS.environ
49 ###--------------------------------------------------------------------------
50 ### Build-time configuration.
54 ###--------------------------------------------------------------------------
55 ### Theoretically tweakable parameters.
59 ###--------------------------------------------------------------------------
63 """Tell the user of some unfortunate circumstance described by MSG."""
64 dlg = GTK.MessageDialog(None, 0, GTK.MESSAGE_ERROR, GTK.BUTTONS_NONE, msg)
65 dlg.set_title('%s Error' % PROG)
66 dlg.add_button('_Bummer', 0)
71 """Fetch the resource named by URL, returning its content as a string."""
73 with U.urlopen(url) as u:
80 def fix_background(w):
81 """Hack the style of the window W so that it shows white-on-black."""
82 style = w.get_style().copy()
83 style.base[GTK.STATE_NORMAL] = BLACK
84 style.bg[GTK.STATE_NORMAL] = BLACK
85 style.text[GTK.STATE_NORMAL] = WHITE
88 ###--------------------------------------------------------------------------
91 class ImageCache (object):
93 I maintain a cache of CacheableImage objects.
95 I evict images which haven't been used recently if the total size of the
96 image data I'm holding is larger than the threshold in my `THRESH'
100 ## Useful attributes:
101 ## _total = total size of image data in the cache, in bytes
102 ## _first, _last = head and tail of a linked list of CacheableImage objects
104 ## We make use of the link attributes _first and _next of CacheableImage
107 ## Notionally configurable parameters.
108 THRESH = 128*1024*1024
111 """Initialize an ImageCache object. The cache is initially empty."""
113 me._first = me._last = None
116 """Add IMG to the cache, possibly evicting old images."""
118 ## Update the cache size, and maybe evict images if we're over budget.
119 me._total += img.size
120 while me._first and me._total > me.THRESH: me._first.evict()
122 ## Link the new image into the list.
125 if me._last: me._last._next = img
126 else: me._first = img
131 Remove IMG from the cache.
133 This is usually a response to an eviction notice received by the image.
137 if img._prev: img._prev._next = img._next
138 else: me._first = img._next
139 if img._next: img._next._prev = img._prev
140 else: img._last = img._prev
142 ## Update the cache usage.
143 me._total -= img.size
145 ## We only need one cache, in practice, and here it is.
148 class CacheableImage (object):
150 I represent an image which can be retained in the ImageCache.
152 I'm an abstract class. Subclasses are expected to implement a method
153 `_acquire' which fetches the image's data from wherever it comes from and
154 returns it, as a Gdk Pixbuf object.
156 Cacheable images may also retain a thumbnail which is retained
157 until explicitly discarded.
160 ## Useful attributes:
161 ## _pixbuf = the pixbuf of the acquired image, or None
162 ## _thumb = the pixbuf of the thumbnail, or None
163 ## _next, _prev = forward and backward links in the ImageCache list
166 """Initialize the image."""
169 me._prev = me._next = None
174 Return the underlying image data, as a Gdk Pixbuf object.
176 The image data is acquired if necessary, and cached for later reuse.
179 me._pixbuf = me._acquire()
180 me.size = me._pixbuf.get_pixels_array().nbytes
185 """Discard the image data. This is usually a request by the cache."""
191 Discard the image data and thumbnail.
193 They will be regenerated again on demand.
200 """Return a Thumbnail object for the image."""
201 if not me._thumb: me._thumb = Thumbnail(me)
204 class Thumbnail (object):
206 I represent a reduced-size view of an image, suitable for showing in a big
209 My `pixbuf' attribute stores a Gdk Pixbuf of the thumbnail image.
212 def __init__(me, img):
214 Initialize the Thumbnail.
216 The thumbnail will contain a reduced-size view of the CacheableImage IMG.
219 wd, ht = pix.get_width(), pix.get_height()
224 twd, tht = [(x*THUMBSZ + m//2)//m for x in [wd, ht]]
225 me.pixbuf = pix.scale_simple(twd, tht, GDK.INTERP_HYPER)
227 ###--------------------------------------------------------------------------
228 ### Various kinds of image.
230 class NullImage (CacheableImage):
232 I represent a placeholder image.
234 I'm usually used because the proper image you actually wanted is
235 unavailable for some reason.
238 ## Useful attributes:
239 ## _size = size of the (square) image, in pixels
240 ## _text = the text to display in the image.
242 def __init__(me, size, text):
244 Construct a new NullImage.
246 The image will be a square, SIZE pixels along each side, and will display
249 CacheableImage.__init__(me)
254 """Render the placeholder image."""
256 ## Set up a drawing surface and context.
257 surf = XR.ImageSurface(XR.FORMAT_ARGB32, me._size, me._size)
258 xr = XR.Context(surf)
260 ## Choose an appropriate background colour and fill it in.
261 xr.set_source_rgb(0.3, 0.3, 0.3)
264 ## Choose an appropriately nondescript font and foreground colour.
265 xr.select_font_face('sans-serif',
266 XR.FONT_SLANT_NORMAL, XR.FONT_WEIGHT_BOLD)
267 xr.set_source_rgb(0.8, 0.8, 0.8)
269 ## Figure out how big the text is going to be, and therefore how to scale
270 ## it so that it fills the available space pleasingly.
271 xb, yb, wd, ht, xa, ya = xr.text_extents(me._text)
273 z = me._size/float(m) * 2.0/3.0
276 ## Render the text in the middle of the image.
277 xr.move_to(3.0*m/4.0 - wd/2.0 - xb, 3.0*m/4.0 - ht/2.0 - yb)
278 xr.show_text(me._text)
280 ## We're done drawing. Collect the image and capture it as a Pixbuf so
281 ## that everyone else can use it.
283 pix = GDK.pixbuf_new_from_data(surf.get_data(),
284 GDK.COLORSPACE_RGB, True, 8,
285 me._size, me._size, surf.get_stride())
288 class FileImage (CacheableImage):
290 I represent an image fetched from a (local disk) file.
293 ## Useful attributes:
294 ## _file = filename to read image data from
296 def __init__(me, file):
297 """Initialize a FileImage which reads its image data from FILE."""
298 CacheableImage.__init__(me)
302 """Acquire the image data."""
303 return GDK.pixbuf_new_from_file(me._file)
305 class RemoteImage (CacheableImage):
307 I represent an image whose data can be fetched over the network.
310 ## Useful attributes:
311 ## _url = the URL to use to fetch the image
312 ## _ref = the referrer to set when fetching the image
313 ## _data = the image content from the server
314 ## _format = a PixbufFormat object describing the image format
316 ## On first acquire, we fetch the image data from the server the hard way,
317 ## but we retain this indefinitely, even if the pixbuf is evicted as a
318 ## result of the cache filling. The idea is that JPEG files (say) are
319 ## rather smaller than uncompressed pixbufs, and it's better to use up
320 ## local memory storing a JPEG than to have to fetch the whole thing over
321 ## the network again.
323 ## A dummy image used in place of the real thing if we encounter an error.
324 ERRIMG = NullImage(256, '!')
326 def __init__(me, url, ref = None):
328 Initialize the RemoteImage object.
330 Image data will be fetched from URL; if a REF is provided (and is not
331 None), then it will be presented as the `referer' when fetching the
334 CacheableImage.__init__(me)
341 Fetch the image data from the server, if necessary.
343 On success, the raw image data is stored in the `_data' attribute. Many
344 things can go wrong, though.
347 ## If we already have the image data, then use what we've got already.
350 ## Fetch the image data from the server.
352 rq = U2.Request(me._url)
353 if me._ref: rq.add_header('Referer', me._ref)
356 stuff = rs.read(16384)
359 me._data = d.getvalue()
361 ## Try to figure out what kind of image this is. With a bit of luck, we
362 ## can figure this out by spooning a bit of image data into a
364 ld = GDK.PixbufLoader()
369 if o >= n: raise ValueError, 'not going to work'
370 l = min(n, o + 16384)
371 ld.write(me._data[o:l])
377 ## If this is a GIF then bail now. They look terrible.
378 if 'image/gif' in f['mime_types']:
380 raise ValueError, 'boycotting GIF image'
383 ## Tidy up: we don't want the loader any more.
385 except G.GError: pass
389 """The raw image data fetched from the remote source."""
395 Return a decoded image from the image data.
397 If this isn't going to work, return a dummy image.
400 ld = GDK.PixbufLoader()
401 try: ld.write(me.raw)
403 return ld.get_pixbuf()
405 SYS.stderr.write("%s: failed to decode image from `%s': %s'" %
406 (PROG, me._url, str(e)))
407 return me.ERRIMG.pixbuf
411 """Return a file extension for the image, in case we want to save it."""
412 ## If we can use `.jpg' then do that; otherwise, take whatever Gdk-Pixbuf
414 exts = me._format['extensions']
416 if i in exts: return i
419 class SearchImage (RemoteImage):
421 I represent an image found by searching the web.
424 ## Useful attributes:
425 ## _tburl = the thumbnail url
427 def __init__(me, url, ref, tburl):
429 Initialize a SearchImage object.
431 The URL and REF arguments are as for the base RemoteImage object; TBURL
432 is the location of a thumbnail image which (so the thinking goes) will be
433 smaller and hence faster to fetch than the main one.
435 RemoteImage.__init__(me, url, ref)
440 """Fetch a thumbnail separately, using the thumbnail URL provided."""
441 if not me._thumb: me._thumb = Thumbnail(RemoteImage(me._tburl))
444 ###--------------------------------------------------------------------------
447 class SearchCover (object):
449 A base class for icons in the SearchViewer window.
452 ## Useful attributes:
453 ## img = a CacheableImage for the image to display
454 ## text = a string containing the text to show
456 def __init__(me, img, width = None, height = None, marker = ''):
458 Initialize a SearchCover object.
460 The IMG is a CacheableImage of some kind. If the WIDTH and HEIGHT are
461 omitted, the image data will be acquired and both dimensions calculated
462 (since, after all, if we have to go to the bother of fetching the image,
463 we may as well get an accurate size).
466 if width is None or height is None:
468 width = pix.get_width()
469 height = pix.get_height()
470 me.text = '%d×%d%s' % (width, height, marker)
472 class SearchResult (SearchCover):
474 I represent information about an image found while searching the web.
476 I'm responsible for parsing information about individual search hits from
480 """Initialize a SearchResult, given a decoded JSON fragment R."""
485 ref = i['contextLink']
486 tburl = i['thumbnailLink']
487 SearchCover.__init__(me, SearchImage(url, ref, tburl),
488 width = w, height = h)
490 class ViewCover (object):
492 I represent an album cover image found already in the filesystem.
494 I can be found in MainViewer windows.
497 ## Useful attributes:
498 ## img = a CacheableImage for the image to display
499 ## text = the text (directory name) associated with the cover
500 ## path = the full pathname to this directory
501 ## leaf = the basename of the cover image, or None
503 ## An error image, for use when there isn't a usable image.
504 NULLIMG = NullImage(THUMBSZ, '?')
506 def __init__(me, dir, path, leaf):
508 Initialize a new image.
510 DIR is the directory containing the image, relative to the root of the
511 search; it's presented to the user to help them distinguish the similar
512 icons (or identical ones, if multiple directories lack cover images).
513 PATH is the full pathname to the directory, and is used when installing a
514 replacement cover. LEAF is the basename of the cover image in that
515 directory, or None if there currently isn't an image there.
521 me.img = me.covimg = FileImage(OS.path.join(me.path, me.leaf))
526 class BaseCoverViewer (GTK.ScrolledWindow):
528 I represent a viewer for a collection of cover images, shown as thumbnails.
530 The image objects should have the following attributes.
532 img The actual image, as a CacheableImage.
534 text Some text to associate with the image, as a Python
537 I will store an `iterator' in the image object's `it' attribute, which will
538 let others identify it later to the underlying Gtk machinery.
540 Subclasses should implement two methods:
542 activate(COVER) The COVER has been activated by the user (e.g.,
545 select(COVER) The COVER has been selected by the user (e.g.,
546 clicked) in a temporary manner; if COVER is None,
547 then a previously selected cover has become
551 ## Useful attributes:
552 ## iv = an IconView widget used to show the thumbnails
553 ## list = a ListStore object used to keep track of the cover images; each
554 ## item is a list of the form [PIXBUF, TEXT, COVER], where the PIXBUF
555 ## and TEXT are extracted from the cover object in the obvious way
558 """Initialize a BaseCoverViewer."""
560 ## Initialize myself, as a scrollable thingy.
561 GTK.ScrolledWindow.__init__(me)
562 me.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
564 ## Set up an IconView to actually show the cover thumbnails.
565 me.iv = GTK.IconView()
566 me.iv.connect('item-activated',
567 lambda iv, p: me.activate(me._frompath(p)))
568 me.iv.connect('selection-changed', me._select)
569 me.iv.set_pixbuf_column(0)
570 me.iv.set_text_column(1)
571 me.iv.set_orientation(GTK.ORIENTATION_VERTICAL)
572 me.iv.set_item_width(THUMBSZ + 32)
573 fix_background(me.iv)
576 ## Clear the list ready for cover images to be added.
581 Clear the viewer of cover images.
583 This does /not/ clear the `it' attribute of previously attached cover
586 me.list = GTK.ListStore(GDK.Pixbuf, G.TYPE_STRING, G.TYPE_PYOBJECT)
587 me.iv.set_model(me.list)
590 def addcover(me, cov):
592 Add the cover image COV to the viewer.
594 `COV.it' is filled in with the object's iterator.
596 cov.it = me.list.append([cov.img.thumbnail.pixbuf, cov.text, cov])
598 def _frompath(me, path):
599 """Convert a PATH to a cover image, and return it."""
600 return me.list[path][2]
603 """Handle a selection event, calling the subclass `select' method."""
604 sel = me.iv.get_selected_items()
605 if len(sel) != 1: me.select(None)
606 else: me.select(me._frompath(sel[0]))
608 class SearchViewer (BaseCoverViewer):
610 I'm a BaseCoverViewer subclass for showing search results.
612 I'll be found within a CoverChooser window, showing the thumbnails
613 resulting from a search. I need to keep track of my parent CoverChooser,
614 so that I can tell it to show a large version of a selected image.
617 ## Useful attributes:
618 ## _chooser = the containing CoverChooser object
620 def __init__(me, chooser):
622 Initialize the SearchViewer, associating it with its parent CoverChooser.
624 BaseCoverViewer.__init__(me)
625 me._chooser = chooser
627 def switch(me, current):
629 Switch to a different album chosen in the CoverChooser.
631 CURRENT is either None, in which case the viewer is simply cleared, or
632 the current cover image (some CacheableImage object) for the newly chosen
633 album, which should be shown as the initial selection.
637 cov = SearchCover(current, marker = '*')
639 me.iv.select_path(me.list.get_path(cov.it))
641 def activate(me, cov):
642 """Inform the CoverChooser that the user activated COV."""
643 me._chooser.activated(cov)
646 """Inform the CoverChooser that the user selected COV."""
647 me._chooser.selected(cov)
649 class SearchFail (Exception):
650 """An exception found while trying to search for images."""
653 class CoverChooser (object):
655 I represent a window for choosing one of a number of cover art images.
657 I allow the user to choose one of a number of alternative images for an
658 album, and to search the Internet for more options.
660 I work on behalf of some other client object; I am responsible for
661 collecting the user's ultimate choice, but that responsibility ends when I
662 tell my client which image the user picked.
664 During my lifetime, I can be used to choosing images for different purposes
665 (possibly on behalf of different clients), though I can only work on one
666 thing at a time. I switch between jobs when `update' is called; see the
667 documentation of that method for details of the protocol.
669 I try to arrange that there's at most one instance of me, in the variable
673 ## Important attributes:
674 ## query = the entry widget for typing search terms
675 ## sv = the search viewer pane showing thumbnails of search results
676 ## img = the image preview pane showing a selected image at full size
677 ## win = our top-level window
678 ## view = the object which invoked us
679 ## which = the client's `which' parameter
680 ## current = the currently chosen image
682 ## This is a bit grim because search APIs and keys.
684 SEARCHID = '016674315927968770913:8blyelgp3wu'
685 REFERER = 'https://www.distorted.org.uk/~mdw/coverart'
688 def set_apikey(cls, apikey):
689 """Inform the class that it can use APIKEY to authorize its search."""
691 'https://www.googleapis.com/customsearch/v1?' \
692 'key=%s&cx=%s&searchType=image&q=' % (apikey, cls.SEARCHID)
695 """Initialize the window, but don't try to show it yet."""
698 me.win = GTK.Window()
700 ## The window layout is like this.
702 ## -----------------------------------
703 ## | [... ] [Search] |
704 ## |-----------------------------------|
714 ## -----------------------------------
716 ## Main layout box, with search stuff at the top and selection stuff
720 ## First, fill in the search stuff.
722 me.query = GTK.Entry()
723 top.pack_start(me.query, True, True, 2)
724 srch = GTK.Button('_Search')
725 srch.set_flags(GTK.CAN_DEFAULT)
726 srch.connect('clicked', me.search)
727 top.pack_start(srch, False, False, 2)
728 box.pack_start(top, False, False, 2)
730 ## Now the thumbnail viewer and preview below.
732 me.sv = SearchViewer(me)
733 panes.pack1(me.sv, False, True)
734 scr = GTK.ScrolledWindow()
735 scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
740 scr.add_with_viewport(evb)
741 panes.pack2(scr, True, True)
742 panes.set_position(THUMBSZ + 48)
743 box.pack_start(panes, True, True, 0)
746 ## Finally, configure some signal handlers.
747 me.win.connect('destroy', me.destroyed)
748 me.win.set_default_size(800, 550)
750 ## Set the default button. (Gtk makes us wait until the button has a
751 ## top-level window to live in.)
754 def update(me, view, which, dir, current):
756 Update the CoverChooser to choose a different album's cover image.
758 The VIEW is the client object. We will later call its `replace' method,
759 passing it WHICH and the newly chosen image (as a RemoteImage object).
760 WHICH is simply remembered as a context value for `update'. DIR is the
761 path to the album whose cover is to be chosen, used for constructing a
762 suitable search string. CURRENT is some kind of CacheableImage object
763 representing the currently chosen image we may be replacing.
769 me.sv.switch(current)
770 me.query.set_text(me.makequery(dir))
775 Instigate a web search for a replacement image.
777 W is the widget which was frobbed to invoke the search, but it's not very
781 ## Collect the search query.
782 q = me.query.get_text()
784 ## Try to ask a search provider for some likely images.
787 ## We won't get far if we've not been given an API key.
788 if me.SEARCHURL is None: raise SearchFail('no search key')
790 ## Collect the search result.
792 rq = U2.Request(me.SEARCHURL + U.quote_plus(q), None,
793 { 'Referer': me.REFERER })
795 except U2.URLError, e:
796 raise SearchFail(e.reason)
799 ## Clear out all of the images from the last search, leaving only the
800 ## incumbent choice, and then add the new images from the search
802 me.sv.switch(me.current)
803 for r in result['items']:
804 try: me.sv.addcover(SearchResult(r))
805 except (U2.URLError, U2.HTTPError): pass
807 ## Maybe that didn't work.
808 except SearchFail, e:
809 whinge('search failed: %s' % e.args[0])
811 def makequery(me, path):
812 """Construct a default search query string for the chosen album."""
813 bits = path.split(OS.path.sep)
814 return ' '.join(['"%s"' % p for p in bits[-2:]])
816 def selected(me, cov):
818 Show a full-size version of COV in the preview pane.
820 Called by the SearchViewer when a thumbnail is selected.
822 if cov: me.img.set_from_pixbuf(cov.img.pixbuf)
825 def activated(me, cov):
827 Inform our client that COV has been chosen as the replacement image.
829 Called by the SearchViewer when a thumbnail is activated.
831 if isinstance(cov, SearchCover): me.view.replace(me.which, cov.img)
833 def destroyed(me, w):
835 Our widget has been destroyed.
837 We're going away, so clear out the reference to us.
842 ## There's currently no chooser.
845 class MainViewer (BaseCoverViewer):
847 I'm a top-level cover viewer, showing thumbnails for the albums I can find
851 ## Useful attributes:
852 ## root = the root of the directory tree to manage
854 def __init__(me, root):
856 Initialize a viewer for choosing cover art in the tree headed by ROOT.
858 BaseCoverViewer.__init__(me)
864 Walk the directory tree from DIR down, adding icons for cover art I find
868 ## Assume this is a leaf directory for now.
871 ## Figure out the actual pathname we're looking at.
872 b = OS.path.join(me.root, dir)
874 ## The name of any image file we find.
877 ## Work through the items in the directory.
878 for l in sorted(OS.listdir(b)):
880 if OS.path.isdir(OS.path.join(b, l)):
881 ## If this item is a directory, then we're not leaf, but need to
882 ## descend recursively.
884 me.walk(OS.path.join(dir, l))
886 ## If this smells like a cover image then remember it. (If there are
887 ## multiple plausible options, just remember one more or less
889 base, ext = OS.path.splitext(l)
890 if base == 'cover' and ext in ['.jpg', '.png', '.gif']: imgfile = l
892 ## If this is a leaf directory, hopefully representing an album rather
893 ## than an artist or higher-level grouping, then add an icon representing
894 ## it, including any cover image that we found.
896 me.addcover(ViewCover(dir, OS.path.join(me.root, dir), imgfile))
899 """The user selected a cover icon, but we don't care."""
902 def activate(me, cov):
904 A cover icon was activated.
906 Allow the user to choose a replacement cover.
909 if not CHOOSER: CHOOSER = CoverChooser()
910 CHOOSER.update(me, cov, cov.text, cov.covimg)
912 def replace(me, cov, img):
914 Replace the cover COV by the newly chosen image IMG.
916 This is called by the CoverChooser.
918 leaf = 'cover.%s' % img.ext
919 out = OS.path.join(cov.path, leaf)
921 with open(new, 'wb') as f: f.write(img.raw)
923 if cov.leaf not in [None, leaf]:
924 OS.unlink(OS.path.join(cov.path, cov.leaf))
925 ncov = ViewCover(cov.text, cov.path, leaf)
927 me.list[ncov.it] = [ncov.img.thumbnail.pixbuf, ncov.text, ncov]
930 ###--------------------------------------------------------------------------
933 if __name__ == '__main__':
935 ## Set the program name.
936 PROG = OS.path.basename(SYS.argv[0])
938 ## Try to find an API key for searching.
939 CONFROOT = ENV.get('XDG_CONFIG_HOME', None)
941 CONFROOT = OS.path.join(ENV['HOME'], '.config')
942 CONFDIR = OS.path.join(CONFROOT, 'autoys')
944 f = open(OS.path.join(CONFDIR, 'apikey'))
946 if e.errno == E.ENOENT: pass
950 apikey = f.readline().strip()
951 CoverChooser.set_apikey(apikey)
953 ## Parse the command line.
954 op = OP.OptionParser(prog = PROG, version = VERSION,
955 usage = '%prog ROOT',
957 Browse the cover-art images in the directory tree headed by ROOT, and allow
958 the images to be replaced by others found by searching the Internet.
960 opts, args = op.parse_args(SYS.argv[1:])
961 if len(args) != 1: op.error('wrong number of arguments')
966 BLACK = GDK.Color(0, 0, 0)
967 WHITE = GDK.Color(65535, 65535, 65535)
969 ## Make a top-level window showing a MainView and display it.
971 VIEW = MainViewer(ROOT)
973 WIN.set_default_size(6*(THUMBSZ + 48), 660)
974 WIN.set_title('coverart')
975 WIN.connect('destroy', lambda _: LOOP.quit())
978 ## Carry on until there's nothing left to do.
981 ###----- That's all, folks --------------------------------------------------