Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add base implementation of attachments #591

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
727 changes: 727 additions & 0 deletions beets/attachments.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import:
detail: no
flat: no
group_albums: no
attachments: yes

clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information"]
Expand Down Expand Up @@ -102,3 +103,6 @@ match:
required: []
track_length_grace: 10
track_length_max: 30

attachment:
'track separators': [' - ', ' ', '-', '_', '.']
35 changes: 35 additions & 0 deletions beets/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from beets import config
from beets.util import pipeline
from beets.util import syspath, normpath, displayable_path
from beets.attachments import AttachmentFactory
from enum import Enum
from beets import mediafile

Expand Down Expand Up @@ -180,6 +181,8 @@ def __init__(self, lib, logfile, paths, query):
self.query = query
self.seen_idents = set()
self._is_resuming = dict()
self.attachment_factory = AttachmentFactory(lib)
self.attachment_factory.register_plugins(plugins.find_plugins())

# Normalize the paths.
if self.paths:
Expand Down Expand Up @@ -354,6 +357,7 @@ def __init__(self, toppath=None, paths=None, items=None):
# TODO remove this eventually
self.should_remove_duplicates = False
self.is_album = True
self.attachments = []

def set_null_candidates(self):
"""Set the candidates to indicate no album match was found.
Expand Down Expand Up @@ -606,14 +610,23 @@ def manipulate_files(self, move=False, copy=False, write=False,
for item in self.imported_items():
item.store()

for a in self.attachments:
if move or copy:
a.move(copy=copy)

plugins.send('import_task_files', session=session, task=self)

def add(self, lib):
"""Add the items as an album to the library and remove replaced items.
"""
with lib.transaction():
self.remove_replaced(lib)
# FIXME set album earlier so we can use it to create
# attachmenst
self.album = lib.add_album(self.imported_items())
for a in self.attachments:
a.entity = self.album
a.store()

def remove_replaced(self, lib):
"""Removes all the items from the library that have the same
Expand Down Expand Up @@ -648,6 +661,19 @@ def reload(self):
item.load()
self.album.load()

def discover_attachments(self, factory):
"""Return a list of known attachments for files in the album's directory.

Saves the list in the `attachments` attribute.
"""
# FIXME the album model should already be available so we can
# attach the attachment. This also means attachments must handle
# unpersisted entities.
for album_path in self.paths or []:
for path in factory.discover(album_path):
self.attachments.extend(factory.detect(path))
return self.attachments

# Utilities.

def prune(self, filename):
Expand Down Expand Up @@ -722,6 +748,8 @@ def add(self, lib):
with lib.transaction():
self.remove_replaced(lib)
lib.add(self.item)
for a in self.attachments:
a.store()

def infer_album_fields(self):
raise NotImplementedError
Expand All @@ -736,6 +764,11 @@ def choose_match(self, session):
def reload(self):
self.item.load()

def discover_attachments(self, factory):
for path in factory.discover(self.item.path):
self.attachments.extend(factory.detect(path, self.item))
return self.attachments


# FIXME The inheritance relationships are inverted. This is why there
# are so many methods which pass. We should introduce a new
Expand Down Expand Up @@ -1096,6 +1129,8 @@ def apply_choices(session, task):
if task.is_album:
task.infer_album_fields()

if session.config['attachments']:
task.discover_attachments(session.attachment_factory)
task.add(session.lib)


Expand Down
7 changes: 4 additions & 3 deletions beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
from beets import dbcore
from beets.dbcore import types
import beets

from beets import attachments
from beets.attachments import Attachment

log = logging.getLogger('beets')

Expand Down Expand Up @@ -193,7 +194,7 @@ def __unicode__(self):

# Item and Album model classes.

class LibModel(dbcore.Model):
class LibModel(dbcore.Model, attachments.LibModelMixin):
"""Shared concrete functionality for Items and Albums.
"""
_bytes_keys = ('path', 'artpath')
Expand Down Expand Up @@ -957,7 +958,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',
Expand Down
123 changes: 123 additions & 0 deletions beets/ui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from beets.util.functemplate import Template
from beets import library
from beets import config
from beets.attachments import AttachmentFactory
from beets.util.confit import _package_path

VARIOUS_ARTISTS = u'Various Artists'
Expand Down Expand Up @@ -78,6 +79,128 @@ def _do_query(lib, query, album, also_items=True):
return items, albums


class AttachCommand(ui.Subcommand):

def __init__(self):
super(AttachCommand, self).__init__(
'attach',
help='create an attachment for an album or a '
'track and move the attachment'
)

self.parser.add_option(
'-c', '--copy', action='store_true', dest='copy',
help='copy attachment intead of moving them'
)
self.parser.add_option(
'-M', '--no-move', action='store_false', dest='move',
help='keep the attachment in place'
)
self.parser.add_option(
'--track', action='store_true', dest='track',
help='attach path to the tracks matched by the query'
)
self.parser.add_option(
'-t', '--type', dest='type',
help='create one attachment with this type'
)
self.parser.add_option(
'-l', '--local', dest='local', action='store_true',
help='path is local to album directory'
)
self.parser.add_option(
'-d', '--discover', dest='discover', action='store_true',
)

def func(self, lib, opts, args):
factory = AttachmentFactory(lib)
factory.register_plugins(plugins.find_plugins())

if opts.discover:
path = None
else:
path = args.pop(0)

if opts.track:
entities = lib.items(decargs(args))
else:
entities = lib.albums(decargs(args))

for entity in entities:
if opts.local or opts.discover:
paths = factory.discover(entity, path)
else:
paths = [self.resolve_path(path)]

for abspath in paths:
if opts.type:
attachments = [factory.create(abspath, opts.type, entity)]
else:
attachments = factory.detect(abspath, entity)

for a in attachments:
a.store()
if opts.move != False or opts.copy:
a.move(copy=opts.copy)
else:
log.warn(u'unknown attachment: {0}'
.format(displayable_path(abspath)))

def resolve_path(self, path, album_dir=None):
if os.path.isabs(path):
return normpath(path)
if album_dir:
return normpath(os.path.join(album_dir, path))
return normpath(os.path.abspath(path))


default_commands.append(AttachCommand())


class AttachListCommand(ui.Subcommand):

def __init__(self):
super(AttachListCommand, self).__init__(
'attachls',
help='list attachments by query'
'track and move the attachment'
)

def func(self, lib, opts, args):
args = decargs(args)
factory = AttachmentFactory(lib)
for a in factory.parse_and_find(*args):
print(u'{0}: {1}'.format(a.type, displayable_path(a.path)))


default_commands.append(AttachListCommand())


class AttachImportCommand(ui.Subcommand):
"""Search files in album directories and create attachments for them.
"""

def __init__(self):
super(AttachImportCommand, self).__init__(
'attach-import',
help='create attachments for albums already in the library'
)

def func(self, lib, opts, args):
args = decargs(args)
factory = AttachmentFactory(lib)
for album in lib.albums(decargs(args)):
for path in factory.discover(album):
for attachment in factory.detect(path, album):
print(u"add {0} attachment {1} to '{2} - {3}'"
.format(attachment.type, path,
album.albumartist, album.album))
attachment.add()


default_commands.append(AttachImportCommand())


# fields: Shows a list of available fields for queries and format strings.

def fields_func(lib, opts, args):
Expand Down
118 changes: 118 additions & 0 deletions beetsplug/coverart.py
Original file line number Diff line number Diff line change
@@ -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