mirror of
https://github.com/beetbox/beets.git
synced 2026-02-17 04:43:40 +01:00
Integrate and test attachments into library
This commit is contained in:
parent
f000a0af07
commit
67eb8cf9fd
3 changed files with 125 additions and 96 deletions
|
|
@ -15,23 +15,22 @@
|
|||
|
||||
import re
|
||||
import urlparse
|
||||
import json
|
||||
import os.path
|
||||
from argparse import ArgumentParser
|
||||
|
||||
from beets import library
|
||||
from beets import dbcore
|
||||
from beets.dbcore.query import Query, AndQuery
|
||||
|
||||
|
||||
class Attachment(dbcore.db.Model):
|
||||
"""Represents an attachment in the database.
|
||||
|
||||
An attachment has four properties that correspond to fields in the
|
||||
database: ``url``, ``type``, ``ref``, and ``ref_type``. Flexible
|
||||
attributes are accessed as ``attachment[key]``.
|
||||
database: `url`, `type`, `ref`, and `ref_type`. Flexible
|
||||
attributes are accessed as `attachment[key]`.
|
||||
"""
|
||||
|
||||
_fields = {
|
||||
'id': dbcore.types.Id(),
|
||||
'url': dbcore.types.String(),
|
||||
'ref': dbcore.types.Integer(),
|
||||
'ref_type': dbcore.types.String(),
|
||||
|
|
@ -39,30 +38,16 @@ class Attachment(dbcore.db.Model):
|
|||
}
|
||||
_table = 'attachments'
|
||||
_flex_table = 'attachment_metadata'
|
||||
# FIXME do we need a _search_fields property?
|
||||
|
||||
def __init__(self, db=None, libdir=None, **values):
|
||||
def __init__(self, db=None, libdir=None, path=None, **values):
|
||||
if path is not None:
|
||||
values['url'] = path
|
||||
super(Attachment, self).__init__(db, **values)
|
||||
self.libdir = libdir
|
||||
|
||||
@classmethod
|
||||
def _getters(cls):
|
||||
return []
|
||||
|
||||
@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.
|
||||
"""Return the `Item` or `Album` we are attached to.
|
||||
"""
|
||||
if self.ref is None or self.ref_type is None:
|
||||
return None
|
||||
|
|
@ -74,59 +59,70 @@ class Attachment(dbcore.db.Model):
|
|||
|
||||
@entity.setter
|
||||
def entity(self, entity):
|
||||
"""Set the ``ref`` and ``ref_type`` properties so that
|
||||
``self.entity() == entity``.
|
||||
"""Set the `ref` and `ref_type` properties so that
|
||||
`self.entity == entity`.
|
||||
"""
|
||||
if isinstance(entity, library.Item):
|
||||
# FIXME prevents circular dependency
|
||||
from beets.library import Item, Album
|
||||
if isinstance(entity, Item):
|
||||
self.ref_type = 'item'
|
||||
elif isinstance(entity, library.Album):
|
||||
elif isinstance(entity, Album):
|
||||
self.ref_type = 'album'
|
||||
else:
|
||||
raise ValueError('{} must be a Item or Album'.format(entity))
|
||||
|
||||
if not entity.id:
|
||||
raise ValueError('{} must have an id',format(entity))
|
||||
raise ValueError('{} must have an id', format(entity))
|
||||
self.ref = entity.id
|
||||
|
||||
def move(self, destination=None, copy=False, force=False):
|
||||
"""Moves the attachment from its original ``url`` to its
|
||||
"""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 `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``.
|
||||
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.
|
||||
If `copy` is `False` and the original `url` pointed to a local
|
||||
file it removes that file.
|
||||
"""
|
||||
# TODO implement
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
if self.resolve().scheme == 'file':
|
||||
return self.resolve().path
|
||||
|
||||
@path.setter
|
||||
def path(self, value):
|
||||
self.url = value
|
||||
|
||||
def resolve(self):
|
||||
"""Return a url structure for the ``url`` property.
|
||||
"""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``.
|
||||
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``.
|
||||
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)
|
||||
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.
|
||||
|
|
@ -138,17 +134,23 @@ class Attachment(dbcore.db.Model):
|
|||
if key in self._fields.keys():
|
||||
return self[key]
|
||||
else:
|
||||
return object.__getattr__(self, key)
|
||||
object.__getattr__(self, key)
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
# Unlike dbcore.Model we do not provide attribute setters for
|
||||
# flexible fields.
|
||||
if key in self._fields.keys():
|
||||
self[key] = value
|
||||
else:
|
||||
object.__setattr__(self, key, value)
|
||||
|
||||
@classmethod
|
||||
def _getters(cls):
|
||||
return []
|
||||
|
||||
|
||||
class AttachmentFactory(object):
|
||||
"""Factory that creates or finds attachments in the database.
|
||||
"""Create and find attachments in the database.
|
||||
|
||||
Using this factory is the prefered way of creating attachments as it
|
||||
allows plugins to provide additional data.
|
||||
|
|
@ -161,17 +163,29 @@ class AttachmentFactory(object):
|
|||
self._collectors = []
|
||||
|
||||
def find(self, attachment_query=None, entity_query=None):
|
||||
"""See Library documentation"""
|
||||
self._db.attachments(attachment_query, entity_query)
|
||||
"""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()
|
||||
"""
|
||||
# FIXME make this faster with joins
|
||||
queries = [AttachmentEntityQuery(entity_query)]
|
||||
if attachment_query:
|
||||
queries.append(attachment_query)
|
||||
return self._db._fetch(Attachment, AndQuery(queries))
|
||||
|
||||
def discover(self, url, entity=None):
|
||||
"""Yield a list of attachments for types registered with that url.
|
||||
"""Yield a list of attachments for types registered with the 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`.
|
||||
a list of types for `url`. For each type it yields an attachment
|
||||
created with `create_with_type`.
|
||||
|
||||
The scheme of the url defaults to ``file``.
|
||||
The scheme of the url defaults to `file`.
|
||||
"""
|
||||
url = urlparse.urlparse(url, scheme='file')
|
||||
if url.scheme != 'file':
|
||||
|
|
@ -180,19 +194,19 @@ class AttachmentFactory(object):
|
|||
# discoverers for general URLs.
|
||||
return
|
||||
|
||||
for type in self._discover_types(url.path):
|
||||
for type in self._discover_types(url.path):
|
||||
yield self.create(url.path, type, entity)
|
||||
|
||||
def create(self, url, type, entity=None):
|
||||
"""Return a populated ``Attachment`` instance.
|
||||
"""Return a populated `Attachment` instance.
|
||||
|
||||
The ``url``, ``type``, and ``entity`` properties of the
|
||||
attachment are set corresponding to the arguments. The method
|
||||
also populates the ``meta`` property with data retrieved from
|
||||
all registered collectors.
|
||||
The `url`, `type`, and `entity` properties of the attachment are
|
||||
set corresponding to the arguments. The method also set
|
||||
flexible attributes for metadata retrieved from all registered
|
||||
collectors.
|
||||
"""
|
||||
# TODO extend this to handle general urls
|
||||
attachment = Attachment(db=self._db, beetsdir=self._libdir,
|
||||
attachment = Attachment(db=self._db, libdir=self._libdir,
|
||||
url=url, type=type)
|
||||
if entity is not None:
|
||||
attachment.entity = entity
|
||||
|
|
@ -203,34 +217,44 @@ class AttachmentFactory(object):
|
|||
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``
|
||||
returns its name as a string. Otherwise it must return `None`
|
||||
"""
|
||||
self._discoverers.append(discover)
|
||||
|
||||
def register_collector(self, collector):
|
||||
"""`collector` is a callable accepting the type and path of an
|
||||
attachment as its arguments. The `collector` should return a
|
||||
dictionary of metadata it was able to retrieve from the source
|
||||
or `None`.
|
||||
"""
|
||||
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):
|
||||
def _discover_types(self, path):
|
||||
types = []
|
||||
for discover in self._discoverers:
|
||||
try:
|
||||
type = discover(url)
|
||||
type = discover(path)
|
||||
if type:
|
||||
types.append(type)
|
||||
except:
|
||||
# TODO logging?
|
||||
pass
|
||||
return types
|
||||
|
||||
def _collect_meta(self, type, url):
|
||||
def _collect_meta(self, type, path):
|
||||
all_meta = {}
|
||||
for collector in self._collectors:
|
||||
meta = collector(type, url)
|
||||
if isinstance(meta, dict):
|
||||
all_meta.update(meta)
|
||||
try:
|
||||
meta = collector(type, path)
|
||||
if isinstance(meta, dict):
|
||||
all_meta.update(meta)
|
||||
except:
|
||||
# TODO logging?
|
||||
pass
|
||||
return all_meta
|
||||
|
||||
|
||||
|
|
@ -241,8 +265,8 @@ class AttachmentCommand(ArgumentParser):
|
|||
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.
|
||||
If `name` is "myplugin" the command is run when using `beet
|
||||
myplugin` on the command line.
|
||||
"""
|
||||
|
||||
aliases = []
|
||||
|
|
@ -250,7 +274,7 @@ class AttachmentCommand(ArgumentParser):
|
|||
"""
|
||||
|
||||
factory = None
|
||||
"""Instance of ``AtachmentFactory``.
|
||||
"""Instance of `AtachmentFactory`.
|
||||
|
||||
This property will be set by beets before running the command.
|
||||
"""
|
||||
|
|
@ -261,41 +285,34 @@ class AttachmentCommand(ArgumentParser):
|
|||
def run(self, arguments):
|
||||
"""Execute the command.
|
||||
|
||||
:param arguments: A namespace object as returned by ``parse_args()``.
|
||||
:param arguments: A namespace object as returned by `parse_args()`.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def add_arguments(self, arguments):
|
||||
"""Adds custom arguments with ``ArgumentParser.add_argument()``.
|
||||
"""Adds custom arguments with `ArgumentParser.add_argument()`.
|
||||
|
||||
The method is called by beets prior to calling ``parse_args``.
|
||||
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``.
|
||||
class AttachmentEntityQuery(Query):
|
||||
|
||||
Calling `attachments(None, entity_query)` is equivalent to::
|
||||
def __init__(self, query):
|
||||
self.query = query
|
||||
|
||||
library.albums(entity_query).attachments() + \
|
||||
library.items(entity_query).attachments()
|
||||
"""
|
||||
# TODO implement
|
||||
raise NotImplementedError
|
||||
def match(self, attachment):
|
||||
if self.query is not None:
|
||||
return self.query.match(attachment.entity)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
class LibModelMixin(object):
|
||||
"""Extends ``beets.library.LibModel`` with attachment queries.
|
||||
"""Get associated attachments of `beets.library.LibModel` instances.
|
||||
"""
|
||||
|
||||
def attachments(self):
|
||||
"""Return a list of attachements associated to this model.
|
||||
"""
|
||||
# TODO implement
|
||||
raise NotImplementedError
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from beets.util.functemplate import Template
|
|||
from beets import dbcore
|
||||
from beets.dbcore import types
|
||||
import beets
|
||||
|
||||
from beets.attachments import Attachment
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
|
@ -957,7 +957,7 @@ def get_query(val, model_cls):
|
|||
class Library(dbcore.Database):
|
||||
"""A database of music containing songs and albums.
|
||||
"""
|
||||
_models = (Item, Album)
|
||||
_models = (Item, Album, Attachment)
|
||||
|
||||
def __init__(self, path='library.blb',
|
||||
directory='~/Music',
|
||||
|
|
|
|||
|
|
@ -18,13 +18,14 @@ from _common import unittest
|
|||
from beets.attachments import AttachmentFactory
|
||||
from beets.library import Library, Album
|
||||
|
||||
|
||||
class AttachmentFactoryTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.lib = Library(':memory:')
|
||||
self.factory = AttachmentFactory(self.lib)
|
||||
|
||||
def test_create(self):
|
||||
def test_create_with_url_and_type(self):
|
||||
attachment = self.factory.create('/path/to/attachment', 'coverart')
|
||||
self.assertEqual(attachment.url, '/path/to/attachment')
|
||||
self.assertEqual(attachment.type, 'coverart')
|
||||
|
|
@ -38,13 +39,24 @@ class AttachmentFactoryTest(unittest.TestCase):
|
|||
self.assertEqual(attachment.ref_type, 'album')
|
||||
|
||||
def test_create_populates_metadata(self):
|
||||
def collector(type, url):
|
||||
def collector(type, path):
|
||||
return {'mime': 'image/'}
|
||||
self.factory.register_collector(collector)
|
||||
|
||||
attachment = self.factory.create('/path/to/attachment', 'coverart')
|
||||
self.assertEqual(attachment['mime'], 'image/')
|
||||
|
||||
def test_find_all_attachments(self):
|
||||
self.factory.create('/path', 'atype').add()
|
||||
self.factory.create('/another_path', 'asecondtype').add()
|
||||
|
||||
all_attachments = self.factory.find()
|
||||
self.assertEqual(len(all_attachments), 2)
|
||||
|
||||
attachment = all_attachments.get()
|
||||
self.assertEqual(attachment.path, '/path')
|
||||
self.assertEqual(attachment.type, 'atype')
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
|
|
|||
Loading…
Reference in a new issue