mirror of
https://github.com/beetbox/beets.git
synced 2026-01-27 18:43:50 +01:00
Add base implementation of attachments
This commit is contained in:
parent
810841ba5a
commit
24c66538cb
3 changed files with 461 additions and 0 deletions
282
beets/attachements.py
Normal file
282
beets/attachements.py
Normal 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
61
beetsplug/attachments.py
Normal 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
118
beetsplug/coverart.py
Normal 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 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)
|
||||
Loading…
Reference in a new issue