From 24c66538cbab56b2b1fb9356bc652cb35244cc74 Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Sat, 8 Mar 2014 00:22:34 +0100 Subject: [PATCH] Add base implementation of attachments --- beets/attachements.py | 282 +++++++++++++++++++++++++++++++++++++++ beetsplug/attachments.py | 61 +++++++++ beetsplug/coverart.py | 118 ++++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 beets/attachements.py create mode 100644 beetsplug/attachments.py create mode 100644 beetsplug/coverart.py diff --git a/beets/attachements.py b/beets/attachements.py new file mode 100644 index 000000000..6ae16a1cf --- /dev/null +++ b/beets/attachements.py @@ -0,0 +1,282 @@ +# This file is part of beets. +# Copyright 2014, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +import re +import urlparse +import json +import os.path +from argparse import ArgumentParser + +from beets import library +from beets import dbcore + + +class Attachment(dbcore.db.Model): + + _fields = { + 'url': dbcore.types.String(), + 'ref': dbcore.types.Integer(), + 'ref_type': dbcore.types.String(), + 'type': dbcore.types.String(), + } + _table = 'attachments' + _flex_table = 'attachment_metadata' + # FIXME do we need a _search_fields property? + + def __init__(self, db=None, libdir=None, **values): + super(Attachment, self).__init__(db, **values) + self.libdir = libdir + + @property + def location(self): + """Return an url string with the ``file`` scheme omitted and + resolved to an absolute path. + """ + url = self.resolve() + if url.scheme == 'file': + return url.path + else: + return urlparse.urlunparse(url) + + @property + def entity(self): + """Return the ``Item`` or ``Album`` we are attached to. + """ + if ref is None or ref_type is None: + return None + # TODO implement correct query + if ref_type == 'item': + self._db.items(id=self.ref) + elif ref_type == 'album': + self._db.albums(id=self.ref) + + @entity.setter + def entity(self, entity): + """Set the ``ref`` and ``ref_type`` properties so that + ``self.entity() == entity``. + """ + if isinstance(entity, library.Item): + self.ref_type = 'item' + elif isinstance(entity, library.Album): + self.ref_type = 'album' + else: + assert false + + assert entity.id + self.ref = entity.id + + def move(self, destination=None, copy=False, force=False): + """Moves the attachment from its original ``url`` to its + destination URL. + + If `destination` is given it must be a path. If the path is relative, it is + treated relative to the ``libdir``. + + If the destination is `None` the method retrieves a template + from a `type -> template` map using the attachements type. It + then evaluates the template in the context of the attachment and + its associated entity. + + The method tries to retrieve the resource from ``self.url`` and + saves it to ``destination``. If the destination already exists + and ``force`` is ``False`` it raises an error. Otherwise the + destination is overwritten and ``self.url`` is set to + ``destination``. + + If ``copy`` is ``False`` and the original ``url`` pointed to a + local file it removes that file. + """ + # TODO implement + raise NotImplementedError + + def resolve(self): + """Return a url structure for the ``url`` property. + + This is similar to ``urlparse(attachment.url)``. If ``url`` has + no schema it defaults to ``file``. If the schema is ``file`` and + the path is relative it is resolved relative to the ``libdir``. + + The return value is an instance of ``urlparse.ParseResult``. + """ + (scheme, netloc, path, params, query, fragment) = \ + urlparse.urlparse(self.url, scheme='file') + if not os.path.isabs(path): + assert os.path.isabs(beetsdir) + path = os.path.normpath(os.path.join(beetsdir, path)) + return urlparse.ParseResult(scheme, netloc, path, params, query, fragment) + + def _validate(self): + # TODO integrate this into the `store()` method. + assert self.entity + assert re.match(r'^[a-zA-Z][-\w]*', self.type) + urlparse.urlparse(self.url) + + +class AttachmentFactory(object): + """Factory that creates or finds attachments in the database. + + Using this factory is the prefered way of creating attachments as it + allows plugins to provide additional data. + """ + + def __init__(self, db=None, libdir=None): + self._db = db + self._libdir = libdir + self._discoverers = [] + self._collectors = [] + + def find(self, attachment_query=None, entity_query=None): + """See Library documentation""" + self._db.attachments(attachment_query, entity_query) + + def discover(self, url, entity=None): + """Yield a list of attachments for types registered with that url. + + The method uses the registered type discoverer functions to get + a list of types for ``path``. For each type it yields an + attachment created with `create_with_type`. + + The scheme of the url defaults to ``file``. + """ + url = urlparse.urlparse(url, scheme='file') + if url.scheme != 'file': + # TODO Discoverers are only required to handle paths. In the + # future we might want to add the possibility to register + # discoverers for general URLs. + return + + for type in self._discover_types(url.path) + yield self.create(url.path, type, entity) + + def create(self, path, type, entity=None): + """Return a populated ``Attachment`` instance. + + The ``url`` and ``type`` properties are set corresponding to the + arguments. If ``entity`` is not ``None`` it attaches the entity + to the attachment. + + The method also populates the ``meta`` property with data + retrieved from all registered collectors. + """ + # TODO extend this to handle general urls + attachment = Attachment(path, db=self._db, beetsdir=self._libdir) + if entity is not None: + attachement.attach_to(entity) + for key, value in self._collect_meta(type, path).items(): + attachment[key] = value + return attachment + + def register_discoverer(self, discover): + """`discover` is a callable accepting the path of an attachment + as its only argument. If it was able to determine the type it + returns its name as a string. Otherwise it must return ``None`` + """ + self._discoverers.append(discover) + + def register_collector(self, collector): + self._collectors.append(collector) + + def register_plugin(self, plugin): + self.register_discoverer(plugin.attachment_discoverer): + self.register_collector(plugin.attachment_collector): + + def _discover_types(self, url): + types = [] + for discover in self._discoverers: + try: + type = discover(url) + if type: + types.append(type) + except: + pass + return types + + def _collect_meta(self, type, url): + all_meta = {} + for collector in self._collectors: + try: + meta = discover(type,url) + if isinstance(dict, meta): + all_meta.update(meta) + except: + pass + return all_meta + + +class AttachmentCommand(ArgumentParser): + """Abstract class to be used by plugins that deal with attachments. + """ + + name = None + """Invoke the command if this string is given as the subcommand. + + If ``name`` is "myplugin" the command is run when using ``beet + myplugin`` on the command line. + """ + + aliases = [] + """Alternative names to invoke this command by. + """ + + factory = None + """Instance of ``AtachmentFactory``. + + This property will be set by beets before running the command. + """ + + def __init__(self): + super(AttachmentCommand, self).__init__() + + def run(self, arguments): + """Execute the command. + + :param arguments: A namespace object as returned by ``parse_args()``. + """ + raise NotImplementedError + + def add_arguments(self, arguments): + """Adds custom arguments with ``ArgumentParser.add_argument()``. + + The method is called by beets prior to calling ``parse_args``. + """ + pass + +class LibraryMixin(object): + """Extends ``beets.library.Library`` with attachment queries. + """ + + def attachments(self, attachment_query=None, entity_query=None): + """Yield all attachments in the library matching + ``attachment_query`` and their associated items matching + ``entity_query``. + + Calling `attachments(None, entity_query)` is equivalent to:: + + library.albums(entity_query).attachments() + \ + library.items(entity_query).attachments() + """ + # TODO implement + raise NotImplementedError + + +class LibModelMixin(object): + """Extends ``beets.library.LibModel`` with attachment queries. + """ + + def attachments(self): + """Return a list of attachements associated to this model. + """ + # TODO implement + raise NotImplementedError diff --git a/beetsplug/attachments.py b/beetsplug/attachments.py new file mode 100644 index 000000000..a91304020 --- /dev/null +++ b/beetsplug/attachments.py @@ -0,0 +1,61 @@ +# This file is part of beets. +# Copyright 2014, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +import beets.ui +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand + + +class AttachmentPlugin(BeetsPlugin): + """Adds ``attach`` command and creates attachments after import. + """ + def __init__(self): + super(AttachmentPlugin).__init__() + self.register_listener('import_task_apply', self.import_attachments) + + def commands(self): + return [AttachCommand()] + + def import_attachments(self, task, session): + """Looks for files in imported folder creates attachments for them. + """ + # TODO implement + raise NotImplementedError + + +class AttachCommand(Subcommand): + + def __init__(self): + # TODO add usage + super(AttachCommand, self).__init__() + parser.add_option('-l', '--local', dest='local'. + action='store_true', default=False + help='ATTACHMENT path is relative to album directory') + + def func(self, lib, opts, args): + """Create an attachment from file for all albums matched by a query. + """ + # TODO add verbose logging + path = args.pop(0) + query = ui.decargs(args) + for album in lib.albums(ui.decargs(args)): + if opts.local: + path = os.path.join(album.item_dir()) + else: + path = os.path.abspath(path) + for attachment in factory.discover(path, album): + attachment.move() + attachemnt.store() + diff --git a/beetsplug/coverart.py b/beetsplug/coverart.py new file mode 100644 index 000000000..290d41346 --- /dev/null +++ b/beetsplug/coverart.py @@ -0,0 +1,118 @@ +# This file is part of beets. +# Copyright 2014, Thomas Scholtes. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + + +COVERTYPE = enum([ + 'front', + 'back', + # TODO extend. The ID3v2 types [1] might be a good starting point, + # but I find them a bit convoluted. MusicBrainz [2] is also a good + # source. + # + # [1]: http://en.wikipedia.org/wiki/ID3#ID3v2_Embedded_Image_Extension + # [2]: http://musicbrainz.org/doc/Cover_Art/Types +], name='COVERTYPE') +"""Enumeration of known types of cover art. + +The string representation is stored in the 'covertype' metadata field of +an attachment. +""" + + +class CoverArtPlugin(BeetsPlugin): + """Registers ``coverart`` attachment type and command. + """ + + def attachment_commands(self): + return [CoverArtCommand] + + def attachment_discover(self, path): + # FIXME mock code, simpler to check file extension + mime_type = get_mime_type_from_file(path) + if mime_type.startswith('image/'): + return 'coverart' + + def attachment_collect(self, type, path): + if type != 'coverart': + return + + # FIXME mock code + metadata = {} + if basename(path).startwith('front'): + metadata['covertype'] = 'front' + elif basenmae(path).startswith('back'): + metadata['covertype'] = 'back' + + width, height = get_image_resolution(path) + metadata['width'] = width + metadata['height'] = width + + metadata['mime'] = get_mime_type_from_file(path) + return metadata + + +class CoverArtCommand(AttachmentCommand): + name = 'coverart' + + # This is set by beets when instantiating the command + factory = None + + def add_arguments(self): + # TODO add options and arguments through the ArgumentParser + # interface. + raise NotImplementedError + + def run(self, argv, options): + """Dispatch invocation to ``attach()`` or ``list()``. + """ + album_query = query_from_args(argv) + if options.attach: + # -a option creates attachments + self.attach(album_query, path=options.attach, + covertype=options.type, local=options.local) + else: + # list covers of a particular type + self.list(album_query, covertype=options.type) + + def attach(self, query, path, covertype=None, local=False): + """Attaches ``path`` as coverart to all albums matching ``query``. + + :param covertype: Set the covertype of the attachment. + :param local: If true, path is relative to each album’s directory. + """ + # TODO implement `embed` option to write images to tags. Since + # the `MediaFile` class doesn't support multiple images at the + # moment we have to implement it there first. + for album in albums_from_query(query): + if local: + localpath = join(album.path, path) + else: + localpath = path + attachment = self.factory.create_from_type(localpath, + entity=album, type='coverart') + if covertype: + attachment.meta['covertype'] = covertype + attachment.move(cover_art_destination(attachment)) + attachment.store() + + def list(query, covertype=None): + """Print list of coverart attached to albums matching ``query``. + + :param covertype: Restrict the list to coverart of this type. + """ + for attachment in self.factory.find(TypeQuery('coverart'), query) + if covertype is None: + print_attachment(attachment) + elif attachment.meta['covertype'] == covertype: + print_attachment(attachment)