Add base implementation of attachments

This commit is contained in:
Thomas Scholtes 2014-03-08 00:22:34 +01:00
parent 810841ba5a
commit 24c66538cb
3 changed files with 461 additions and 0 deletions

282
beets/attachements.py Normal file
View file

@ -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

61
beetsplug/attachments.py Normal file
View file

@ -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()

118
beetsplug/coverart.py Normal file
View file

@ -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 albums 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)