mirror of
https://github.com/beetbox/beets.git
synced 2025-12-26 18:43:38 +01:00
merge fixes from master
This commit is contained in:
commit
671ac052c6
17 changed files with 622 additions and 11 deletions
|
|
@ -7,3 +7,6 @@ install:
|
|||
- pip install pylast flask --use-mirrors
|
||||
- "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2 --use-mirrors; fi"
|
||||
script: nosetests
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import shlex
|
|||
import unicodedata
|
||||
import threading
|
||||
import contextlib
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from unidecode import unidecode
|
||||
from beets.mediafile import MediaFile
|
||||
|
|
@ -283,7 +284,12 @@ class Item(object):
|
|||
f = MediaFile(syspath(self.path))
|
||||
for key in ITEM_KEYS_WRITABLE:
|
||||
setattr(f, key, getattr(self, key))
|
||||
f.save()
|
||||
|
||||
try:
|
||||
f.save()
|
||||
except (OSError, IOError) as exc:
|
||||
raise util.FilesystemError(exc, 'write', (self.path,),
|
||||
traceback.format_exc())
|
||||
|
||||
# The file has a new mtime.
|
||||
self.mtime = self.current_mtime()
|
||||
|
|
@ -1135,6 +1141,9 @@ class Library(BaseLibrary):
|
|||
|
||||
# Preserve extension.
|
||||
_, extension = pathmod.splitext(item.path)
|
||||
if fragment:
|
||||
# Outputting Unicode.
|
||||
extension = extension.decode('utf8', 'ignore')
|
||||
subpath += extension.lower()
|
||||
|
||||
if fragment:
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class FilesystemError(HumanReadableException):
|
|||
clause = 'while {0} {1} to {2}'.format(
|
||||
self._gerund(), repr(self.paths[0]), repr(self.paths[1])
|
||||
)
|
||||
elif self.verb in ('delete',):
|
||||
elif self.verb in ('delete', 'write'):
|
||||
clause = 'while {0} {1}'.format(
|
||||
self._gerund(), repr(self.paths[0])
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ music player.
|
|||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import gst
|
||||
import sys
|
||||
import time
|
||||
import gobject
|
||||
|
|
@ -26,6 +25,10 @@ import os
|
|||
import copy
|
||||
import urllib
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
class GstPlayer(object):
|
||||
"""A music player abstracting GStreamer's Playbin element.
|
||||
|
||||
|
|
|
|||
104
beetsplug/fuzzy_search.py
Normal file
104
beetsplug/fuzzy_search.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2011, Philippe Mongeau.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Like beet list, but with fuzzy matching
|
||||
"""
|
||||
import beets
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand, decargs, print_
|
||||
from beets.util.functemplate import Template
|
||||
import difflib
|
||||
|
||||
|
||||
# THRESHOLD = 0.7
|
||||
|
||||
|
||||
def fuzzy_score(query, item):
|
||||
return difflib.SequenceMatcher(a=query, b=item).quick_ratio()
|
||||
|
||||
|
||||
def is_match(query, item, album=False, verbose=False, threshold=0.7):
|
||||
query = ' '.join(query)
|
||||
|
||||
if album:
|
||||
values = [item.albumartist, item.album]
|
||||
else:
|
||||
values = [item.artist, item.album, item.title]
|
||||
|
||||
s = max(fuzzy_score(query.lower(), i.lower()) for i in values)
|
||||
if verbose:
|
||||
return (s >= threshold, s)
|
||||
else:
|
||||
return s >= threshold
|
||||
|
||||
|
||||
def fuzzy_list(lib, config, opts, args):
|
||||
query = decargs(args)
|
||||
fmt = opts.format
|
||||
if opts.threshold is not None:
|
||||
threshold = float(opts.threshold)
|
||||
else:
|
||||
threshold = float(conf['threshold'])
|
||||
|
||||
if fmt is None:
|
||||
# If no specific template is supplied, use a default
|
||||
if opts.album:
|
||||
fmt = u'$albumartist - $album'
|
||||
else:
|
||||
fmt = u'$artist - $album - $title'
|
||||
template = Template(fmt)
|
||||
|
||||
if opts.album:
|
||||
objs = lib.albums()
|
||||
else:
|
||||
objs = lib.items()
|
||||
|
||||
items = filter(lambda i: is_match(query, i, album=opts.album,
|
||||
threshold=threshold), objs)
|
||||
for i in items:
|
||||
if opts.path:
|
||||
print_(i.item_dir() if opts.album else i.path)
|
||||
elif opts.album:
|
||||
print_(i.evaluate_template(template))
|
||||
else:
|
||||
print_(i.evaluate_template(template, lib))
|
||||
if opts.verbose:
|
||||
print(is_match(query, i, album=opts.album, verbose=True)[1])
|
||||
|
||||
|
||||
fuzzy_cmd = Subcommand('fuzzy',
|
||||
help='list items using fuzzy matching')
|
||||
fuzzy_cmd.parser.add_option('-a', '--album', action='store_true',
|
||||
help='choose an album instead of track')
|
||||
fuzzy_cmd.parser.add_option('-p', '--path', action='store_true',
|
||||
help='print the path of the matched item')
|
||||
fuzzy_cmd.parser.add_option('-f', '--format', action='store',
|
||||
help='print with custom format', default=None)
|
||||
fuzzy_cmd.parser.add_option('-v', '--verbose', action='store_true',
|
||||
help='output scores for matches')
|
||||
fuzzy_cmd.parser.add_option('-t', '--threshold', action='store',
|
||||
help='return result with a fuzzy score above threshold. \
|
||||
(default is 0.7)', default=None)
|
||||
fuzzy_cmd.func = fuzzy_list
|
||||
|
||||
conf = {}
|
||||
|
||||
|
||||
class Fuzzy(BeetsPlugin):
|
||||
def commands(self):
|
||||
return [fuzzy_cmd]
|
||||
|
||||
def configure(self, config):
|
||||
conf['threshold'] = beets.ui.config_val(config, 'fuzzy',
|
||||
'threshold', 0.7)
|
||||
|
|
@ -21,7 +21,7 @@ import re
|
|||
|
||||
from beets import ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import normpath
|
||||
from beets.util import normpath, syspath, bytestring_path
|
||||
|
||||
M3U_DEFAULT_NAME = 'imported.m3u'
|
||||
|
||||
|
|
@ -36,9 +36,9 @@ class ImportFeedsPlugin(BeetsPlugin):
|
|||
_feeds_dir = ui.config_val(config, 'importfeeds', 'feeds_dir', None)
|
||||
|
||||
if _feeds_dir:
|
||||
_feeds_dir = os.path.expanduser(_feeds_dir)
|
||||
_feeds_dir = os.path.expanduser(bytestring_path(_feeds_dir))
|
||||
if not os.path.exists(_feeds_dir):
|
||||
os.makedirs(_feeds_dir)
|
||||
os.makedirs(syspath(_feeds_dir))
|
||||
|
||||
def _get_feeds_dir(lib):
|
||||
"""Given a Library object, return the path to the feeds directory to be
|
||||
|
|
@ -49,8 +49,8 @@ def _get_feeds_dir(lib):
|
|||
dirpath = lib.directory
|
||||
|
||||
# Ensure directory exists.
|
||||
if not os.path.exists(dirpath):
|
||||
os.makedirs(dirpath)
|
||||
if not os.path.exists(syspath(dirpath)):
|
||||
os.makedirs(syspath(dirpath))
|
||||
return dirpath
|
||||
|
||||
def _build_m3u_filename(basename):
|
||||
|
|
@ -65,7 +65,7 @@ def _build_m3u_filename(basename):
|
|||
def _write_m3u(m3u_path, items_paths):
|
||||
"""Append relative paths to items into m3u file.
|
||||
"""
|
||||
with open(m3u_path, 'a') as f:
|
||||
with open(syspath(m3u_path), 'a') as f:
|
||||
for path in items_paths:
|
||||
f.write(path + '\n')
|
||||
|
||||
|
|
|
|||
|
|
@ -167,16 +167,21 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
fallback_str = ui.config_val(config, 'lastgenre', 'fallback_str', None)
|
||||
|
||||
def imported(self, config, task):
|
||||
tags = []
|
||||
if task.is_album:
|
||||
album = config.lib.get_album(task.album_id)
|
||||
lastfm_obj = LASTFM.get_album(album.albumartist, album.album)
|
||||
if album.genre:
|
||||
tags.append(album.genre)
|
||||
else:
|
||||
item = task.item
|
||||
lastfm_obj = LASTFM.get_track(item.artist, item.title)
|
||||
if item.genre:
|
||||
tags.append(item.genre)
|
||||
|
||||
tags = _tags_for(lastfm_obj)
|
||||
tags.extend(_tags_for(lastfm_obj))
|
||||
genre = _tags_to_genre(tags)
|
||||
|
||||
|
||||
if not genre and fallback_str != None:
|
||||
genre = fallback_str
|
||||
log.debug(u'no last.fm genre found: fallback to %s' % genre)
|
||||
|
|
|
|||
131
beetsplug/the.py
Normal file
131
beetsplug/the.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2012, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Moves patterns in path formats (suitable for moving articles)."""
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import re
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
|
||||
|
||||
__author__ = 'baobab@heresiarch.info'
|
||||
__version__ = '1.0'
|
||||
|
||||
PATTERN_THE = u'^[the]{3}\s'
|
||||
PATTERN_A = u'^[a][n]?\s'
|
||||
FORMAT = u'{0}, {1}'
|
||||
|
||||
the_options = {
|
||||
'debug': False,
|
||||
'the': True,
|
||||
'a': True,
|
||||
'format': FORMAT,
|
||||
'strip': False,
|
||||
'silent': False,
|
||||
'patterns': [PATTERN_THE, PATTERN_A],
|
||||
}
|
||||
|
||||
|
||||
class ThePlugin(BeetsPlugin):
|
||||
|
||||
def configure(self, config):
|
||||
if not config.has_section('the'):
|
||||
print('[the] plugin is not configured, using defaults',
|
||||
file=sys.stderr)
|
||||
return
|
||||
self.in_config = True
|
||||
the_options['debug'] = ui.config_val(config, 'the', 'debug', False,
|
||||
bool)
|
||||
the_options['the'] = ui.config_val(config, 'the', 'the', True, bool)
|
||||
the_options['a'] = ui.config_val(config, 'the', 'a', True, bool)
|
||||
the_options['format'] = ui.config_val(config, 'the', 'format',
|
||||
FORMAT)
|
||||
the_options['strip'] = ui.config_val(config, 'the', 'strip', False,
|
||||
bool)
|
||||
the_options['silent'] = ui.config_val(config, 'the', 'silent', False,
|
||||
bool)
|
||||
the_options['patterns'] = ui.config_val(config, 'the', 'patterns',
|
||||
'').split()
|
||||
for p in the_options['patterns']:
|
||||
if p:
|
||||
try:
|
||||
re.compile(p)
|
||||
except re.error:
|
||||
print(u'[the] invalid pattern: {0}'.format(p),
|
||||
file=sys.stderr)
|
||||
else:
|
||||
if not (p.startswith('^') or p.endswith('$')):
|
||||
if not the_options['silent']:
|
||||
print(u'[the] warning: pattern \"{0}\" will not '
|
||||
'match string start/end'.format(p),
|
||||
file=sys.stderr)
|
||||
if the_options['a']:
|
||||
the_options['patterns'] = [PATTERN_A] + the_options['patterns']
|
||||
if the_options['the']:
|
||||
the_options['patterns'] = [PATTERN_THE] + the_options['patterns']
|
||||
if not the_options['patterns'] and not the_options['silent']:
|
||||
print('[the] no patterns defined!')
|
||||
if the_options['debug']:
|
||||
print(u'[the] patterns: {0}'
|
||||
.format(' '.join(the_options['patterns'])), file=sys.stderr)
|
||||
|
||||
|
||||
def unthe(text, pattern, strip=False):
|
||||
"""Moves pattern in the path format string or strips it
|
||||
|
||||
text -- text to handle
|
||||
pattern -- regexp pattern (case ignore is already on)
|
||||
strip -- if True, pattern will be removed
|
||||
|
||||
"""
|
||||
if text:
|
||||
r = re.compile(pattern, flags=re.IGNORECASE)
|
||||
try:
|
||||
t = r.findall(text)[0]
|
||||
except IndexError:
|
||||
return text
|
||||
else:
|
||||
r = re.sub(r, '', text).strip()
|
||||
if strip:
|
||||
return r
|
||||
else:
|
||||
return the_options['format'].format(r, t.strip()).strip()
|
||||
else:
|
||||
return u''
|
||||
|
||||
|
||||
@ThePlugin.template_func('the')
|
||||
def func_the(text):
|
||||
"""Provides beets template function %the"""
|
||||
if not the_options['patterns']:
|
||||
return text
|
||||
if text:
|
||||
for p in the_options['patterns']:
|
||||
r = unthe(text, p, the_options['strip'])
|
||||
if r != text:
|
||||
break
|
||||
if the_options['debug']:
|
||||
print(u'[the] \"{0}\" -> \"{1}\"'.format(text, r), file=sys.stderr)
|
||||
return r
|
||||
else:
|
||||
return u''
|
||||
|
||||
|
||||
# simple tests
|
||||
if __name__ == '__main__':
|
||||
print(unthe('The The', PATTERN_THE))
|
||||
print(unthe('An Apple', PATTERN_A))
|
||||
print(unthe('A Girl', PATTERN_A, strip=True))
|
||||
129
beetsplug/zero.py
Normal file
129
beetsplug/zero.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2012, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
#
|
||||
# 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.
|
||||
|
||||
""" Clears tag fields in media files."""
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import re
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
from beets.library import ITEM_KEYS
|
||||
from beets.importer import action
|
||||
|
||||
|
||||
__author__ = 'baobab@heresiarch.info'
|
||||
__version__ = '0.9'
|
||||
|
||||
|
||||
class ZeroPlugin(BeetsPlugin):
|
||||
|
||||
_instance = None
|
||||
|
||||
debug = False
|
||||
fields = []
|
||||
patterns = {}
|
||||
warned = False
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ZeroPlugin,
|
||||
cls).__new__(cls, *args, **kwargs)
|
||||
return cls._instance
|
||||
|
||||
def __str__(self):
|
||||
return ('[zero]\n debug = {0}\n fields = {1}\n patterns = {2}\n'
|
||||
' warned = {3}'.format(self.debug, self.fields, self.patterns,
|
||||
self.warned))
|
||||
|
||||
def dbg(self, *args):
|
||||
"""Prints message to stderr."""
|
||||
if self.debug:
|
||||
print('[zero]', *args, file=sys.stderr)
|
||||
|
||||
def configure(self, config):
|
||||
if not config.has_section('zero'):
|
||||
self.dbg('plugin is not configured')
|
||||
return
|
||||
self.debug = ui.config_val(config, 'zero', 'debug', True, bool)
|
||||
for f in ui.config_val(config, 'zero', 'fields', '').split():
|
||||
if f not in ITEM_KEYS:
|
||||
self.dbg(
|
||||
'invalid field \"{0}\" (try \'beet fields\')'.format(f)
|
||||
)
|
||||
else:
|
||||
self.fields.append(f)
|
||||
p = ui.config_val(config, 'zero', f, '').split()
|
||||
if p:
|
||||
self.patterns[f] = p
|
||||
else:
|
||||
self.patterns[f] = ['.']
|
||||
if self.debug:
|
||||
print(self, file=sys.stderr)
|
||||
|
||||
def import_task_choice_event(self, task, config):
|
||||
"""Listen for import_task_choice event."""
|
||||
if self.debug:
|
||||
self.dbg('listen: import_task_choice')
|
||||
if task.choice_flag == action.ASIS and not self.warned:
|
||||
self.dbg('cannot zero in \"as-is\" mode')
|
||||
self.warned = True
|
||||
# TODO request write in as-is mode
|
||||
|
||||
@classmethod
|
||||
def match_patterns(cls, field, patterns):
|
||||
"""Check if field (as string) is matching any of the patterns in
|
||||
the list.
|
||||
"""
|
||||
for p in patterns:
|
||||
if re.findall(p, unicode(field), flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
def write_event(self, item):
|
||||
"""Listen for write event."""
|
||||
if self.debug:
|
||||
self.dbg('listen: write')
|
||||
if not self.fields:
|
||||
self.dbg('no fields, nothing to do')
|
||||
return
|
||||
for fn in self.fields:
|
||||
try:
|
||||
fval = getattr(item, fn)
|
||||
except AttributeError:
|
||||
self.dbg('? no such field: {0}'.format(fn))
|
||||
else:
|
||||
if not self.match_patterns(fval, self.patterns[fn]):
|
||||
self.dbg('\"{0}\" ({1}) is not match any of: {2}'
|
||||
.format(fval, fn, ' '.join(self.patterns[fn])))
|
||||
continue
|
||||
self.dbg('\"{0}\" ({1}) match: {2}'
|
||||
.format(fval, fn, ' '.join(self.patterns[fn])))
|
||||
setattr(item, fn, type(fval)())
|
||||
self.dbg('{0}={1}'.format(fn, getattr(item, fn)))
|
||||
|
||||
|
||||
@ZeroPlugin.listen('import_task_choice')
|
||||
def zero_choice(task, config):
|
||||
ZeroPlugin().import_task_choice_event(task, config)
|
||||
|
||||
@ZeroPlugin.listen('write')
|
||||
def zero_write(item):
|
||||
ZeroPlugin().write_event(item)
|
||||
|
||||
|
||||
# simple test
|
||||
if __name__ == '__main__':
|
||||
print(ZeroPlugin().match_patterns('test', ['[0-9]']))
|
||||
print(ZeroPlugin().match_patterns('test', ['.']))
|
||||
|
|
@ -4,6 +4,13 @@ Changelog
|
|||
1.0b16 (in development)
|
||||
-----------------------
|
||||
|
||||
* New plugin: :doc:`/plugins/fuzzy_search` lets you find albums and tracks using
|
||||
fuzzy string matching so you don't have to type (or even remember) their exact
|
||||
names. Thanks to Philippe Mongeau.
|
||||
* New plugin: :doc:`/plugins/the` adds a template function that helps format
|
||||
text for nicely-sorted directory listings. Thanks to Blemjhoo Tezoulbr.
|
||||
* New plugin: :doc:`/plugins/zero` filters out undesirable fields before they
|
||||
are written to your tags. Thanks again to Blemjhoo Tezoulbr.
|
||||
* :doc:`/plugins/scrub`: Scrubbing now removes *all* types of tags from a file
|
||||
rather than just one. For example, if your FLAC file has both ordinary FLAC
|
||||
tags and ID3 tags, the ID3 tags are now also removed.
|
||||
|
|
@ -20,11 +27,16 @@ Changelog
|
|||
`Tomahawk resolver`_).
|
||||
* :doc:`/plugins/web`: Files now download with a reasonable filename rather
|
||||
than just being called "file" (thanks to Zach Denton).
|
||||
* :doc:`/plugins/importfeeds`: Fix error in symlink mode with non-ASCII
|
||||
filenames.
|
||||
* Add the track mapping dictionary to the ``album_distance`` plugin function.
|
||||
* Fix an assertion failure when the MusicBrainz main database and search server
|
||||
disagree.
|
||||
* Fix a bug that caused the :doc:`/plugins/lastgenre` and other plugins not to
|
||||
modify files' tags even when they successfully change the database.
|
||||
* Fix a VFS bug leading to a crash in the :doc:`/plugins/bpd` when files had
|
||||
non-ASCII extensions.
|
||||
* Add a human-readable error message when writing files' tags fails.
|
||||
|
||||
.. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html
|
||||
|
||||
|
|
|
|||
25
docs/plugins/fuzzy_search.rst
Normal file
25
docs/plugins/fuzzy_search.rst
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Fuzzy Search Plugin
|
||||
===================
|
||||
|
||||
The ``fuzzy_search`` plugin provides a command that search your library using
|
||||
fuzzy pattern matching. This can be useful if you want to find a track with complicated characters in the title.
|
||||
|
||||
First, enable the plugin named ``fuzzy_search`` (see :doc:`/plugins/index`).
|
||||
You'll then be able to use the ``beet fuzzy`` command::
|
||||
|
||||
$ beet fuzzy Vareoldur
|
||||
Sigur Rós - Valtari - Varðeldur
|
||||
|
||||
The command has several options that resemble those for the ``beet list``
|
||||
command (see :doc:`/reference/cli`). To choose an album instead of a single
|
||||
track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and
|
||||
to use a custom format for printing, use ``-f FORMAT``.
|
||||
|
||||
The ``-t NUMBER`` option lets you specify how precise the fuzzy match has to be
|
||||
(default is 0.7). To make a fuzzier search, try ``beet fuzzy -t 0.5 Varoeldur``.
|
||||
A value of ``1`` will show only perfect matches and a value of ``0`` will match everything.
|
||||
|
||||
The default threshold can also be set from the config file.::
|
||||
|
||||
[fuzzy]
|
||||
threshold: 0.8
|
||||
|
|
@ -50,6 +50,9 @@ disabled by default, but you can turn them on as described above.
|
|||
rdm
|
||||
mbcollection
|
||||
importfeeds
|
||||
the
|
||||
fuzzy_search
|
||||
zero
|
||||
|
||||
Autotagger Extensions
|
||||
''''''''''''''''''''''
|
||||
|
|
@ -66,12 +69,14 @@ Metadata
|
|||
* :doc:`embedart`: Embed album art images into files' metadata.
|
||||
* :doc:`replaygain`: Calculate volume normalization for players that support it.
|
||||
* :doc:`scrub`: Clean extraneous metadata from music files.
|
||||
* :doc:`zero`: Nullify fields by pattern or unconditionally.
|
||||
|
||||
Path Formats
|
||||
''''''''''''
|
||||
|
||||
* :doc:`inline`: Use Python snippets to customize path format strings.
|
||||
* :doc:`rewrite`: Substitute values in path formats.
|
||||
* :doc:`the`: Moves patterns in path formats (suitable for moving articles).
|
||||
|
||||
Interoperability
|
||||
''''''''''''''''
|
||||
|
|
@ -85,6 +90,7 @@ Miscellaneous
|
|||
|
||||
* :doc:`web`: An experimental Web-based GUI for beets.
|
||||
* :doc:`rdm`: Randomly choose albums and tracks from your library.
|
||||
* :doc:`fuzzy_search`: Search albums and tracks with fuzzy string matching.
|
||||
* :doc:`mbcollection`: Maintain your MusicBrainz collection list.
|
||||
* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is
|
||||
compatible with `MPD clients`_.
|
||||
|
|
|
|||
47
docs/plugins/the.rst
Normal file
47
docs/plugins/the.rst
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
The Plugin
|
||||
==========
|
||||
|
||||
The ``the`` plugin allows you to move patterns in path formats. It's suitable,
|
||||
for example, for moving articles from string start to the end. This is useful
|
||||
for quick search on filesystems and generally looks good. Plugin DOES NOT
|
||||
change tags. By default plugin supports English "the, a, an", but custom
|
||||
regexp patterns can be added by user. How it works::
|
||||
|
||||
The Something -> Something, The
|
||||
A Band -> Band, A
|
||||
An Orchestra -> Orchestra, An
|
||||
|
||||
To use plugin, enable it by including ``the`` into ``plugins`` line of
|
||||
your beets config::
|
||||
|
||||
[beets]
|
||||
plugins = the
|
||||
|
||||
Plugin provides template function %the, so you can use it on $albumartist or $artist::
|
||||
|
||||
[paths]
|
||||
default: %the{$albumartist}/($year) $album/$track $title
|
||||
|
||||
Default options are acceptable (moves all English articles to the end), but you
|
||||
can add plugin section into config file::
|
||||
|
||||
[the]
|
||||
# handle The, default is on
|
||||
the=yes
|
||||
# handle A/An, default is on
|
||||
a=yes
|
||||
# format string, {0} - part w/o article, {1} - article
|
||||
# spaces already trimmed from ends of both parts
|
||||
# default is '{0}, {1}'
|
||||
format={0}, {1}
|
||||
# strip instead of moving to the end, default is off
|
||||
strip=no
|
||||
# do not print warnings, default is off
|
||||
silent=no
|
||||
# custom regexp patterns, separated by space
|
||||
patterns=
|
||||
|
||||
Custom patterns are usual regular expressions. Ignore case is turned on, but ^ is not added
|
||||
automatically, so be careful. Actually, you can swap arguments in format option and write
|
||||
regexp to match end of the string, so things will be moved from the end of the string to
|
||||
start.
|
||||
29
docs/plugins/zero.rst
Normal file
29
docs/plugins/zero.rst
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
Zero Plugin
|
||||
===========
|
||||
|
||||
The ``zero`` plugin allows you to null fields before writing tags to files.
|
||||
Fields can be nulled unconditionally or by pattern match. For example, it can
|
||||
be used to strip useless comments like "ripped by" etc or any other stuff you
|
||||
hate. Library is not modified.
|
||||
|
||||
To use plugin, enable it by including ``zero`` into ``plugins`` line of
|
||||
your beets config::
|
||||
|
||||
[beets]
|
||||
plugins = zero
|
||||
|
||||
To configure the plugin, use a ``[zero]`` section in your configuration file.
|
||||
Set ``fields`` to the (whitespace-separated) list of fields to null. You can get
|
||||
the list of available fields by running ``beet fields``. To conditionally filter
|
||||
a field, use ``field=regexp regexp`` to specify regular expressions.
|
||||
|
||||
For example::
|
||||
|
||||
[zero]
|
||||
fields=month day genre comments
|
||||
# Custom regexp patterns for each field, separated by spaces:
|
||||
comments=EAC LAME from.+collection ripped\sby
|
||||
genre=rnb power\smetal
|
||||
|
||||
If custom pattern is not defined, field will be nulled unconditionally. Note
|
||||
that the plugin currently does not zero fields when importing "as-is".
|
||||
|
|
@ -442,6 +442,12 @@ class DestinationTest(unittest.TestCase):
|
|||
finally:
|
||||
sys.getfilesystemencoding = oldfunc
|
||||
|
||||
def test_unicode_extension_in_fragment(self):
|
||||
self.lib.path_formats = [('default', u'foo')]
|
||||
self.i.path = util.bytestring_path(u'bar.caf\xe9')
|
||||
dest = self.lib.destination(self.i, platform='linux2', fragment=True)
|
||||
self.assertEqual(dest, u'foo.caf\xe9')
|
||||
|
||||
class PathFormattingMixin(object):
|
||||
"""Utilities for testing path formatting."""
|
||||
def _setf(self, fmt):
|
||||
|
|
|
|||
52
test/test_the.py
Normal file
52
test/test_the.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Tests for the 'the' plugin"""
|
||||
|
||||
from _common import unittest
|
||||
from beetsplug import the
|
||||
|
||||
|
||||
class ThePluginTest(unittest.TestCase):
|
||||
|
||||
|
||||
def test_unthe_with_default_patterns(self):
|
||||
self.assertEqual(the.unthe('', the.PATTERN_THE), '')
|
||||
self.assertEqual(the.unthe('The Something', the.PATTERN_THE),
|
||||
'Something, The')
|
||||
self.assertEqual(the.unthe('The The', the.PATTERN_THE), 'The, The')
|
||||
self.assertEqual(the.unthe('The The', the.PATTERN_THE), 'The, The')
|
||||
self.assertEqual(the.unthe('The The X', the.PATTERN_THE),
|
||||
u'The X, The')
|
||||
self.assertEqual(the.unthe('the The', the.PATTERN_THE), 'The, the')
|
||||
self.assertEqual(the.unthe('Protected The', the.PATTERN_THE),
|
||||
'Protected The')
|
||||
self.assertEqual(the.unthe('A Boy', the.PATTERN_A), 'Boy, A')
|
||||
self.assertEqual(the.unthe('a girl', the.PATTERN_A), 'girl, a')
|
||||
self.assertEqual(the.unthe('An Apple', the.PATTERN_A), 'Apple, An')
|
||||
self.assertEqual(the.unthe('An A Thing', the.PATTERN_A), 'A Thing, An')
|
||||
self.assertEqual(the.unthe('the An Arse', the.PATTERN_A),
|
||||
'the An Arse')
|
||||
self.assertEqual(the.unthe('The Something', the.PATTERN_THE,
|
||||
strip=True), 'Something')
|
||||
self.assertEqual(the.unthe('An A', the.PATTERN_A, strip=True), 'A')
|
||||
|
||||
def test_template_function_with_defaults(self):
|
||||
the.the_options['patterns'] = [the.PATTERN_THE, the.PATTERN_A]
|
||||
the.the_options['format'] = the.FORMAT
|
||||
self.assertEqual(the.func_the('The The'), 'The, The')
|
||||
self.assertEqual(the.func_the('An A'), 'A, An')
|
||||
|
||||
def test_custom_pattern(self):
|
||||
the.the_options['patterns'] = [ u'^test\s']
|
||||
the.the_options['format'] = the.FORMAT
|
||||
self.assertEqual(the.func_the('test passed'), 'passed, test')
|
||||
|
||||
def test_custom_format(self):
|
||||
the.the_options['patterns'] = [the.PATTERN_THE, the.PATTERN_A]
|
||||
the.the_options['format'] = '{1} ({0})'
|
||||
self.assertEqual(the.func_the('The A'), 'The (A)')
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
50
test/test_zero.py
Normal file
50
test/test_zero.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Tests for the 'zero' plugin"""
|
||||
|
||||
from _common import unittest
|
||||
from beets.library import Item
|
||||
from beetsplug.zero import ZeroPlugin
|
||||
|
||||
|
||||
class ZeroPluginTest(unittest.TestCase):
|
||||
|
||||
def test_singleton(self):
|
||||
z1 = ZeroPlugin()
|
||||
z2 = ZeroPlugin()
|
||||
self.assertTrue(z1 is z2)
|
||||
|
||||
def test_no_patterns(self):
|
||||
v = {'comments' : 'test comment',
|
||||
'day' : 13,
|
||||
'month' : 3,
|
||||
'year' : 2012}
|
||||
i=Item(v)
|
||||
z = ZeroPlugin()
|
||||
z.debug = False
|
||||
z.fields = ['comments', 'month', 'day']
|
||||
z.patterns = {'comments': ['.'],
|
||||
'month': ['.'],
|
||||
'day': ['.']}
|
||||
z.write_event(i)
|
||||
self.assertEqual(i.comments, '')
|
||||
self.assertEqual(i.day, 0)
|
||||
self.assertEqual(i.month, 0)
|
||||
self.assertEqual(i.year, 2012)
|
||||
|
||||
def test_patterns(self):
|
||||
v = {'comments' : 'from lame collection, ripped by eac',
|
||||
'year' : 2012}
|
||||
i=Item(v)
|
||||
z = ZeroPlugin()
|
||||
z.debug = False
|
||||
z.fields = ['comments', 'year']
|
||||
z.patterns = {'comments': 'eac lame'.split(),
|
||||
'year': '2098 2099'.split()}
|
||||
z.write_event(i)
|
||||
self.assertEqual(i.comments, '')
|
||||
self.assertEqual(i.year, 2012)
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
Loading…
Reference in a new issue