InRough update of the beets lib to 1.0b15

This commit is contained in:
rembo10
2012-07-28 23:45:08 +05:30
parent c1edd5085a
commit d245428ca2
17 changed files with 2786 additions and 1263 deletions

View File

@@ -8,7 +8,7 @@
# 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.
@@ -16,7 +16,7 @@
# MODIFIED TO WORK WITH HEADPHONES!!
#
__version__ = '1.0b14'
__version__ = '1.0b15'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
from lib.beets.library import Library

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,7 +8,7 @@
# 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.
@@ -22,7 +22,7 @@ from lib.beets import library, mediafile
from lib.beets.util import sorted_walk, ancestry
# Parts of external interface.
from .hooks import AlbumInfo, TrackInfo
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch
from .match import AutotagError
from .match import tag_item, tag_album
from .match import RECOMMEND_STRONG, RECOMMEND_MEDIUM, RECOMMEND_NONE
@@ -93,7 +93,7 @@ def albums_in_dir(path, ignore=()):
collapse_root = root
collapse_items = []
continue
# If it's nonempty, yield it.
if items:
yield root, items
@@ -106,6 +106,8 @@ def apply_item_metadata(item, track_info):
"""Set an item's metadata from its matched TrackInfo object.
"""
item.artist = track_info.artist
item.artist_sort = track_info.artist_sort
item.artist_credit = track_info.artist_credit
item.title = track_info.title
item.mb_trackid = track_info.track_id
if track_info.artist_id:
@@ -113,11 +115,12 @@ def apply_item_metadata(item, track_info):
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
def apply_metadata(items, album_info):
"""Set the items' metadata to match an AlbumInfo object. The list
of items must be ordered.
def apply_metadata(album_info, mapping, per_disc_numbering=False):
"""Set the items' metadata to match an AlbumInfo object using a
mapping from Items to TrackInfo objects. If `per_disc_numbering`,
then the track numbers are per-disc instead of per-release.
"""
for index, (item, track_info) in enumerate(zip(items, album_info.tracks)):
for item, track_info in mapping.iteritems():
# Album, artist, track count.
if not item:
continue
@@ -127,8 +130,15 @@ def apply_metadata(items, album_info):
item.artist = album_info.artist
item.albumartist = album_info.artist
item.album = album_info.album
item.tracktotal = len(items)
item.tracktotal = len(album_info.tracks)
# Artist sort and credit names.
item.artist_sort = track_info.artist_sort or album_info.artist_sort
item.artist_credit = track_info.artist_credit or \
album_info.artist_credit
item.albumartist_sort = album_info.artist_sort
item.albumartist_credit = album_info.artist_credit
# Release date.
if album_info.year:
item.year = album_info.year
@@ -136,15 +146,19 @@ def apply_metadata(items, album_info):
item.month = album_info.month
if album_info.day:
item.day = album_info.day
# Title and track index.
# Title.
item.title = track_info.title
item.track = index + 1
if per_disc_numbering:
item.track = track_info.medium_index
else:
item.track = track_info.index
# Disc and disc count.
item.disc = track_info.medium
item.disctotal = album_info.mediums
# MusicBrainz IDs.
item.mb_trackid = track_info.track_id
item.mb_albumid = album_info.album_id
@@ -153,12 +167,25 @@ def apply_metadata(items, album_info):
else:
item.mb_artistid = album_info.artist_id
item.mb_albumartistid = album_info.artist_id
item.mb_releasegroupid = album_info.releasegroup_id
# Compilation flag.
item.comp = album_info.va
# Miscellaneous metadata.
item.albumtype = album_info.albumtype
if album_info.label:
item.label = album_info.label
# Compilation flag.
item.comp = album_info.va
item.asin = album_info.asin
item.catalognum = album_info.catalognum
item.script = album_info.script
item.language = album_info.language
item.country = album_info.country
item.albumstatus = album_info.albumstatus
item.media = album_info.media
item.albumdisambig = album_info.albumdisambig
item.disctitle = track_info.disctitle
# Headphones seal of approval
item.comments = 'tagged by headphones/beets'

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,15 +8,20 @@
# 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.
"""Glue between metadata sources and the matching logic."""
import logging
from collections import namedtuple
from lib.beets import plugins
from lib.beets.autotag import mb
log = logging.getLogger('beets')
# Classes used to represent candidate options.
class AlbumInfo(object):
@@ -36,13 +41,26 @@ class AlbumInfo(object):
- ``day``: release day
- ``label``: music label responsible for the release
- ``mediums``: the number of discs in this release
- ``artist_sort``: name of the release's artist for sorting
- ``releasegroup_id``: MBID for the album's release group
- ``catalognum``: the label's catalog number for the release
- ``script``: character set used for metadata
- ``language``: human language of the metadata
- ``country``: the release country
- ``albumstatus``: MusicBrainz release status (Official, etc.)
- ``media``: delivery mechanism (Vinyl, etc.)
- ``albumdisambig``: MusicBrainz release disambiguation comment
- ``artist_credit``: Release-specific artist name
The fields up through ``tracks`` are required. The others are
optional and may be None.
"""
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
albumtype=None, va=False, year=None, month=None, day=None,
label=None, mediums=None):
label=None, mediums=None, artist_sort=None,
releasegroup_id=None, catalognum=None, script=None,
language=None, country=None, albumstatus=None, media=None,
albumdisambig=None, artist_credit=None):
self.album = album
self.album_id = album_id
self.artist = artist
@@ -56,6 +74,16 @@ class AlbumInfo(object):
self.day = day
self.label = label
self.mediums = mediums
self.artist_sort = artist_sort
self.releasegroup_id = releasegroup_id
self.catalognum = catalognum
self.script = script
self.language = language
self.country = country
self.albumstatus = albumstatus
self.media = media
self.albumdisambig = albumdisambig
self.artist_credit = artist_credit
class TrackInfo(object):
"""Describes a canonical track present on a release. Appears as part
@@ -66,32 +94,53 @@ class TrackInfo(object):
- ``artist``: individual track artist name
- ``artist_id``
- ``length``: float: duration of the track in seconds
- ``index``: position on the entire release
- ``medium``: the disc number this track appears on in the album
- ``medium_index``: the track's position on the disc
- ``artist_sort``: name of the track artist for sorting
- ``disctitle``: name of the individual medium (subtitle)
- ``artist_credit``: Recording-specific artist name
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None.
may be None. The indices ``index``, ``medium``, and ``medium_index``
are all 1-based.
"""
def __init__(self, title, track_id, artist=None, artist_id=None,
length=None, medium=None, medium_index=None):
length=None, index=None, medium=None, medium_index=None,
artist_sort=None, disctitle=None, artist_credit=None):
self.title = title
self.track_id = track_id
self.artist = artist
self.artist_id = artist_id
self.length = length
self.index = index
self.medium = medium
self.medium_index = medium_index
self.artist_sort = artist_sort
self.disctitle = disctitle
self.artist_credit = artist_credit
AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping',
'extra_items', 'extra_tracks'])
TrackMatch = namedtuple('TrackMatch', ['distance', 'info'])
# Aggregation of sources.
def _album_for_id(album_id):
"""Get an album corresponding to a MusicBrainz release ID."""
return mb.album_for_id(album_id)
try:
return mb.album_for_id(album_id)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
def _track_for_id(track_id):
"""Get an item for a recording MBID."""
return mb.track_for_id(track_id)
try:
return mb.track_for_id(track_id)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
def _album_candidates(items, artist, album, va_likely):
"""Search for album matches. ``items`` is a list of Item objects
@@ -104,11 +153,17 @@ def _album_candidates(items, artist, album, va_likely):
# Base candidates if we have album and artist to match.
if artist and album:
out.extend(mb.match_album(artist, album, len(items)))
try:
out.extend(mb.match_album(artist, album, len(items)))
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
out.extend(mb.match_album(None, album, len(items)))
try:
out.extend(mb.match_album(None, album, len(items)))
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Candidates from plugins.
out.extend(plugins.candidates(items))
@@ -124,7 +179,10 @@ def _item_candidates(item, artist, title):
# MusicBrainz candidates.
if artist and title:
out.extend(mb.match_track(artist, title))
try:
out.extend(mb.match_track(artist, title))
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Plugin candidates.
out.extend(plugins.item_candidates(item))

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,13 +8,15 @@
# 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.
"""Matches existing metadata with canonical information to identify
releases and tracks.
"""
from __future__ import division
import logging
import re
from lib.munkres import Munkres
@@ -33,6 +35,8 @@ ALBUM_WEIGHT = 3.0
TRACK_WEIGHT = 1.0
# The weight of a missing track.
MISSING_WEIGHT = 0.9
# The weight of an extra (umatched) track.
UNMATCHED_WEIGHT = 0.6
# These distances are components of the track distance (that is, they
# compete against each other but not ARTIST_WEIGHT and ALBUM_WEIGHT;
# the overall TRACK_WEIGHT does that).
@@ -112,7 +116,7 @@ def string_dist(str1, str2):
"""
str1 = str1.lower()
str2 = str2.lower()
# Don't penalize strings that move certain words to the end. For
# example, "the something" should be considered equal to
# "something, the".
@@ -126,7 +130,7 @@ def string_dist(str1, str2):
for pat, repl in SD_REPLACE:
str1 = re.sub(pat, repl, str1)
str2 = re.sub(pat, repl, str2)
# Change the weight for certain string portions matched by a set
# of regular expressions. We gradually change the strings and build
# up penalties associated with parts of the string that were
@@ -137,7 +141,7 @@ def string_dist(str1, str2):
# Get strings that drop the pattern.
case_str1 = re.sub(pat, '', str1)
case_str2 = re.sub(pat, '', str2)
if case_str1 != str1 or case_str2 != str2:
# If the pattern was present (i.e., it is deleted in the
# the current case), recalculate the distances for the
@@ -146,7 +150,7 @@ def string_dist(str1, str2):
case_delta = max(0.0, base_dist - case_dist)
if case_delta == 0.0:
continue
# Shift our baseline strings down (to avoid rematching the
# same part of the string) and add a scaled distance
# amount to the penalties.
@@ -155,7 +159,7 @@ def string_dist(str1, str2):
base_dist = case_dist
penalty += weight * case_delta
dist = base_dist + penalty
return dist
def current_metadata(items):
@@ -171,42 +175,33 @@ def current_metadata(items):
consensus[key] = (freq == len(values))
return likelies['artist'], likelies['album'], consensus['artist']
def order_items(items, trackinfo):
"""Orders the items based on how they match some canonical track
information. Returns a list of Items whose length is equal to the
length of ``trackinfo``. This always produces a result if the
numbers of items is at most the number of TrackInfo objects
(otherwise, returns None). In the case of a partial match, the
returned list may contain None in some positions.
def assign_items(items, tracks):
"""Given a list of Items and a list of TrackInfo objects, find the
best mapping between them. Returns a mapping from Items to TrackInfo
objects, a set of extra Items, and a set of extra TrackInfo
objects. These "extra" objects occur when there is an unequal number
of objects of the two types.
"""
# Make sure lengths match: If there is less items, it might just be that
# there is some tracks missing.
if len(items) > len(trackinfo):
return None
# Construct the cost matrix.
costs = []
for cur_item in items:
for item in items:
row = []
for i, canon_item in enumerate(trackinfo):
row.append(track_distance(cur_item, canon_item, i+1))
for i, track in enumerate(tracks):
row.append(track_distance(item, track))
costs.append(row)
# Find a minimum-cost bipartite matching.
matching = Munkres().compute(costs)
# Order items based on the matching.
ordered_items = [None]*len(trackinfo)
for cur_idx, canon_idx in matching:
ordered_items[canon_idx] = items[cur_idx]
return ordered_items
# Produce the output matching.
mapping = dict((items[i], tracks[j]) for (i, j) in matching)
extra_items = set(items) - set(mapping.keys())
extra_tracks = set(tracks) - set(mapping.values())
return mapping, extra_items, extra_tracks
def track_distance(item, track_info, track_index=None, incl_artist=False):
"""Determines the significance of a track metadata change. Returns
a float in [0.0,1.0]. `track_index` is the track number of the
`track_info` metadata set. If `track_index` is provided and
item.track is set, then these indices are used as a component of
the distance calculation. `incl_artist` indicates that a distance
def track_distance(item, track_info, incl_artist=False):
"""Determines the significance of a track metadata change. Returns a
float in [0.0,1.0]. `incl_artist` indicates that a distance
component should be included for the track artist (i.e., for
various-artist releases).
"""
@@ -221,7 +216,7 @@ def track_distance(item, track_info, track_index=None, incl_artist=False):
diff = min(diff, TRACK_LENGTH_MAX)
dist += (diff / TRACK_LENGTH_MAX) * TRACK_LENGTH_WEIGHT
dist_max += TRACK_LENGTH_WEIGHT
# Track title.
dist += string_dist(item.title, track_info.title) * TRACK_TITLE_WEIGHT
dist_max += TRACK_TITLE_WEIGHT
@@ -237,11 +232,11 @@ def track_distance(item, track_info, track_index=None, incl_artist=False):
dist_max += TRACK_ARTIST_WEIGHT
# Track index.
if track_index and item.track:
if item.track not in (track_index, track_info.medium_index):
if track_info.index and item.track:
if item.track not in (track_info.index, track_info.medium_index):
dist += TRACK_INDEX_WEIGHT
dist_max += TRACK_INDEX_WEIGHT
# MusicBrainz track ID.
if item.mb_trackid:
if item.mb_trackid != track_info.track_id:
@@ -255,35 +250,43 @@ def track_distance(item, track_info, track_index=None, incl_artist=False):
return dist / dist_max
def distance(items, album_info):
def distance(items, album_info, mapping):
"""Determines how "significant" an album metadata change would be.
Returns a float in [0.0,1.0]. The list of items must be ordered.
Returns a float in [0.0,1.0]. `album_info` is an AlbumInfo object
reflecting the album to be compared. `items` is a sequence of all
Item objects that will be matched (order is not important).
`mapping` is a dictionary mapping Items to TrackInfo objects; the
keys are a subset of `items` and the values are a subset of
`album_info.tracks`.
"""
cur_artist, cur_album, _ = current_metadata(items)
cur_artist = cur_artist or ''
cur_album = cur_album or ''
# These accumulate the possible distance components. The final
# distance will be dist/dist_max.
dist = 0.0
dist_max = 0.0
# Artist/album metadata.
if not album_info.va:
dist += string_dist(cur_artist, album_info.artist) * ARTIST_WEIGHT
dist_max += ARTIST_WEIGHT
dist += string_dist(cur_album, album_info.album) * ALBUM_WEIGHT
dist_max += ALBUM_WEIGHT
# Track distances.
for i, (item, track_info) in enumerate(zip(items, album_info.tracks)):
if item:
dist += track_distance(item, track_info, i+1, album_info.va) * \
TRACK_WEIGHT
dist_max += TRACK_WEIGHT
else:
dist += MISSING_WEIGHT
dist_max += MISSING_WEIGHT
# Matched track distances.
for item, track in mapping.iteritems():
dist += track_distance(item, track, album_info.va) * TRACK_WEIGHT
dist_max += TRACK_WEIGHT
# Extra and unmatched tracks.
for track in set(album_info.tracks) - set(mapping.values()):
dist += MISSING_WEIGHT
dist_max += MISSING_WEIGHT
for item in set(items) - set(mapping.keys()):
dist += UNMATCHED_WEIGHT
dist_max += UNMATCHED_WEIGHT
# Plugin distances.
plugin_d, plugin_dm = plugins.album_distance(items, album_info)
@@ -294,18 +297,19 @@ def distance(items, album_info):
if dist_max == 0.0:
return 0.0
else:
return dist/dist_max
return dist / dist_max
def match_by_id(items):
"""If the items are tagged with a MusicBrainz album ID, returns an
info dict for the corresponding album. Otherwise, returns None.
AlbumInfo object for the corresponding album. Otherwise, returns
None.
"""
# Is there a consensus on the MB album ID?
albumids = [item.mb_albumid for item in items if item.mb_albumid]
if not albumids:
log.debug('No album IDs found.')
return None
# If all album IDs are equal, look up the album.
if bool(reduce(lambda x,y: x if x==y else (), albumids)):
albumid = albumids[0]
@@ -314,21 +318,21 @@ def match_by_id(items):
else:
log.debug('No album ID consensus.')
return None
#fixme In the future, at the expense of performance, we could use
# other IDs (i.e., track and artist) in case the album tag isn't
# present, but that event seems very unlikely.
def recommendation(results):
"""Given a sorted list of result tuples, returns a recommendation
flag (RECOMMEND_STRONG, RECOMMEND_MEDIUM, RECOMMEND_NONE) based
on the results' distances.
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
recommendation flag (RECOMMEND_STRONG, RECOMMEND_MEDIUM,
RECOMMEND_NONE) based on the results' distances.
"""
if not results:
# No candidates: no recommendation.
rec = RECOMMEND_NONE
else:
min_dist = results[0][0]
min_dist = results[0].distance
if min_dist < STRONG_REC_THRESH:
# Strong recommendation level.
rec = RECOMMEND_STRONG
@@ -338,7 +342,7 @@ def recommendation(results):
elif min_dist <= MEDIUM_REC_THRESH:
# Medium recommendation level.
rec = RECOMMEND_MEDIUM
elif results[1][0] - min_dist >= REC_GAP_THRESH:
elif results[1].distance - min_dist >= REC_GAP_THRESH:
# Gap between first two candidates is large.
rec = RECOMMEND_MEDIUM
else:
@@ -346,36 +350,28 @@ def recommendation(results):
rec = RECOMMEND_NONE
return rec
def validate_candidate(items, tuple_dict, info):
def _add_candidate(items, results, info):
"""Given a candidate AlbumInfo object, attempt to add the candidate
to the output dictionary of result tuples. This involves checking
the track count, ordering the items, checking for duplicates, and
calculating the distance.
to the output dictionary of AlbumMatch objects. This involves
checking the track count, ordering the items, checking for
duplicates, and calculating the distance.
"""
log.debug('Candidate: %s - %s' % (info.artist, info.album))
# Don't duplicate.
if info.album_id in tuple_dict:
if info.album_id in results:
log.debug('Duplicate.')
return
# Make sure the album has the correct number of tracks.
if len(items) > len(info.tracks):
log.debug('Too many items to match: %i > %i.' %
(len(items), len(info.tracks)))
return
# Put items in order.
ordered = order_items(items, info.tracks)
if not ordered:
log.debug('Not orderable.')
return
# Find mapping between the items and the track info.
mapping, extra_items, extra_tracks = assign_items(items, info.tracks)
# Get the change distance.
dist = distance(ordered, info)
dist = distance(items, info, mapping)
log.debug('Success. Distance: %f' % dist)
tuple_dict[info.album_id] = dist, ordered, info
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
extra_items, extra_tracks)
def tag_album(items, timid=False, search_artist=None, search_album=None,
search_id=None):
@@ -383,10 +379,8 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
set of items comprised by an album. Returns everything relevant:
- The current artist.
- The current album.
- A list of (distance, items, info) tuples where info is a
dictionary containing the inferred tags and items is a
reordered version of the input items list. The candidates are
sorted by distance (i.e., best match first).
- A list of AlbumMatch objects. The candidates are sorted by
distance (i.e., best match first).
- A recommendation, one of RECOMMEND_STRONG, RECOMMEND_MEDIUM,
or RECOMMEND_NONE; indicating that the first candidate is
very likely, it is somewhat likely, or no conclusion could
@@ -398,11 +392,11 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
# Get current metadata.
cur_artist, cur_album, artist_consensus = current_metadata(items)
log.debug('Tagging %s - %s' % (cur_artist, cur_album))
# The output result (distance, AlbumInfo) tuples (keyed by MB album
# ID).
candidates = {}
# Try to find album indicated by MusicBrainz IDs.
if search_id:
log.debug('Searching for album ID: ' + search_id)
@@ -410,7 +404,7 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
else:
id_info = match_by_id(items)
if id_info:
validate_candidate(items, candidates, id_info)
_add_candidate(items, candidates, id_info)
rec = recommendation(candidates.values())
log.debug('Album ID match recommendation is ' + str(rec))
if candidates and not timid:
@@ -427,13 +421,13 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
return cur_artist, cur_album, candidates.values(), rec
else:
return cur_artist, cur_album, [], RECOMMEND_NONE
# Search terms.
if not (search_artist and search_album):
# No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album
log.debug(u'Search terms: %s - %s' % (search_artist, search_album))
# Is this album likely to be a "various artist" release?
va_likely = ((not artist_consensus) or
(search_artist.lower() in VA_ARTISTS) or
@@ -445,8 +439,8 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
va_likely)
log.debug(u'Evaluating %i candidates.' % len(search_cands))
for info in search_cands:
validate_candidate(items, candidates, info)
_add_candidate(items, candidates, info)
# Sort and get the recommendation.
candidates = sorted(candidates.itervalues())
rec = recommendation(candidates)
@@ -455,10 +449,10 @@ def tag_album(items, timid=False, search_artist=None, search_album=None,
def tag_item(item, timid=False, search_artist=None, search_title=None,
search_id=None):
"""Attempts to find metadata for a single track. Returns a
`(candidates, recommendation)` pair where `candidates` is a list
of `(distance, track_info)` pairs. `search_artist` and
`search_title` may be used to override the current metadata for
the purposes of the MusicBrainz title; likewise `search_id`.
`(candidates, recommendation)` pair where `candidates` is a list of
TrackMatch objects. `search_artist` and `search_title` may be used
to override the current metadata for the purposes of the MusicBrainz
title; likewise `search_id`.
"""
# Holds candidates found so far: keys are MBIDs; values are
# (distance, TrackInfo) pairs.
@@ -471,7 +465,8 @@ def tag_item(item, timid=False, search_artist=None, search_title=None,
track_info = hooks._track_for_id(trackid)
if track_info:
dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = (dist, track_info)
candidates[track_info.track_id] = \
hooks.TrackMatch(dist, track_info)
# If this is a good match, then don't keep searching.
rec = recommendation(candidates.values())
if rec == RECOMMEND_STRONG and not timid:
@@ -484,7 +479,7 @@ def tag_item(item, timid=False, search_artist=None, search_title=None,
return candidates.values(), rec
else:
return [], RECOMMEND_NONE
# Search terms.
if not (search_artist and search_title):
search_artist, search_title = item.artist, item.title
@@ -493,7 +488,7 @@ def tag_item(item, timid=False, search_artist=None, search_title=None,
# Get and evaluate candidate metadata.
for track_info in hooks._item_candidates(item, search_artist, search_title):
dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = (dist, track_info)
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
# Sort by distance and return with recommendation.
log.debug('Found %i candidates.' % len(candidates))

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,7 +8,7 @@
# 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.
@@ -16,9 +16,11 @@
"""
import logging
import lib.musicbrainzngs as musicbrainzngs
import traceback
import lib.beets.autotag.hooks
import lib.beets
from lib.beets import util
SEARCH_LIMIT = 5
VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
@@ -26,8 +28,18 @@ VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
musicbrainzngs.set_useragent('beets', lib.beets.__version__,
'http://beets.radbox.org/')
class ServerBusyError(Exception): pass
class BadResponseError(Exception): pass
class MusicBrainzAPIError(util.HumanReadableException):
"""An error while talking to MusicBrainz. The `query` field is the
parameter to the action and may have any type.
"""
def __init__(self, reason, verb, query, tb=None):
self.query = query
super(MusicBrainzAPIError, self).__init__(reason, verb, tb)
def get_message(self):
return u'"{0}" in {1} with query {2}'.format(
self._reasonstr(), self.verb, repr(self.query)
)
log = logging.getLogger('beets')
@@ -45,22 +57,64 @@ else:
_mb_release_search = musicbrainzngs.search_releases
_mb_recording_search = musicbrainzngs.search_recordings
def track_info(recording, medium=None, medium_index=None):
def _flatten_artist_credit(credit):
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
credit.
"""
artist_parts = []
artist_sort_parts = []
artist_credit_parts = []
for el in credit:
if isinstance(el, basestring):
# Join phrase.
artist_parts.append(el)
artist_credit_parts.append(el)
artist_sort_parts.append(el)
else:
# An artist.
cur_artist_name = el['artist']['name']
artist_parts.append(cur_artist_name)
# Artist sort name.
if 'sort-name' in el['artist']:
artist_sort_parts.append(el['artist']['sort-name'])
else:
artist_sort_parts.append(cur_artist_name)
# Artist credit.
if 'name' in el:
artist_credit_parts.append(el['name'])
else:
artist_credit_parts.append(cur_artist_name)
return (
''.join(artist_parts),
''.join(artist_sort_parts),
''.join(artist_credit_parts),
)
def track_info(recording, index=None, medium=None, medium_index=None):
"""Translates a MusicBrainz recording result dictionary into a beets
``TrackInfo`` object. ``medium_index``, if provided, is the track's
index (1-based) on its medium.
``TrackInfo`` object. Three parameters are optional and are used
only for tracks that appear on releases (non-singletons): ``index``,
the overall track number; ``medium``, the disc number;
``medium_index``, the track's index on its medium. Each number is a
1-based index.
"""
info = lib.beets.autotag.hooks.TrackInfo(recording['title'],
recording['id'],
index=index,
medium=medium,
medium_index=medium_index)
# Get the name of the track artist.
if recording.get('artist-credit-phrase'):
info.artist = recording['artist-credit-phrase']
if recording.get('artist-credit'):
# Get the artist names.
info.artist, info.artist_sort, info.artist_credit = \
_flatten_artist_credit(recording['artist-credit'])
# Get the ID of the first artist.
if 'artist-credit' in recording:
# Get the ID and sort name of the first artist.
artist = recording['artist-credit'][0]['artist']
info.artist_id = artist['id']
@@ -84,25 +138,25 @@ def album_info(release):
AlbumInfo object containing the interesting data about that release.
"""
# Get artist name using join phrases.
artist_parts = []
for el in release['artist-credit']:
if isinstance(el, basestring):
artist_parts.append(el)
else:
artist_parts.append(el['artist']['name'])
artist_name = ''.join(artist_parts)
artist_name, artist_sort_name, artist_credit_name = \
_flatten_artist_credit(release['artist-credit'])
# Basic info.
track_infos = []
index = 0
for medium in release['medium-list']:
disctitle = medium.get('title')
for track in medium['track-list']:
index += 1
ti = track_info(track['recording'],
index,
int(medium['position']),
int(track['position']))
if track.get('title'):
# Track title may be distinct from underling recording
# title.
ti.title = track['title']
ti.disctitle = disctitle
track_infos.append(ti)
info = lib.beets.autotag.hooks.AlbumInfo(
release['title'],
@@ -111,10 +165,15 @@ def album_info(release):
release['artist-credit'][0]['artist']['id'],
track_infos,
mediums=len(release['medium-list']),
artist_sort=artist_sort_name,
artist_credit=artist_credit_name,
)
info.va = info.artist_id == VARIOUS_ARTISTS_ID
if 'asin' in release:
info.asin = release['asin']
info.asin = release.get('asin')
info.releasegroup_id = release['release-group']['id']
info.albumdisambig = release['release-group'].get('disambiguation')
info.country = release.get('country')
info.albumstatus = release.get('status')
# Release type not always populated.
if 'type' in release['release-group']:
@@ -137,12 +196,25 @@ def album_info(release):
label = label_info['label']['name']
if label != '[no label]':
info.label = label
info.catalognum = label_info.get('catalog-number')
# Text representation data.
if release.get('text-representation'):
rep = release['text-representation']
info.script = rep.get('script')
info.language = rep.get('language')
# Media (format).
if release['medium-list']:
first_medium = release['medium-list'][0]
info.media = first_medium.get('format')
return info
def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
"""Searches for a single album ("release" in MusicBrainz parlance)
and returns an iterator over AlbumInfo objects.
and returns an iterator over AlbumInfo objects. May raise a
MusicBrainzAPIError.
The query consists of an artist name, an album name, and,
optionally, a number of tracks on the album.
@@ -161,7 +233,11 @@ def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
if not any(criteria.itervalues()):
return
res = _mb_release_search(limit=limit, **criteria)
try:
res = _mb_release_search(limit=limit, **criteria)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'release search', criteria,
traceback.format_exc())
for release in res['release-list']:
# The search result is missing some data (namely, the tracks),
# so we just use the ID and fetch the rest of the information.
@@ -171,7 +247,7 @@ def match_album(artist, album, tracks=None, limit=SEARCH_LIMIT):
def match_track(artist, title, limit=SEARCH_LIMIT):
"""Searches for a single track and returns an iterable of TrackInfo
objects.
objects. May raise a MusicBrainzAPIError.
"""
criteria = {
'artist': artist.lower(),
@@ -181,28 +257,39 @@ def match_track(artist, title, limit=SEARCH_LIMIT):
if not any(criteria.itervalues()):
return
res = _mb_recording_search(limit=limit, **criteria)
try:
res = _mb_recording_search(limit=limit, **criteria)
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'recording search', criteria,
traceback.format_exc())
for recording in res['recording-list']:
yield track_info(recording)
def album_for_id(albumid):
"""Fetches an album by its MusicBrainz ID and returns an AlbumInfo
object or None if the album is not found.
object or None if the album is not found. May raise a
MusicBrainzAPIError.
"""
try:
res = musicbrainzngs.get_release_by_id(albumid, RELEASE_INCLUDES)
except musicbrainzngs.ResponseError:
log.debug('Album ID match failed.')
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'get release by ID', albumid,
traceback.format_exc())
return album_info(res['release'])
def track_for_id(trackid):
"""Fetches a track by its MusicBrainz ID. Returns a TrackInfo object
or None if no track is found.
or None if no track is found. May raise a MusicBrainzAPIError.
"""
try:
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
except musicbrainzngs.ResponseError:
log.debug('Track ID match failed.')
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'get recording by ID', trackid,
traceback.format_exc())
return track_info(res['recording'])

View File

@@ -8,14 +8,15 @@
# 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.
"""Provides the basic, interface-agnostic workflow for importing and
autotagging music files.
"""
from __future__ import with_statement # Python 2.5
from __future__ import print_function
import os
import logging
import pickle
@@ -23,7 +24,6 @@ from collections import defaultdict
from lib.beets import autotag
from lib.beets import library
import lib.beets.autotag.art
from lib.beets import plugins
from lib.beets import util
from lib.beets.util import pipeline
@@ -56,7 +56,7 @@ def tag_log(logfile, status, path):
reflect the reason the album couldn't be tagged.
"""
if logfile:
print >>logfile, '%s %s' % (status, path)
print('{0} {1}'.format(status, path), file=logfile)
logfile.flush()
def log_choice(config, task, duplicate=False):
@@ -80,23 +80,6 @@ def log_choice(config, task, duplicate=False):
elif task.choice_flag is action.SKIP:
tag_log(config.logfile, 'skip', path)
def _reopen_lib(lib):
"""Because of limitations in SQLite, a given Library is bound to
the thread in which it was created. This function reopens Library
objects so that they can be used from separate threads.
"""
if isinstance(lib, library.Library):
return library.Library(
lib.path,
lib.directory,
lib.path_formats,
lib.art_filename,
lib.timeout,
lib.replacements,
)
else:
return lib
def _duplicate_check(lib, task):
"""Check whether an album already exists in the library. Returns a
list of Album objects (empty if no duplicates are found).
@@ -193,7 +176,7 @@ def _save_state(state):
try:
with open(STATE_FILE, 'w') as f:
pickle.dump(state, f)
except IOError, exc:
except IOError as exc:
log.error(u'state file could not be written: %s' % unicode(exc))
@@ -259,11 +242,11 @@ class ImportConfig(object):
then never touched again.
"""
_fields = ['lib', 'paths', 'resume', 'logfile', 'color', 'quiet',
'quiet_fallback', 'copy', 'write', 'art', 'delete',
'quiet_fallback', 'copy', 'move', 'write', 'delete',
'choose_match_func', 'should_resume_func', 'threaded',
'autot', 'singletons', 'timid', 'choose_item_func',
'query', 'incremental', 'ignore',
'resolve_duplicate_func']
'resolve_duplicate_func', 'per_disc_numbering']
def __init__(self, **kwargs):
for slot in self._fields:
setattr(self, slot, kwargs[slot])
@@ -283,6 +266,14 @@ class ImportConfig(object):
self.resume = False
self.incremental = False
# Copy and move are mutually exclusive.
if self.move:
self.copy = False
# Only delete when copying.
if not self.copy:
self.delete = False
# The importer task class.
@@ -296,6 +287,7 @@ class ImportTask(object):
self.items = items
self.sentinel = False
self.remove_duplicates = False
self.is_album = True
@classmethod
def done_sentinel(cls, toppath):
@@ -324,56 +316,50 @@ class ImportTask(object):
obj.is_album = False
return obj
def set_match(self, cur_artist, cur_album, candidates, rec):
def set_candidates(self, cur_artist, cur_album, candidates, rec):
"""Sets the candidates for this album matched by the
`autotag.tag_album` method.
"""
assert self.is_album
assert not self.sentinel
self.cur_artist = cur_artist
self.cur_album = cur_album
self.candidates = candidates
self.rec = rec
self.is_album = True
def set_null_match(self):
def set_null_candidates(self):
"""Set the candidates to indicate no album match was found.
"""
self.set_match(None, None, None, None)
self.cur_artist = None
self.cur_album = None
self.candidates = None
self.rec = None
def set_item_match(self, candidates, rec):
def set_item_candidates(self, candidates, rec):
"""Set the match for a single-item task."""
assert not self.is_album
assert self.item is not None
self.item_match = (candidates, rec)
def set_null_item_match(self):
"""For single-item tasks, mark the item as having no matches.
"""
assert not self.is_album
assert self.item is not None
self.item_match = None
self.candidates = candidates
self.rec = rec
def set_choice(self, choice):
"""Given either an (info, items) tuple or an action constant,
indicates that an action has been selected by the user (or
automatically).
"""Given an AlbumMatch or TrackMatch object or an action constant,
indicates that an action has been selected for this task.
"""
assert not self.sentinel
# Not part of the task structure:
assert choice not in (action.MANUAL, action.MANUAL_ID)
assert choice != action.APPLY # Only used internally.
assert choice != action.APPLY # Only used internally.
if choice in (action.SKIP, action.ASIS, action.TRACKS):
self.choice_flag = choice
self.info = None
self.match = None
else:
assert not isinstance(choice, action)
if self.is_album:
info, items = choice
self.items = items # Reordered items list.
assert isinstance(choice, autotag.AlbumMatch)
else:
info = choice
self.info = info
self.choice_flag = action.APPLY # Implicit choice.
assert isinstance(choice, autotag.TrackMatch)
self.choice_flag = action.APPLY # Implicit choice.
self.match = choice
def save_progress(self):
"""Updates the progress state to indicate that this album has
@@ -393,7 +379,9 @@ class ImportTask(object):
if self.sentinel or self.is_album:
history_add(self.path)
# Logical decisions.
def should_write_tags(self):
"""Should new info be written to the files' metadata?"""
if self.choice_flag == action.APPLY:
@@ -402,16 +390,16 @@ class ImportTask(object):
return False
else:
assert False
def should_fetch_art(self):
"""Should album art be downloaded for this album?"""
return self.should_write_tags() and self.is_album
def should_skip(self):
"""After a choice has been made, returns True if this is a
sentinel or it has been marked for skipping.
"""
return self.sentinel or self.choice_flag == action.SKIP
# Useful data.
# Convenient data.
def chosen_ident(self):
"""Returns identifying metadata about the current choice. For
albums, this is an (artist, album) pair. For items, this is
@@ -424,12 +412,41 @@ class ImportTask(object):
if self.choice_flag is action.ASIS:
return (self.cur_artist, self.cur_album)
elif self.choice_flag is action.APPLY:
return (self.info.artist, self.info.album)
return (self.match.info.artist, self.match.info.album)
else:
if self.choice_flag is action.ASIS:
return (self.item.artist, self.item.title)
elif self.choice_flag is action.APPLY:
return (self.info.artist, self.info.title)
return (self.match.info.artist, self.match.info.title)
def imported_items(self):
"""Return a list of Items that should be added to the library.
If this is an album task, return the list of items in the
selected match or everything if the choice is ASIS. If this is a
singleton task, return a list containing the item.
"""
if self.is_album:
if self.choice_flag == action.ASIS:
return list(self.items)
elif self.choice_flag == action.APPLY:
return self.match.mapping.keys()
else:
assert False
else:
return [self.item]
# Utilities.
def prune(self, filename):
"""Prune any empty directories above the given file. If this
task has no `toppath` or the file path provided is not within
the `toppath`, then this function has no effect. Similarly, if
the file still exists, no pruning is performed, so it's safe to
call when the file in question may not have been removed.
"""
if self.toppath and not os.path.exists(filename):
util.prune_dirs(os.path.dirname(filename), self.toppath)
# Full-album pipeline stages.
@@ -464,14 +481,14 @@ def read_tasks(config):
if config.incremental:
incremental_skipped = 0
history_dirs = history_get()
for toppath in config.paths:
# Check whether the path is to a file.
if config.singletons and not os.path.isdir(syspath(toppath)):
item = library.Item.from_path(toppath)
yield ImportTask.item_task(item)
continue
# Produce paths under this directory.
if progress:
resume_dir = resume_dirs.get(toppath)
@@ -513,16 +530,14 @@ def query_tasks(config):
Instead of finding files from the filesystem, a query is used to
match items from the library.
"""
lib = _reopen_lib(config.lib)
if config.singletons:
# Search for items.
for item in lib.items(config.query):
for item in config.lib.items(config.query):
yield ImportTask.item_task(item)
else:
# Search for albums.
for album in lib.albums(config.query):
for album in config.lib.albums(config.query):
log.debug('yielding album %i: %s - %s' %
(album.id, album.albumartist, album.album))
items = list(album.items())
@@ -540,11 +555,13 @@ def initial_lookup(config):
if task.sentinel:
continue
plugins.send('import_task_start', task=task, config=config)
log.debug('Looking up: %s' % task.path)
try:
task.set_match(*autotag.tag_album(task.items, config.timid))
task.set_candidates(*autotag.tag_album(task.items, config.timid))
except autotag.AutotagError:
task.set_null_match()
task.set_null_candidates()
def user_query(config):
"""A coroutine for interfacing with the user about the tagging
@@ -552,18 +569,18 @@ def user_query(config):
a file-like object for logging the import process. The coroutine
accepts and yields ImportTask objects.
"""
lib = _reopen_lib(config.lib)
recent = set()
task = None
while True:
task = yield task
if task.sentinel:
continue
# Ask the user for a choice.
choice = config.choose_match_func(task, config)
task.set_choice(choice)
log_choice(config, task)
plugins.send('import_task_choice', task=task, config=config)
# As-tracks: transition to singleton workflow.
if choice is action.TRACKS:
@@ -577,7 +594,7 @@ def user_query(config):
while True:
item_task = yield
item_tasks.append(item_task)
ipl = pipeline.Pipeline((emitter(), item_lookup(config),
ipl = pipeline.Pipeline((emitter(), item_lookup(config),
item_query(config), collector()))
ipl.run_sequential()
task = pipeline.multiple(item_tasks)
@@ -589,7 +606,7 @@ def user_query(config):
# The "recent" set keeps track of identifiers for recently
# imported albums -- those that haven't reached the database
# yet.
if ident in recent or _duplicate_check(lib, task):
if ident in recent or _duplicate_check(config.lib, task):
config.resolve_duplicate_func(task, config)
log_choice(config, task, True)
recent.add(ident)
@@ -608,21 +625,20 @@ def show_progress(config):
log.info(task.path)
# Behave as if ASIS were selected.
task.set_null_match()
task.set_null_candidates()
task.set_choice(action.ASIS)
def apply_choices(config):
"""A coroutine for applying changes to albums during the autotag
process.
"""A coroutine for applying changes to albums and singletons during
the autotag process.
"""
lib = _reopen_lib(config.lib)
task = None
while True:
while True:
task = yield task
if task.should_skip():
continue
items = [i for i in task.items if i] if task.is_album else [task.item]
items = task.imported_items()
# Clear IDs in case the items are being re-tagged.
for item in items:
item.id = None
@@ -631,9 +647,13 @@ def apply_choices(config):
# Change metadata.
if task.should_write_tags():
if task.is_album:
autotag.apply_metadata(task.items, task.info)
autotag.apply_metadata(
task.match.info, task.match.mapping,
per_disc_numbering=config.per_disc_numbering
)
else:
autotag.apply_item_metadata(task.item, task.info)
autotag.apply_item_metadata(task.item, task.match.info)
plugins.send('import_task_apply', config=config, task=task)
# Infer album-level fields.
if task.is_album:
@@ -642,14 +662,14 @@ def apply_choices(config):
# Find existing item entries that these are replacing (for
# re-imports). Old album structures are automatically cleaned up
# when the last item is removed.
replaced_items = defaultdict(list)
task.replaced_items = defaultdict(list)
for item in items:
dup_items = lib.items(library.MatchQuery('path', item.path))
dup_items = config.lib.items(library.MatchQuery('path', item.path))
for dup_item in dup_items:
replaced_items[item].append(dup_item)
task.replaced_items[item].append(dup_item)
log.debug('replacing item %i: %s' %
(dup_item.id, displayable_path(item.path)))
log.debug('%i of %i items replaced' % (len(replaced_items),
log.debug('%i of %i items replaced' % (len(task.replaced_items),
len(items)))
# Find old items that should be replaced as part of a duplicate
@@ -657,93 +677,111 @@ def apply_choices(config):
duplicate_items = []
if task.remove_duplicates:
if task.is_album:
for album in _duplicate_check(lib, task):
for album in _duplicate_check(config.lib, task):
duplicate_items += album.items()
else:
duplicate_items = _item_duplicate_check(lib, task)
duplicate_items = _item_duplicate_check(config.lib, task)
log.debug('removing %i old duplicated items' %
len(duplicate_items))
# Delete duplicate files that are located inside the library
# directory.
for duplicate_path in [i.path for i in duplicate_items]:
if lib.directory in util.ancestry(duplicate_path):
if config.lib.directory in util.ancestry(duplicate_path):
log.debug(u'deleting replaced duplicate %s' %
util.displayable_path(duplicate_path))
util.soft_remove(duplicate_path)
util.remove(duplicate_path)
util.prune_dirs(os.path.dirname(duplicate_path),
lib.directory)
config.lib.directory)
# Move/copy files.
task.old_paths = [item.path for item in items]
for item in items:
if config.copy:
# If we're replacing an item, then move rather than
# copying.
old_path = item.path
do_copy = not bool(replaced_items[item])
lib.move(item, do_copy, task.is_album)
if not do_copy:
# If we moved the item, remove the now-nonexistent
# file from old_paths.
task.old_paths.remove(old_path)
if config.write and task.should_write_tags():
item.write()
# Add items to library. We consolidate this at the end to avoid
# locking while we do the copying and tag updates.
try:
# Add items -- before path changes -- to the library. We add the
# items now (rather than at the end) so that album structures
# are in place before calls to destination().
with config.lib.transaction():
# Remove old items.
for replaced in replaced_items.itervalues():
for replaced in task.replaced_items.itervalues():
for item in replaced:
lib.remove(item)
config.lib.remove(item)
for item in duplicate_items:
lib.remove(item)
config.lib.remove(item)
# Add new ones.
if task.is_album:
# Add an album.
album = lib.add_album(items)
album = config.lib.add_album(items)
task.album_id = album.id
else:
# Add tracks.
for item in items:
lib.add(item)
finally:
lib.save()
config.lib.add(item)
def fetch_art(config):
"""A coroutine that fetches and applies album art for albums where
appropriate.
def plugin_stage(config, func):
"""A coroutine (pipeline stage) that calls the given function with
each non-skipped import task. These stages occur between applying
metadata changes and moving/copying/writing files.
"""
task = None
while True:
task = yield task
if task.should_skip():
continue
func(config, task)
def manipulate_files(config):
"""A coroutine (pipeline stage) that performs necessary file
manipulations *after* items have been added to the library.
"""
lib = _reopen_lib(config.lib)
task = None
while True:
task = yield task
if task.should_skip():
continue
if task.should_fetch_art():
artpath = lib.beets.autotag.art.art_for_album(task.info, task.path)
# Move/copy files.
items = task.imported_items()
task.old_paths = [item.path for item in items] # For deletion.
for item in items:
if config.move:
# Just move the file.
old_path = item.path
config.lib.move(item, False)
task.prune(old_path)
elif config.copy:
# If it's a reimport, move in-library files and copy
# out-of-library files. Otherwise, copy and keep track
# of the old path.
old_path = item.path
if task.replaced_items[item]:
# This is a reimport. Move in-library files and copy
# out-of-library files.
if config.lib.directory in util.ancestry(old_path):
config.lib.move(item, False)
# We moved the item, so remove the
# now-nonexistent file from old_paths.
task.old_paths.remove(old_path)
else:
config.lib.move(item, True)
else:
# A normal import. Just copy files and keep track of
# old paths.
config.lib.move(item, True)
# Save the art if any was found.
if artpath:
try:
album = lib.get_album(task.album_id)
album.set_art(artpath)
if config.delete and not util.samefile(artpath,
album.artpath):
# Delete the original file after it's imported.
os.remove(artpath)
finally:
lib.save(False)
if config.write and task.should_write_tags():
item.write()
# Save new paths.
with config.lib.transaction():
for item in items:
config.lib.store(item)
# Plugin event.
plugins.send('import_task_files', config=config, task=task)
def finalize(config):
"""A coroutine that finishes up importer tasks. In particular, the
coroutine sends plugin events, deletes old files, and saves
progress. This is a "terminal" coroutine (it yields None).
"""
lib = _reopen_lib(config.lib)
while True:
task = yield
if task.should_skip():
@@ -753,15 +791,17 @@ def finalize(config):
task.save_history()
continue
items = [i for i in task.items if i] if task.is_album else [task.item]
items = task.imported_items()
# Announce that we've added an album.
if task.is_album:
album = lib.get_album(task.album_id)
plugins.send('album_imported', lib=lib, album=album, config=config)
album = config.lib.get_album(task.album_id)
plugins.send('album_imported',
lib=config.lib, album=album, config=config)
else:
for item in items:
plugins.send('item_imported', lib=lib, item=item, config=config)
plugins.send('item_imported',
lib=config.lib, item=item, config=config)
# Finally, delete old files.
if config.copy and config.delete:
@@ -769,11 +809,8 @@ def finalize(config):
for old_path in task.old_paths:
# Only delete files that were actually copied.
if old_path not in new_paths:
os.remove(syspath(old_path))
# Clean up directory if it is emptied.
if task.toppath:
util.prune_dirs(os.path.dirname(old_path),
task.toppath)
util.remove(syspath(old_path), False)
task.prune(old_path)
# Update progress.
if config.resume is not False:
@@ -794,13 +831,14 @@ def item_lookup(config):
if task.sentinel:
continue
task.set_item_match(*autotag.tag_item(task.item, config.timid))
plugins.send('import_task_start', task=task, config=config)
task.set_item_candidates(*autotag.tag_item(task.item, config.timid))
def item_query(config):
"""A coroutine that queries the user for input on single-item
lookups.
"""
lib = _reopen_lib(config.lib)
task = None
recent = set()
while True:
@@ -811,11 +849,12 @@ def item_query(config):
choice = config.choose_item_func(task, config)
task.set_choice(choice)
log_choice(config, task)
plugins.send('import_task_choice', task=task, config=config)
# Duplicate check.
if task.choice_flag in (action.ASIS, action.APPLY):
ident = task.chosen_ident()
if ident in recent or _item_duplicate_check(lib, task):
if ident in recent or _item_duplicate_check(config.lib, task):
config.resolve_duplicate_func(task, config)
log_choice(config, task, True)
recent.add(ident)
@@ -832,7 +871,7 @@ def item_progress(config):
continue
log.info(displayable_path(task.item.path))
task.set_null_item_match()
task.set_null_candidates()
task.set_choice(action.ASIS)
@@ -843,7 +882,7 @@ def run_import(**kwargs):
ImportConfig.
"""
config = ImportConfig(**kwargs)
# Set up the pipeline.
if config.query is None:
stages = [read_tasks(config)]
@@ -864,8 +903,9 @@ def run_import(**kwargs):
# When not autotagging, just display progress.
stages += [show_progress(config)]
stages += [apply_choices(config)]
if config.art:
stages += [fetch_art(config)]
for stage_func in plugins.import_stages():
stages.append(plugin_stage(config, stage_func))
stages += [manipulate_files(config)]
stages += [finalize(config)]
pl = pipeline.Pipeline(stages)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,7 +8,7 @@
# 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.
@@ -106,7 +106,7 @@ def _safe_cast(out_type, val):
else:
try:
# Should work for strings, bools, ints:
return bool(int(val))
return bool(int(val))
except ValueError:
return False
@@ -181,7 +181,6 @@ class Packed(object):
"""Makes a packed list of values subscriptable. To access the packed
output after making changes, use packed_thing.items.
"""
def __init__(self, items, packstyle, none_val=0, out_type=int):
"""Create a Packed object for subscripting the packed values in
items. The items are packed using packstyle, which is a value
@@ -193,11 +192,11 @@ class Packed(object):
self.packstyle = packstyle
self.none_val = none_val
self.out_type = out_type
def __getitem__(self, index):
if not isinstance(index, int):
raise TypeError('index must be an integer')
if self.items is None:
return self.none_val
@@ -206,7 +205,7 @@ class Packed(object):
# Remove time information from dates. Usually delimited by
# a "T" or a space.
items = re.sub(r'[Tt ].*$', '', unicode(items))
# transform from a string packing into a list we can index into
if self.packstyle == packing.SLASHED:
seq = unicode(items).split('/')
@@ -214,17 +213,17 @@ class Packed(object):
seq = unicode(items).split('-')
elif self.packstyle == packing.TUPLE:
seq = items # tuple: items is already indexable
try:
out = seq[index]
except:
out = None
if out is None or out == self.none_val or out == '':
return _safe_cast(self.out_type, self.none_val)
else:
return _safe_cast(self.out_type, out)
def __setitem__(self, index, value):
if self.packstyle in (packing.SLASHED, packing.TUPLE):
# SLASHED and TUPLE are always two-item packings
@@ -232,7 +231,7 @@ class Packed(object):
else:
# DATE can have up to three fields
length = 3
# make a list of the items we'll pack
new_items = []
for i in range(length):
@@ -241,7 +240,7 @@ class Packed(object):
else:
next_item = self[i]
new_items.append(next_item)
if self.packstyle == packing.DATE:
# Truncate the items wherever we reach an invalid (none)
# entry. This prevents dates like 2008-00-05.
@@ -249,7 +248,7 @@ class Packed(object):
if item == self.none_val or item is None:
del(new_items[i:]) # truncate
break
if self.packstyle == packing.SLASHED:
self.items = '/'.join(map(unicode, new_items))
elif self.packstyle == packing.DATE:
@@ -260,7 +259,7 @@ class Packed(object):
self.items = '-'.join(elems)
elif self.packstyle == packing.TUPLE:
self.items = new_items
# The field itself.
@@ -270,7 +269,6 @@ class MediaField(object):
can be unicode, int, or bool. id3, mp4, and flac are StorageStyle
instances parameterizing the field's storage for each type.
"""
def __init__(self, out_type = unicode, **kwargs):
"""Creates a new MediaField.
- out_type: The field's semantic (exterior) type.
@@ -283,7 +281,7 @@ class MediaField(object):
raise TypeError('MediaField constructor must have keyword '
'arguments mp3, mp4, and etc')
self.styles = kwargs
def _fetchdata(self, obj, style):
"""Get the value associated with this descriptor's field stored
with the given StorageStyle. Unwraps from a list if necessary.
@@ -305,18 +303,18 @@ class MediaField(object):
frame = obj.mgfile[style.key]
except KeyError:
return None
entry = getattr(frame, style.id3_frame_field)
else: # Not MP3.
else: # Not MP3.
try:
entry = obj.mgfile[style.key]
except KeyError:
return None
# possibly index the list
# Possibly index the list.
if style.list_elem:
if entry: # List must have at least one value.
if entry: # List must have at least one value.
# Handle Mutagen bugs when reading values (#356).
try:
return entry[0]
@@ -328,21 +326,23 @@ class MediaField(object):
return None
else:
return entry
def _storedata(self, obj, val, style):
"""Store val for this descriptor's field in the tag dictionary
according to the provided StorageStyle. Store it as a
single-item list if necessary.
"""
# wrap as a list if necessary
if style.list_elem: out = [val]
else: out = val
# Wrap as a list if necessary.
if style.list_elem:
out = [val]
else:
out = val
if obj.type == 'mp3':
# Try to match on "desc" field.
if style.id3_desc is not None:
frames = obj.mgfile.tags.getall(style.key)
# try modifying in place
found = False
for frame in frames:
@@ -350,56 +350,56 @@ class MediaField(object):
setattr(frame, style.id3_frame_field, out)
found = True
break
# need to make a new frame?
if not found:
assert isinstance(style.id3_frame_field, str) # Keyword.
assert isinstance(style.id3_frame_field, str) # Keyword.
frame = lib.mutagen.id3.Frames[style.key](
encoding=3,
desc=style.id3_desc,
**{style.id3_frame_field: val}
)
obj.mgfile.tags.add(frame)
# Try to match on "owner" field.
elif style.key.startswith('UFID:'):
owner = style.key.split(':', 1)[1]
frames = obj.mgfile.tags.getall(style.key)
for frame in frames:
# Replace existing frame data.
if frame.owner == owner:
setattr(frame, style.id3_frame_field, val)
else:
# New frame.
assert isinstance(style.id3_frame_field, str) # Keyword.
frame = lib.mutagen.id3.UFID(owner=owner,
assert isinstance(style.id3_frame_field, str) # Keyword.
frame = lib.mutagen.id3.UFID(owner=owner,
**{style.id3_frame_field: val})
obj.mgfile.tags.setall('UFID', [frame])
# Just replace based on key.
else:
assert isinstance(style.id3_frame_field, str) # Keyword.
assert isinstance(style.id3_frame_field, str) # Keyword.
frame = lib.mutagen.id3.Frames[style.key](encoding = 3,
**{style.id3_frame_field: val})
obj.mgfile.tags.setall(style.key, [frame])
else: # Not MP3.
obj.mgfile[style.key] = out
def _styles(self, obj):
if obj.type in ('mp3', 'mp4'):
styles = self.styles[obj.type]
else:
styles = self.styles['etc'] # sane styles
styles = self.styles['etc'] # Sane styles.
# Make sure we always return a list of styles, even when given
# a single style for convenience.
if isinstance(styles, StorageStyle):
return [styles]
else:
return styles
def __get__(self, obj, owner):
"""Retrieve the value of this metadata field.
"""
@@ -413,7 +413,7 @@ class MediaField(object):
out = self._fetchdata(obj, style)
if out:
break
if style.packing:
out = Packed(out, style.packing)[style.pack_pos]
@@ -421,9 +421,9 @@ class MediaField(object):
if obj.type == 'mp4' and style.key.startswith('----:') and \
isinstance(out, str):
out = out.decode('utf8')
return _safe_cast(self.out_type, out)
def __set__(self, obj, val):
"""Set the value of this metadata field.
"""
@@ -433,15 +433,15 @@ class MediaField(object):
return
for style in styles:
if style.packing:
p = Packed(self._fetchdata(obj, style), style.packing)
p[style.pack_pos] = val
out = p.items
else: # unicode, integer, or boolean scalar
out = val
# deal with Nones according to abstract type if present
if out is None:
if self.out_type == int:
@@ -451,7 +451,7 @@ class MediaField(object):
elif self.out_type == unicode:
out = u''
# We trust that packed values are handled above.
# Convert to correct storage type (irrelevant for
# packed values).
if style.as_type == unicode:
@@ -472,7 +472,7 @@ class MediaField(object):
out = int(out)
elif style.as_type in (bool, str):
out = style.as_type(out)
# MPEG-4 "freeform" (----) frames must be encoded as UTF-8
# byte strings.
if obj.type == 'mp4' and style.key.startswith('----:') and \
@@ -494,7 +494,7 @@ class CompositeDateField(object):
self.year_field = year_field
self.month_field = month_field
self.day_field = day_field
def __get__(self, obj, owner):
"""Return a datetime.date object whose components indicating the
smallest valid date whose components are at least as large as
@@ -509,9 +509,9 @@ class CompositeDateField(object):
max(self.month_field.__get__(obj, owner), 1),
max(self.day_field.__get__(obj, owner), 1)
)
except ValueError: # Out of range values.
except ValueError: # Out of range values.
return datetime.date.min
def __set__(self, obj, val):
"""Set the year, month, and day fields to match the components of
the provided datetime.date object.
@@ -676,13 +676,12 @@ class MediaFile(object):
"""Represents a multimedia file on disk and provides access to its
metadata.
"""
def __init__(self, path):
"""Constructs a new MediaFile reflecting the file at path. May
throw UnreadableFileError.
"""
self.path = path
unreadable_exc = (
lib.mutagen.mp3.HeaderNotFoundError,
lib.mutagen.flac.FLACNoHeaderError,
@@ -697,9 +696,10 @@ class MediaFile(object):
raise UnreadableFileError('Mutagen could not read file')
except IOError:
raise UnreadableFileError('could not read file')
except:
except Exception as exc:
# Hide bugs in Mutagen.
log.error('uncaught Mutagen exception:\n' + traceback.format_exc())
log.debug(traceback.format_exc())
log.error('uncaught Mutagen exception: {0}'.format(exc))
raise UnreadableFileError('Mutagen raised an exception')
if self.mgfile is None: # Mutagen couldn't guess the type
@@ -723,230 +723,282 @@ class MediaFile(object):
else:
raise FileTypeError('file type %s unsupported by MediaFile' %
type(self.mgfile).__name__)
# add a set of tags if it's missing
if self.mgfile.tags is None:
self.mgfile.add_tags()
def save(self):
self.mgfile.save()
#### field definitions ####
# Field definitions.
title = MediaField(
mp3 = StorageStyle('TIT2'),
mp4 = StorageStyle("\xa9nam"),
etc = StorageStyle('title'),
)
mp3 = StorageStyle('TIT2'),
mp4 = StorageStyle("\xa9nam"),
etc = StorageStyle('TITLE'),
)
artist = MediaField(
mp3 = StorageStyle('TPE1'),
mp4 = StorageStyle("\xa9ART"),
etc = StorageStyle('artist'),
)
mp3 = StorageStyle('TPE1'),
mp4 = StorageStyle("\xa9ART"),
etc = StorageStyle('ARTIST'),
)
album = MediaField(
mp3 = StorageStyle('TALB'),
mp4 = StorageStyle("\xa9alb"),
etc = StorageStyle('album'),
)
mp3 = StorageStyle('TALB'),
mp4 = StorageStyle("\xa9alb"),
etc = StorageStyle('ALBUM'),
)
genre = MediaField(
mp3 = StorageStyle('TCON'),
mp4 = StorageStyle("\xa9gen"),
etc = StorageStyle('genre'),
)
mp3 = StorageStyle('TCON'),
mp4 = StorageStyle("\xa9gen"),
etc = StorageStyle('GENRE'),
)
composer = MediaField(
mp3 = StorageStyle('TCOM'),
mp4 = StorageStyle("\xa9wrt"),
etc = StorageStyle('composer'),
)
mp3 = StorageStyle('TCOM'),
mp4 = StorageStyle("\xa9wrt"),
etc = StorageStyle('COMPOSER'),
)
grouping = MediaField(
mp3 = StorageStyle('TIT1'),
mp4 = StorageStyle("\xa9grp"),
etc = StorageStyle('grouping'),
)
mp3 = StorageStyle('TIT1'),
mp4 = StorageStyle("\xa9grp"),
etc = StorageStyle('GROUPING'),
)
year = MediaField(out_type=int,
mp3 = StorageStyle('TDRC',
packing = packing.DATE,
pack_pos = 0),
mp4 = StorageStyle("\xa9day",
packing = packing.DATE,
pack_pos = 0),
etc = [StorageStyle('date',
packing = packing.DATE,
pack_pos = 0),
StorageStyle('year')]
)
mp3 = StorageStyle('TDRC', packing=packing.DATE, pack_pos=0),
mp4 = StorageStyle("\xa9day", packing=packing.DATE, pack_pos=0),
etc = [StorageStyle('DATE', packing=packing.DATE, pack_pos=0),
StorageStyle('YEAR')]
)
month = MediaField(out_type=int,
mp3 = StorageStyle('TDRC',
packing = packing.DATE,
pack_pos = 1),
mp4 = StorageStyle("\xa9day",
packing = packing.DATE,
pack_pos = 1),
etc = StorageStyle('date',
packing = packing.DATE,
pack_pos = 1)
)
mp3 = StorageStyle('TDRC', packing=packing.DATE, pack_pos=1),
mp4 = StorageStyle("\xa9day", packing=packing.DATE, pack_pos=1),
etc = StorageStyle('DATE', packing=packing.DATE, pack_pos=1),
)
day = MediaField(out_type=int,
mp3 = StorageStyle('TDRC',
packing = packing.DATE,
pack_pos = 2),
mp4 = StorageStyle("\xa9day",
packing = packing.DATE,
pack_pos = 2),
etc = StorageStyle('date',
packing = packing.DATE,
pack_pos = 2)
)
mp3 = StorageStyle('TDRC', packing=packing.DATE, pack_pos=2),
mp4 = StorageStyle("\xa9day", packing=packing.DATE, pack_pos=2),
etc = StorageStyle('DATE', packing=packing.DATE, pack_pos=2),
)
date = CompositeDateField(year, month, day)
track = MediaField(out_type = int,
mp3 = StorageStyle('TRCK',
packing = packing.SLASHED,
pack_pos = 0),
mp4 = StorageStyle('trkn',
packing = packing.TUPLE,
pack_pos = 0),
etc = [StorageStyle('track'),
StorageStyle('tracknumber')]
)
tracktotal = MediaField(out_type = int,
mp3 = StorageStyle('TRCK',
packing = packing.SLASHED,
pack_pos = 1),
mp4 = StorageStyle('trkn',
packing = packing.TUPLE,
pack_pos = 1),
etc = [StorageStyle('tracktotal'),
StorageStyle('trackc'),
StorageStyle('totaltracks')]
)
disc = MediaField(out_type = int,
mp3 = StorageStyle('TPOS',
packing = packing.SLASHED,
pack_pos = 0),
mp4 = StorageStyle('disk',
packing = packing.TUPLE,
pack_pos = 0),
etc = [StorageStyle('disc'),
StorageStyle('discnumber')]
)
disctotal = MediaField(out_type = int,
mp3 = StorageStyle('TPOS',
packing = packing.SLASHED,
pack_pos = 1),
mp4 = StorageStyle('disk',
packing = packing.TUPLE,
pack_pos = 1),
etc = [StorageStyle('disctotal'),
StorageStyle('discc'),
StorageStyle('totaldiscs')]
)
track = MediaField(out_type=int,
mp3 = StorageStyle('TRCK', packing=packing.SLASHED, pack_pos=0),
mp4 = StorageStyle('trkn', packing=packing.TUPLE, pack_pos=0),
etc = [StorageStyle('TRACK'),
StorageStyle('TRACKNUMBER')]
)
tracktotal = MediaField(out_type=int,
mp3 = StorageStyle('TRCK', packing=packing.SLASHED, pack_pos=1),
mp4 = StorageStyle('trkn', packing=packing.TUPLE, pack_pos=1),
etc = [StorageStyle('TRACKTOTAL'),
StorageStyle('TRACKC'),
StorageStyle('TOTALTRACKS')]
)
disc = MediaField(out_type=int,
mp3 = StorageStyle('TPOS', packing=packing.SLASHED, pack_pos=0),
mp4 = StorageStyle('disk', packing=packing.TUPLE, pack_pos=0),
etc = [StorageStyle('DISC'),
StorageStyle('DISCNUMBER')]
)
disctotal = MediaField(out_type=int,
mp3 = StorageStyle('TPOS', packing=packing.SLASHED, pack_pos=1),
mp4 = StorageStyle('disk', packing=packing.TUPLE, pack_pos=1),
etc = [StorageStyle('DISCTOTAL'),
StorageStyle('DISCC'),
StorageStyle('TOTALDISCS')]
)
lyrics = MediaField(
mp3 = StorageStyle('USLT',
list_elem = False,
id3_desc = u''),
mp4 = StorageStyle("\xa9lyr"),
etc = StorageStyle('lyrics')
)
mp3 = StorageStyle('USLT', list_elem=False, id3_desc=u''),
mp4 = StorageStyle("\xa9lyr"),
etc = StorageStyle('LYRICS')
)
comments = MediaField(
mp3 = StorageStyle('COMM', id3_desc = u''),
mp4 = StorageStyle("\xa9cmt"),
etc = [StorageStyle('description'),
StorageStyle('comment')]
)
bpm = MediaField(out_type = int,
mp3 = StorageStyle('TBPM'),
mp4 = StorageStyle('tmpo', as_type = int),
etc = StorageStyle('bpm')
)
comp = MediaField(out_type = bool,
mp3 = StorageStyle('TCMP'),
mp4 = StorageStyle('cpil',
list_elem = False,
as_type = bool),
etc = StorageStyle('compilation')
)
mp3 = StorageStyle('COMM', id3_desc=u''),
mp4 = StorageStyle("\xa9cmt"),
etc = [StorageStyle('DESCRIPTION'),
StorageStyle('COMMENT')]
)
bpm = MediaField(out_type=int,
mp3 = StorageStyle('TBPM'),
mp4 = StorageStyle('tmpo', as_type=int),
etc = StorageStyle('BPM'),
)
comp = MediaField(out_type=bool,
mp3 = StorageStyle('TCMP'),
mp4 = StorageStyle('cpil', list_elem=False, as_type=bool),
etc = StorageStyle('COMPILATION'),
)
albumartist = MediaField(
mp3 = StorageStyle('TPE2'),
mp4 = StorageStyle('aART'),
etc = [StorageStyle('album artist'),
StorageStyle('albumartist')]
)
mp3 = StorageStyle('TPE2'),
mp4 = StorageStyle('aART'),
etc = [StorageStyle('ALBUM ARTIST'),
StorageStyle('ALBUMARTIST')]
)
albumtype = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Type'),
mp4 = StorageStyle(
'----:com.apple.iTunes:MusicBrainz Album Type'),
etc = StorageStyle('musicbrainz_albumtype')
)
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Type'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Album Type'),
etc = StorageStyle('MUSICBRAINZ_ALBUMTYPE'),
)
label = MediaField(
mp3 = StorageStyle('TPUB'),
mp4 = [StorageStyle('----:com.apple.iTunes:Label'),
StorageStyle('----:com.apple.iTunes:publisher')],
etc = [StorageStyle('label'),
StorageStyle('publisher')] # Traktor
)
mp3 = StorageStyle('TPUB'),
mp4 = [StorageStyle('----:com.apple.iTunes:Label'),
StorageStyle('----:com.apple.iTunes:publisher')],
etc = [StorageStyle('LABEL'),
StorageStyle('PUBLISHER')] # Traktor
)
artist_sort = MediaField(
mp3 = StorageStyle('TSOP'),
mp4 = StorageStyle("soar"),
etc = StorageStyle('ARTISTSORT'),
)
albumartist_sort = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'ALBUMARTISTSORT'),
mp4 = StorageStyle("soaa"),
etc = StorageStyle('ALBUMARTISTSORT'),
)
asin = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'ASIN'),
mp4 = StorageStyle("----:com.apple.iTunes:ASIN"),
etc = StorageStyle('ASIN'),
)
catalognum = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'CATALOGNUMBER'),
mp4 = StorageStyle("----:com.apple.iTunes:CATALOGNUMBER"),
etc = StorageStyle('CATALOGNUMBER'),
)
disctitle = MediaField(
mp3 = StorageStyle('TSST'),
mp4 = StorageStyle("----:com.apple.iTunes:DISCSUBTITLE"),
etc = StorageStyle('DISCSUBTITLE'),
)
encoder = MediaField(
mp3 = StorageStyle('TENC'),
mp4 = StorageStyle("\xa9too"),
etc = [StorageStyle('ENCODEDBY'),
StorageStyle('ENCODER')]
)
script = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'Script'),
mp4 = StorageStyle("----:com.apple.iTunes:SCRIPT"),
etc = StorageStyle('SCRIPT'),
)
language = MediaField(
mp3 = StorageStyle('TLAN'),
mp4 = StorageStyle("----:com.apple.iTunes:LANGUAGE"),
etc = StorageStyle('LANGUAGE'),
)
country = MediaField(
mp3 = StorageStyle('TXXX',
id3_desc=u'MusicBrainz Album Release Country'),
mp4 = StorageStyle("----:com.apple.iTunes:MusicBrainz Album "
"Release Country"),
etc = StorageStyle('RELEASECOUNTRY'),
)
albumstatus = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Status'),
mp4 = StorageStyle("----:com.apple.iTunes:MusicBrainz Album Status"),
etc = StorageStyle('MUSICBRAINZ_ALBUMSTATUS'),
)
media = MediaField(
mp3 = StorageStyle('TMED'),
mp4 = StorageStyle("----:com.apple.iTunes:MEDIA"),
etc = StorageStyle('MEDIA'),
)
albumdisambig = MediaField(
# This tag mapping was invented for beets (not used by Picard, etc).
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Comment'),
mp4 = StorageStyle("----:com.apple.iTunes:MusicBrainz Album Comment"),
etc = StorageStyle('MUSICBRAINZ_ALBUMCOMMENT'),
)
# Nonstandard metadata.
artist_credit = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'Artist Credit'),
mp4 = StorageStyle("----:com.apple.iTunes:Artist Credit"),
etc = StorageStyle('ARTIST_CREDIT'),
)
albumartist_credit = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'Album Artist Credit'),
mp4 = StorageStyle("----:com.apple.iTunes:Album Artist Credit"),
etc = StorageStyle('ALBUMARTIST_CREDIT'),
)
# Album art.
art = ImageField()
# MusicBrainz IDs.
mb_trackid = MediaField(
mp3 = StorageStyle('UFID:http://musicbrainz.org',
list_elem = False,
id3_frame_field = 'data'),
mp4 = StorageStyle(
'----:com.apple.iTunes:MusicBrainz Track Id',
as_type=str),
etc = StorageStyle('musicbrainz_trackid')
)
mp3 = StorageStyle('UFID:http://musicbrainz.org',
list_elem = False,
id3_frame_field = 'data'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Track Id',
as_type=str),
etc = StorageStyle('MUSICBRAINZ_TRACKID')
)
mb_albumid = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Id'),
mp4 = StorageStyle(
'----:com.apple.iTunes:MusicBrainz Album Id',
as_type=str),
etc = StorageStyle('musicbrainz_albumid')
)
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Album Id'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Album Id',
as_type=str),
etc = StorageStyle('MUSICBRAINZ_ALBUMID')
)
mb_artistid = MediaField(
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Artist Id'),
mp4 = StorageStyle(
'----:com.apple.iTunes:MusicBrainz Artist Id',
as_type=str),
etc = StorageStyle('musicbrainz_artistid')
)
mp3 = StorageStyle('TXXX', id3_desc=u'MusicBrainz Artist Id'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Artist Id',
as_type=str),
etc = StorageStyle('MUSICBRAINZ_ARTISTID')
)
mb_albumartistid = MediaField(
mp3 = StorageStyle('TXXX',
id3_desc=u'MusicBrainz Album Artist Id'),
mp4 = StorageStyle(
'----:com.apple.iTunes:MusicBrainz Album Artist Id',
as_type=str),
etc = StorageStyle('musicbrainz_albumartistid')
)
mp3 = StorageStyle('TXXX',
id3_desc=u'MusicBrainz Album Artist Id'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Album Artist Id',
as_type=str),
etc = StorageStyle('MUSICBRAINZ_ALBUMARTISTID')
)
mb_releasegroupid = MediaField(
mp3 = StorageStyle('TXXX',
id3_desc=u'MusicBrainz Release Group Id'),
mp4 = StorageStyle('----:com.apple.iTunes:MusicBrainz Release Group Id',
as_type=str),
etc = StorageStyle('MUSICBRAINZ_RELEASEGROUPID')
)
# Acoustid fields.
acoustid_fingerprint = MediaField(
mp3 = StorageStyle('TXXX',
id3_desc=u'Acoustid Fingerprint'),
mp4 = StorageStyle('----:com.apple.iTunes:Acoustid Fingerprint',
as_type=str),
etc = StorageStyle('ACOUSTID_FINGERPRINT')
)
acoustid_id = MediaField(
mp3 = StorageStyle('TXXX',
id3_desc=u'Acoustid Id'),
mp4 = StorageStyle('----:com.apple.iTunes:Acoustid Id',
as_type=str),
etc = StorageStyle('ACOUSTID_ID')
)
# ReplayGain fields.
rg_track_gain = FloatValueField(2, 'dB',
mp3 = StorageStyle('TXXX',
id3_desc=u'REPLAYGAIN_TRACK_GAIN'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_TRACK_GAIN')
)
mp3 = StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_TRACK_GAIN'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_TRACK_GAIN')
)
rg_album_gain = FloatValueField(2, 'dB',
mp3 = StorageStyle('TXXX',
id3_desc=u'REPLAYGAIN_ALBUM_GAIN'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_ALBUM_GAIN')
)
mp3 = StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_ALBUM_GAIN'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_ALBUM_GAIN')
)
rg_track_peak = FloatValueField(6, None,
mp3 = StorageStyle('TXXX',
id3_desc=u'REPLAYGAIN_TRACK_PEAK'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_TRACK_PEAK')
)
mp3 = StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_TRACK_PEAK'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_TRACK_PEAK')
)
rg_album_peak = FloatValueField(6, None,
mp3 = StorageStyle('TXXX',
id3_desc=u'REPLAYGAIN_ALBUM_PEAK'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_ALBUM_PEAK')
)
mp3 = StorageStyle('TXXX', id3_desc=u'REPLAYGAIN_ALBUM_PEAK'),
mp4 = None,
etc = StorageStyle(u'REPLAYGAIN_ALBUM_PEAK')
)
@property
def length(self):
@@ -959,7 +1011,7 @@ class MediaFile(object):
if hasattr(self.mgfile.info, 'sample_rate'):
return self.mgfile.info.sample_rate
return 0
@property
def bitdepth(self):
"""The number of bits per sample in the audio encoding (an int).
@@ -967,7 +1019,7 @@ class MediaFile(object):
unavailable).
"""
if hasattr(self.mgfile.info, 'bits_per_sample'):
return self.mgfile.info.bits_per_sample
return self.mgfile.info.bits_per_sample
return 0
@property

View File

@@ -8,7 +8,7 @@
# 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.
@@ -43,6 +43,7 @@ class BeetsPlugin(object):
override this method.
"""
_add_media_fields(self.item_fields())
self.import_stages = []
def commands(self):
"""Should return a list of beets.ui.Subcommand objects for
@@ -162,7 +163,7 @@ def load_plugins(names=()):
try:
try:
__import__(modname, None, None)
except ImportError, exc:
except ImportError as exc:
# Again, this is hacky:
if exc.args[0].endswith(' ' + name):
log.warn('** plugin %s not found' % name)
@@ -269,22 +270,27 @@ def _add_media_fields(fields):
for key, value in fields.iteritems():
setattr(mediafile.MediaFile, key, value)
def import_stages():
"""Get a list of import stage functions defined by plugins."""
stages = []
for plugin in find_plugins():
if hasattr(plugin, 'import_stages'):
stages += plugin.import_stages
return stages
# Event dispatch.
# All the handlers for the event system.
# Each key of the dictionary should contain a list of functions to be
# called for any event. Functions will be called in the order they were
# added.
_event_handlers = defaultdict(list)
def load_listeners():
"""Loads and registers event handlers from all loaded plugins.
def event_handlers():
"""Find all event handlers from plugins as a dictionary mapping
event names to sequences of callables.
"""
all_handlers = defaultdict(list)
for plugin in find_plugins():
if plugin.listeners:
for event, handlers in plugin.listeners.items():
_event_handlers[event] += handlers
all_handlers[event] += handlers
return all_handlers
def send(event, **arguments):
"""Sends an event to all assigned event listeners. Event is the
@@ -294,7 +300,7 @@ def send(event, **arguments):
Returns the number of handlers called.
"""
log.debug('Sending event: %s' % event)
handlers = _event_handlers[event]
handlers = event_handlers()[event]
for handler in handlers:
handler(**arguments)
return len(handlers)

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,7 +8,7 @@
# 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.
@@ -16,6 +16,8 @@
interface. To invoke the CLI, just call beets.ui.main(). The actual
CLI commands are implemented in the ui.commands module.
"""
from __future__ import print_function
import os
import locale
import optparse
@@ -27,14 +29,22 @@ import logging
import sqlite3
import errno
import re
import codecs
from lib.beets import library
from lib.beets import plugins
from lib.beets import util
from lib.beets.util.functemplate import Template
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == 'win32':
import colorama
colorama.init()
try:
import colorama
except ImportError:
pass
else:
colorama.init()
# Constants.
CONFIG_PATH_VAR = 'BEETSCONFIG'
@@ -49,9 +59,12 @@ PF_KEY_QUERIES = {
'singleton': 'singleton:true',
}
DEFAULT_PATH_FORMATS = [
(library.PF_KEY_DEFAULT, '$albumartist/$album/$track $title'),
(PF_KEY_QUERIES['singleton'], 'Non-Album/$artist/$title'),
(PF_KEY_QUERIES['comp'], 'Compilations/$album/$track $title'),
(library.PF_KEY_DEFAULT,
Template('$albumartist/$album%aunique{}/$track $title')),
(PF_KEY_QUERIES['singleton'],
Template('Non-Album/$artist/$title')),
(PF_KEY_QUERIES['comp'],
Template('Compilations/$album%aunique{}/$track $title')),
]
DEFAULT_ART_FILENAME = 'cover'
DEFAULT_TIMEOUT = 5.0
@@ -94,7 +107,28 @@ def print_(*strings):
txt = u''
if isinstance(txt, unicode):
txt = txt.encode(_encoding(), 'replace')
print txt
print(txt)
def input_(prompt=None):
"""Like `raw_input`, but decodes the result to a Unicode string.
Raises a UserError if stdin is not available. The prompt is sent to
stdout rather than stderr. A printed between the prompt and the
input cursor.
"""
# raw_input incorrectly sends prompts to stderr, not stdout, so we
# use print() explicitly to display prompts.
# http://bugs.python.org/issue1927
if prompt:
if isinstance(prompt, unicode):
prompt = prompt.encode(_encoding(), 'replace')
print(prompt, end=' ')
try:
resp = raw_input()
except EOFError:
raise UserError('stdin stream ended while input required')
return resp.decode(sys.stdin.encoding, 'ignore')
def input_options(options, require=False, prompt=None, fallback_prompt=None,
numrange=None, default=None, color=False, max_width=72):
@@ -145,7 +179,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Mark the option's shortcut letter for display.
if (default is None and not numrange and first) \
or (isinstance(default, basestring) and
or (isinstance(default, basestring) and
found_letter.lower() == default.lower()):
# The first option is the default; mark it.
show_letter = '[%s]' % found_letter.upper()
@@ -175,7 +209,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
default = numrange[0]
else:
default = display_letters[0].lower()
# Make a prompt if one is not provided.
if not prompt:
prompt_parts = []
@@ -227,16 +261,14 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
fallback_prompt += '%i-%i, ' % numrange
fallback_prompt += ', '.join(display_letters) + ':'
# (raw_input(prompt) was causing problems with colors.)
print prompt,
resp = raw_input()
resp = input_(prompt)
while True:
resp = resp.strip().lower()
# Try default option.
if default is not None and not resp:
resp = default
# Try an integer input if available.
if numrange:
try:
@@ -249,16 +281,15 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
return resp
else:
resp = None
# Try a normal letter input.
if resp:
resp = resp[0]
if resp in letters:
return resp
# Prompt for new input.
print fallback_prompt,
resp = raw_input()
resp = input_(fallback_prompt)
def input_yn(prompt, require=False, color=False):
"""Prompts the user for a "yes" or "no" response. The default is
@@ -277,7 +308,7 @@ def config_val(config, section, name, default, vtype=None):
"""
if not config.has_section(section):
config.add_section(section)
try:
if vtype is bool:
return config.getboolean(section, name)
@@ -371,7 +402,7 @@ def colordiff(a, b, highlight='red'):
a_out = []
b_out = []
matcher = SequenceMatcher(lambda x: False, a, b)
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
if op == 'equal':
@@ -390,7 +421,7 @@ def colordiff(a, b, highlight='red'):
b_out.append(colorize(highlight, b[b_start:b_end]))
else:
assert(False)
return u''.join(a_out), u''.join(b_out)
def default_paths(pathmod=None):
@@ -428,6 +459,8 @@ def _get_replacements(config):
repl_string = config_val(config, 'beets', 'replace', None)
if not repl_string:
return
if not isinstance(repl_string, unicode):
repl_string = repl_string.decode('utf8')
parts = repl_string.strip().split()
if not parts:
@@ -453,10 +486,12 @@ def _get_path_formats(config):
legacy_path_format = config_val(config, 'beets', 'path_format', None)
if legacy_path_format:
# Old path formats override the default values.
path_formats = [(library.PF_KEY_DEFAULT, legacy_path_format)]
path_formats = [(library.PF_KEY_DEFAULT,
Template(legacy_path_format))]
else:
# If no legacy path format, use the defaults instead.
path_formats = DEFAULT_PATH_FORMATS
if config.has_section('paths'):
custom_path_formats = []
for key, value in config.items('paths', True):
@@ -467,8 +502,9 @@ def _get_path_formats(config):
# For non-special keys (literal queries), the _
# character denotes a :.
key = key.replace('_', ':')
custom_path_formats.append((key, value))
custom_path_formats.append((key, Template(value)))
path_formats = custom_path_formats + path_formats
return path_formats
@@ -504,7 +540,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
_HelpSubcommand = Subcommand('help', optparse.OptionParser(),
help='give detailed help on a specific sub-command',
aliases=('?',))
def __init__(self, *args, **kwargs):
"""Create a new subcommand-aware option parser. All of the
options to OptionParser.__init__ are supported in addition
@@ -513,41 +549,41 @@ class SubcommandsOptionParser(optparse.OptionParser):
# The subcommand array, with the help command included.
self.subcommands = list(kwargs.pop('subcommands', []))
self.subcommands.append(self._HelpSubcommand)
# A more helpful default usage.
if 'usage' not in kwargs:
kwargs['usage'] = """
%prog COMMAND [ARGS...]
%prog help COMMAND"""
# Super constructor.
optparse.OptionParser.__init__(self, *args, **kwargs)
# Adjust the help-visible name of each subcommand.
for subcommand in self.subcommands:
subcommand.parser.prog = '%s %s' % \
(self.get_prog_name(), subcommand.name)
# Our root parser needs to stop on the first unrecognized argument.
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
def add_subcommand(self, cmd):
"""Adds a Subcommand object to the parser's list of commands.
"""
self.subcommands.append(cmd)
# Add the list of subcommands to the help message.
def format_help(self, formatter=None):
# Get the original help message, to which we will append.
out = optparse.OptionParser.format_help(self, formatter)
if formatter is None:
formatter = self.formatter
# Subcommands header.
result = ["\n"]
result.append(formatter.format_heading('Commands'))
formatter.indent()
# Generate the display names (including aliases).
# Also determine the help position.
disp_names = []
@@ -557,12 +593,12 @@ class SubcommandsOptionParser(optparse.OptionParser):
if subcommand.aliases:
name += ' (%s)' % ', '.join(subcommand.aliases)
disp_names.append(name)
# Set the help position based on the max width.
proposed_help_position = len(name) + formatter.current_indent + 2
if proposed_help_position <= formatter.max_help_position:
help_position = max(help_position, proposed_help_position)
help_position = max(help_position, proposed_help_position)
# Add each subcommand to the output.
for subcommand, name in zip(self.subcommands, disp_names):
# Lifted directly from optparse.py.
@@ -581,11 +617,11 @@ class SubcommandsOptionParser(optparse.OptionParser):
result.extend(["%*s%s\n" % (help_position, "", line)
for line in help_lines[1:]])
formatter.dedent()
# Concatenate the original help message with the subcommand
# list.
return out + "".join(result)
def _subcommand_for_name(self, name):
"""Return the subcommand in self.subcommands matching the
given name. The name may either be the name of a subcommand or
@@ -596,16 +632,16 @@ class SubcommandsOptionParser(optparse.OptionParser):
name in subcommand.aliases:
return subcommand
return None
def parse_args(self, a=None, v=None):
"""Like OptionParser.parse_args, but returns these four items:
- options: the options passed to the root parser
- subcommand: the Subcommand object that was invoked
- suboptions: the options passed to the subcommand parser
- subargs: the positional arguments passed to the subcommand
"""
"""
options, args = optparse.OptionParser.parse_args(self, a, v)
if not args:
# No command given.
self.print_help()
@@ -615,7 +651,7 @@ class SubcommandsOptionParser(optparse.OptionParser):
subcommand = self._subcommand_for_name(cmdname)
if not subcommand:
self.error('unknown command ' + cmdname)
suboptions, subargs = subcommand.parser.parse_args(args)
if subcommand is self._HelpSubcommand:
@@ -623,13 +659,15 @@ class SubcommandsOptionParser(optparse.OptionParser):
# particular
cmdname = subargs[0]
helpcommand = self._subcommand_for_name(cmdname)
if not helpcommand:
self.error('no command named {0}'.format(cmdname))
helpcommand.parser.print_help()
self.exit()
else:
# general
self.print_help()
self.exit()
return options, subcommand, suboptions, subargs
@@ -654,7 +692,7 @@ def main(args=None, configfh=None):
if configpath:
configpath = util.syspath(configpath)
if os.path.exists(util.syspath(configpath)):
configfh = open(configpath)
configfh = codecs.open(configpath, 'r', encoding='utf-8')
else:
configfh = None
if configfh:
@@ -667,7 +705,6 @@ def main(args=None, configfh=None):
# Load requested plugins.
plugnames = config_val(config, 'beets', 'plugins', '')
plugins.load_plugins(plugnames.split())
plugins.load_listeners()
plugins.send("pluginload")
plugins.configure(config)
@@ -681,10 +718,10 @@ def main(args=None, configfh=None):
help="destination music directory")
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
help='print debugging information')
# Parse the command-line!
options, subcommand, suboptions, subargs = parser.parse_args(args)
# Open library file.
libpath = options.libpath or \
config_val(config, 'beets', 'library', default_libpath)
@@ -709,7 +746,8 @@ def main(args=None, configfh=None):
replacements)
except sqlite3.OperationalError:
raise UserError("database file %s could not be opened" % db_path)
plugins.send("library_opened", lib=lib)
# Configure the logger.
log = logging.getLogger('beets')
if options.verbose:
@@ -719,14 +757,17 @@ def main(args=None, configfh=None):
log.debug(u'config file: %s' % util.displayable_path(configpath))
log.debug(u'library database: %s' % util.displayable_path(lib.path))
log.debug(u'library directory: %s' % util.displayable_path(lib.directory))
# Invoke the subcommand.
try:
subcommand.func(lib, config, suboptions, subargs)
except UserError, exc:
except UserError as exc:
message = exc.args[0] if exc.args else None
subcommand.parser.error(message)
except IOError, exc:
except util.HumanReadableException as exc:
exc.log(log)
sys.exit(1)
except IOError as exc:
if exc.errno == errno.EPIPE:
# "Broken pipe". End silently.
pass

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,25 +8,25 @@
# 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.
"""This module provides the default commands for beets' command-line
interface.
"""
from __future__ import with_statement # Python 2.5
from __future__ import print_function
import logging
import sys
import os
import time
import itertools
import re
import lib.beets
from lib.beets import ui
from lib.beets.ui import print_, decargs
from lib.beets.ui import print_, input_, decargs
from lib.beets import autotag
import lib.beets.autotag.art
from lib.beets import plugins
from lib.beets import importer
from lib.beets.util import syspath, normpath, ancestry, displayable_path
@@ -64,7 +64,7 @@ def _do_query(lib, query, album, also_items=True):
raise ui.UserError('No matching albums found.')
elif not album and not items:
raise ui.UserError('No matching items found.')
return items, albums
FLOAT_EPSILON = 0.01
@@ -84,14 +84,27 @@ def _showdiff(field, oldval, newval, color):
print_(u' %s: %s -> %s' % (field, oldval, newval))
# fields: Shows a list of available fields for queries and format strings.
fields_cmd = ui.Subcommand('fields',
help='show fields available for queries and format strings')
def fields_func(lib, config, opts, args):
print("Available item fields:")
print(" " + "\n ".join([key for key in library.ITEM_KEYS]))
print("\nAvailable album fields:")
print(" " + "\n ".join([key for key in library.ALBUM_KEYS]))
fields_cmd.func = fields_func
default_commands.append(fields_cmd)
# import: Autotagger and importer.
DEFAULT_IMPORT_COPY = True
DEFAULT_IMPORT_MOVE = False
DEFAULT_IMPORT_WRITE = True
DEFAULT_IMPORT_DELETE = False
DEFAULT_IMPORT_AUTOT = True
DEFAULT_IMPORT_TIMID = False
DEFAULT_IMPORT_ART = True
DEFAULT_IMPORT_QUIET = False
DEFAULT_IMPORT_QUIET_FALLBACK = 'skip'
DEFAULT_IMPORT_RESUME = None # "ask"
@@ -101,6 +114,7 @@ DEFAULT_COLOR = True
DEFAULT_IGNORE = [
'.*', '*~',
]
DEFAULT_PER_DISC_NUMBERING = False
VARIOUS_ARTISTS = u'Various Artists'
@@ -122,10 +136,11 @@ def dist_string(dist, color):
out = ui.colorize('red', out)
return out
def show_change(cur_artist, cur_album, items, info, dist, color=True):
"""Print out a representation of the changes that will be made if
tags are changed from (cur_artist, cur_album, items) to info with
distance dist.
def show_change(cur_artist, cur_album, match, color=True,
per_disc_numbering=False):
"""Print out a representation of the changes that will be made if an
album's tags are changed according to `match`, which must be an AlbumMatch
object.
"""
def show_album(artist, album, partial=False):
if artist:
@@ -148,14 +163,25 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
out += u' ' + warning
print_(out)
# Record if the match is partial or not.
partial_match = None in items
def format_index(track_info):
"""Return a string representing the track index of the given
TrackInfo object.
"""
if per_disc_numbering:
if match.info.mediums > 1:
return u'{0}-{1}'.format(track_info.medium,
track_info.medium_index)
else:
return unicode(track_info.medium_index)
else:
return unicode(track_info.index)
# Identify the album in question.
if cur_artist != info.artist or \
(cur_album != info.album and info.album != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', info.artist
album_l, album_r = cur_album or '', info.album
if cur_artist != match.info.artist or \
(cur_album != match.info.album and
match.info.album != VARIOUS_ARTISTS):
artist_l, artist_r = cur_artist or '', match.info.artist
album_l, album_r = cur_album or '', match.info.album
if artist_r == VARIOUS_ARTISTS:
# Hide artists for VA releases.
artist_l, artist_r = u'', u''
@@ -169,8 +195,8 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_("To:")
show_album(artist_r, album_r)
else:
message = u"Tagging: %s - %s" % (info.artist, info.album)
if partial_match:
message = u"Tagging: %s - %s" % (match.info.artist, match.info.album)
if match.extra_items or match.extra_tracks:
warning = PARTIAL_MATCH_MESSAGE
if color:
warning = ui.colorize('yellow', PARTIAL_MATCH_MESSAGE)
@@ -178,18 +204,17 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
print_(message)
# Distance/similarity.
print_('(Similarity: %s)' % dist_string(dist, color))
print_('(Similarity: %s)' % dist_string(match.distance, color))
# Tracks.
missing_tracks = []
for i, (item, track_info) in enumerate(zip(items, info.tracks)):
if not item:
missing_tracks.append((i, track_info))
continue
pairs = match.mapping.items()
pairs.sort(key=lambda (_, track_info): track_info.index)
for item, track_info in pairs:
# Get displayable LHS and RHS values.
cur_track = unicode(item.track)
new_track = unicode(i+1)
new_track = format_index(track_info)
tracks_differ = item.track not in (track_info.index,
track_info.medium_index)
cur_title = item.title
new_title = track_info.title
if item.length and track_info.length:
@@ -198,48 +223,55 @@ def show_change(cur_artist, cur_album, items, info, dist, color=True):
if color:
cur_length = ui.colorize('red', cur_length)
new_length = ui.colorize('red', new_length)
# Possibly colorize changes.
if color:
cur_title, new_title = ui.colordiff(cur_title, new_title)
if cur_track != new_track:
cur_track = ui.colorize('red', cur_track)
new_track = ui.colorize('red', new_track)
cur_track = ui.colorize('red', cur_track)
new_track = ui.colorize('red', new_track)
# Show filename (non-colorized) when title is not set.
if not item.title.strip():
cur_title = displayable_path(os.path.basename(item.path))
if cur_title != new_title:
lhs, rhs = cur_title, new_title
if cur_track != new_track:
if tracks_differ:
lhs += u' (%s)' % cur_track
rhs += u' (%s)' % new_track
print_(u" * %s -> %s" % (lhs, rhs))
else:
line = u' * %s' % item.title
display = False
if cur_track != new_track:
if tracks_differ:
display = True
line += u' (%s -> %s)' % (cur_track, new_track)
if item.length and track_info.length and \
abs(item.length - track_info.length) > 2.0:
display = True
line += u' (%s -> %s)' % (cur_length, new_length)
line += u' (%s vs. %s)' % (cur_length, new_length)
if display:
print_(line)
for i, track_info in missing_tracks:
line = u' * Missing track: %s (%d)' % (track_info.title, i+1)
# Missing and unmatched tracks.
for track_info in match.extra_tracks:
line = u' * Missing track: {0} ({1})'.format(track_info.title,
format_index(track_info))
if color:
line = ui.colorize('yellow', line)
print_(line)
for item in match.extra_items:
line = u' * Unmatched track: {0} ({1})'.format(item.title, item.track)
if color:
line = ui.colorize('yellow', line)
print_(line)
def show_item_change(item, info, dist, color):
def show_item_change(item, match, color):
"""Print out the change that would occur by tagging `item` with the
metadata from `info`.
metadata from `match`, a TrackMatch object.
"""
cur_artist, new_artist = item.artist, info.artist
cur_title, new_title = item.title, info.title
cur_artist, new_artist = item.artist, match.info.artist
cur_title, new_title = item.title, match.info.title
if cur_artist != new_artist or cur_title != new_title:
if color:
@@ -254,7 +286,7 @@ def show_item_change(item, info, dist, color):
else:
print_("Tagging track: %s - %s" % (cur_artist, cur_title))
print_('(Similarity: %s)' % dist_string(dist, color))
print_('(Similarity: %s)' % dist_string(match.distance, color))
def should_resume(config, path):
return ui.input_yn("Import of the directory:\n%s"
@@ -273,17 +305,17 @@ def _quiet_fall_back(config):
return config.quiet_fallback
def choose_candidate(candidates, singleton, rec, color, timid,
cur_artist=None, cur_album=None, item=None):
cur_artist=None, cur_album=None, item=None,
itemcount=None, per_disc_numbering=False):
"""Given a sorted list of candidates, ask the user for a selection
of which candidate to use. Applies to both full albums and
singletons (tracks). For albums, the candidates are `(dist, items,
info)` triples and `cur_artist` and `cur_album` must be provided.
For singletons, the candidates are `(dist, info)` pairs and `item`
must be provided.
of which candidate to use. Applies to both full albums and
singletons (tracks). Candidates are either AlbumMatch or TrackMatch
objects depending on `singleton`. for albums, `cur_artist`,
`cur_album`, and `itemcount` must be provided. For singletons,
`item` must be provided.
Returns the result of the choice, which may SKIP, ASIS, TRACKS, or
MANUAL or a candidate. For albums, a candidate is a `(info, items)`
pair; for items, it is just a TrackInfo object.
MANUAL or a candidate (an AlbumMatch/TrackMatch object).
"""
# Sanity check.
if singleton:
@@ -294,11 +326,15 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Zero candidates.
if not candidates:
print_("No match found.")
if singleton:
print_("No matching recordings found.")
opts = ('Use as-is', 'Skip', 'Enter search', 'enter Id',
'aBort')
else:
print_("No matching release found for {0} tracks."
.format(itemcount))
print_('For help, see: '
'https://github.com/sampsyo/beets/wiki/FAQ#wiki-nomatch')
opts = ('Use as-is', 'as Tracks', 'Skip', 'Enter search',
'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
@@ -321,12 +357,9 @@ def choose_candidate(candidates, singleton, rec, color, timid,
# Is the change good enough?
bypass_candidates = False
if rec != autotag.RECOMMEND_NONE:
if singleton:
dist, info = candidates[0]
else:
dist, items, info = candidates[0]
match = candidates[0]
bypass_candidates = True
while True:
# Display and choose from candidates.
if not bypass_candidates:
@@ -335,22 +368,24 @@ def choose_candidate(candidates, singleton, rec, color, timid,
print_('Finding tags for track "%s - %s".' %
(item.artist, item.title))
print_('Candidates:')
for i, (dist, info) in enumerate(candidates):
print_('%i. %s - %s (%s)' % (i+1, info.artist,
info.title, dist_string(dist, color)))
for i, match in enumerate(candidates):
print_('%i. %s - %s (%s)' %
(i + 1, match.info.artist, match.info.title,
dist_string(match.distance, color)))
else:
print_('Finding tags for album "%s - %s".' %
(cur_artist, cur_album))
print_('Candidates:')
for i, (dist, items, info) in enumerate(candidates):
line = '%i. %s - %s' % (i+1, info.artist, info.album)
for i, match in enumerate(candidates):
line = '%i. %s - %s' % (i + 1, match.info.artist,
match.info.album)
# Label and year disambiguation, if available.
label, year = None, None
if info.label:
label = info.label
if info.year:
year = unicode(info.year)
if match.info.label:
label = match.info.label
if match.info.year:
year = unicode(match.info.year)
if label and year:
line += u' [%s, %s]' % (label, year)
elif label:
@@ -358,17 +393,17 @@ def choose_candidate(candidates, singleton, rec, color, timid,
elif year:
line += u' [%s]' % year
line += ' (%s)' % dist_string(dist, color)
line += ' (%s)' % dist_string(match.distance, color)
# Point out the partial matches.
if None in items:
if match.extra_items or match.extra_tracks:
warning = PARTIAL_MATCH_MESSAGE
if color:
warning = ui.colorize('yellow', warning)
line += u' %s' % warning
print_(line)
# Ask the user for a choice.
if singleton:
opts = ('Skip', 'Use as-is', 'Enter search', 'enter Id',
@@ -391,26 +426,24 @@ def choose_candidate(candidates, singleton, rec, color, timid,
raise importer.ImportAbort()
elif sel == 'i':
return importer.action.MANUAL_ID
else: # Numerical selection.
else: # Numerical selection.
if singleton:
dist, info = candidates[sel-1]
match = candidates[sel - 1]
else:
dist, items, info = candidates[sel-1]
match = candidates[sel - 1]
bypass_candidates = False
# Show what we're about to do.
if singleton:
show_item_change(item, info, dist, color)
show_item_change(item, match, color)
else:
show_change(cur_artist, cur_album, items, info, dist, color)
show_change(cur_artist, cur_album, match, color,
per_disc_numbering)
# Exact match => tag automatically if we're not in timid mode.
if rec == autotag.RECOMMEND_STRONG and not timid:
if singleton:
return info
else:
return info, items
return match
# Ask for confirmation.
if singleton:
opts = ('Apply', 'More candidates', 'Skip', 'Use as-is',
@@ -420,10 +453,7 @@ def choose_candidate(candidates, singleton, rec, color, timid,
'as Tracks', 'Enter search', 'enter Id', 'aBort')
sel = ui.input_options(opts, color=color)
if sel == 'a':
if singleton:
return info
else:
return info, items
return match
elif sel == 'm':
pass
elif sel == 's':
@@ -444,18 +474,17 @@ def manual_search(singleton):
"""Input either an artist and album (for full albums) or artist and
track name (for singletons) for manual search.
"""
artist = raw_input('Artist: ').decode(sys.stdin.encoding)
name = raw_input('Track: ' if singleton else 'Album: ') \
.decode(sys.stdin.encoding)
artist = input_('Artist:')
name = input_('Track:' if singleton else 'Album:')
return artist.strip(), name.strip()
def manual_id(singleton):
"""Input a MusicBrainz ID, either for an album ("release") or a
track ("recording"). If no valid ID is entered, returns None.
"""
prompt = 'Enter MusicBrainz %s ID: ' % \
prompt = 'Enter MusicBrainz %s ID:' % \
('recording' if singleton else 'release')
entry = raw_input(prompt).decode(sys.stdin.encoding).strip()
entry = input_(prompt).strip()
# Find the first thing that looks like a UUID/MBID.
match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', entry)
@@ -468,7 +497,7 @@ def manual_id(singleton):
def choose_match(task, config):
"""Given an initial autotagging of items, go through an interactive
dance with the user to ask for a choice of metadata. Returns an
(info, items) pair, ASIS, or SKIP.
AlbumMatch object, ASIS, or SKIP.
"""
# Show what we're tagging.
print_()
@@ -477,10 +506,9 @@ def choose_match(task, config):
if config.quiet:
# No input; just make a decision.
if task.rec == autotag.RECOMMEND_STRONG:
dist, items, info = task.candidates[0]
show_change(task.cur_artist, task.cur_album, items, info, dist,
config.color)
return info, items
match = task.candidates[0]
show_change(task.cur_artist, task.cur_album, match, config.color)
return match
else:
return _quiet_fall_back(config)
@@ -488,10 +516,11 @@ def choose_match(task, config):
candidates, rec = task.candidates, task.rec
while True:
# Ask for a choice from the user.
choice = choose_candidate(candidates, False, rec, config.color,
choice = choose_candidate(candidates, False, rec, config.color,
config.timid, task.cur_artist,
task.cur_album)
task.cur_album, itemcount=len(task.items),
per_disc_numbering=config.per_disc_numbering)
# Choose which tags to use.
if choice in (importer.action.SKIP, importer.action.ASIS,
importer.action.TRACKS):
@@ -517,25 +546,25 @@ def choose_match(task, config):
except autotag.AutotagError:
candidates, rec = None, None
else:
# We have a candidate! Finish tagging. Here, choice is
# an (info, items) pair as desired.
assert not isinstance(choice, importer.action)
# We have a candidate! Finish tagging. Here, choice is an
# AlbumMatch object.
assert isinstance(choice, autotag.AlbumMatch)
return choice
def choose_item(task, config):
"""Ask the user for a choice about tagging a single item. Returns
either an action constant or a TrackInfo object.
either an action constant or a TrackMatch object.
"""
print_()
print_(task.item.path)
candidates, rec = task.item_match
candidates, rec = task.candidates, task.rec
if config.quiet:
# Quiet mode; make a decision.
if rec == autotag.RECOMMEND_STRONG:
dist, track_info = candidates[0]
show_item_change(task.item, track_info, dist, config.color)
return track_info
match = candidates[0]
show_item_change(task.item, match, config.color)
return match
else:
return _quiet_fall_back(config)
@@ -558,10 +587,10 @@ def choose_item(task, config):
search_id = manual_id(True)
if search_id:
candidates, rec = autotag.tag_item(task.item, config.timid,
search_id=search_id)
search_id=search_id)
else:
# Chose a candidate.
assert not isinstance(choice, importer.action)
assert isinstance(choice, autotag.TrackMatch)
return choice
def resolve_duplicate(task, config):
@@ -595,30 +624,30 @@ def resolve_duplicate(task, config):
# The import command.
def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
def import_files(lib, paths, copy, move, write, autot, logpath, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid, query, incremental, ignore):
timid, query, incremental, ignore, per_disc_numbering):
"""Import the files in the given list of paths, tagging each leaf
directory as an album. If copy, then the files are copied into
the library folder. If write, then new metadata is written to the
files themselves. If not autot, then just import the files
without attempting to tag. If logpath is provided, then untaggable
albums will be logged there. If art, then attempt to download
cover art for each album. If threaded, then accelerate autotagging
directory as an album. If copy, then the files are copied into the
library folder. If write, then new metadata is written to the files
themselves. If not autot, then just import the files without
attempting to tag. If logpath is provided, then untaggable albums
will be logged there. If threaded, then accelerate autotagging
imports by running them in multiple threads. If color, then
ANSI-colorize some terminal output. If delete, then old files are
deleted when they are copied. If quiet, then the user is
never prompted for input; instead, the tagger just skips anything
it is not confident about. resume indicates whether interrupted
imports can be resumed and is either a boolean or None.
quiet_fallback should be either ASIS or SKIP and indicates what
should happen in quiet mode when the recommendation is not strong.
deleted when they are copied. If quiet, then the user is never
prompted for input; instead, the tagger just skips anything it is
not confident about. resume indicates whether interrupted imports
can be resumed and is either a boolean or None. quiet_fallback
should be either ASIS or SKIP and indicates what should happen in
quiet mode when the recommendation is not strong.
"""
# Check the user-specified directories.
for path in paths:
if not singletons and not os.path.isdir(syspath(path)):
fullpath = syspath(normpath(path))
if not singletons and not os.path.isdir(fullpath):
raise ui.UserError('not a directory: ' + path)
elif singletons and not os.path.exists(syspath(path)):
elif singletons and not os.path.exists(fullpath):
raise ui.UserError('no such file: ' + path)
# Check parameter consistency.
@@ -633,7 +662,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
except IOError:
raise ui.UserError(u"could not open log file for writing: %s" %
displayable_path(logpath))
print >>logfile, 'import started', time.asctime()
print('import started', time.asctime(), file=logfile)
else:
logfile = None
@@ -652,8 +681,8 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
quiet = quiet,
quiet_fallback = quiet_fallback,
copy = copy,
move = move,
write = write,
art = art,
delete = delete,
threaded = threaded,
autot = autot,
@@ -666,12 +695,13 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
incremental = incremental,
ignore = ignore,
resolve_duplicate_func = resolve_duplicate,
per_disc_numbering = per_disc_numbering,
)
finally:
# If we were logging, close the file.
if logfile:
print >>logfile, ''
print('', file=logfile)
logfile.close()
# Emit event.
@@ -696,10 +726,6 @@ import_cmd.parser.add_option('-p', '--resume', action='store_true',
default=None, help="resume importing if interrupted")
import_cmd.parser.add_option('-P', '--noresume', action='store_false',
dest='resume', help="do not try to resume importing")
import_cmd.parser.add_option('-r', '--art', action='store_true',
default=None, help="try to download album art")
import_cmd.parser.add_option('-R', '--noart', action='store_false',
dest='art', help="don't album art (opposite of -r)")
import_cmd.parser.add_option('-q', '--quiet', action='store_true',
dest='quiet', help="never prompt for input: skip albums instead")
import_cmd.parser.add_option('-l', '--log', dest='logpath',
@@ -712,19 +738,20 @@ import_cmd.parser.add_option('-L', '--library', dest='library',
action='store_true', help='retag items matching a query')
import_cmd.parser.add_option('-i', '--incremental', dest='incremental',
action='store_true', help='skip already-imported directories')
import_cmd.parser.add_option('-I', '--noincremental', dest='incremental',
action='store_false', help='do not skip already-imported directories')
def import_func(lib, config, opts, args):
copy = opts.copy if opts.copy is not None else \
ui.config_val(config, 'beets', 'import_copy',
DEFAULT_IMPORT_COPY, bool)
move = ui.config_val(config, 'beets', 'import_move',
DEFAULT_IMPORT_MOVE, bool)
write = opts.write if opts.write is not None else \
ui.config_val(config, 'beets', 'import_write',
DEFAULT_IMPORT_WRITE, bool)
delete = ui.config_val(config, 'beets', 'import_delete',
DEFAULT_IMPORT_DELETE, bool)
autot = opts.autotag if opts.autotag is not None else DEFAULT_IMPORT_AUTOT
art = opts.art if opts.art is not None else \
ui.config_val(config, 'beets', 'import_art',
DEFAULT_IMPORT_ART, bool)
threaded = ui.config_val(config, 'beets', 'threaded',
DEFAULT_THREADED, bool)
color = ui.config_val(config, 'beets', 'color', DEFAULT_COLOR, bool)
@@ -741,6 +768,8 @@ def import_func(lib, config, opts, args):
ui.config_val(config, 'beets', 'import_incremental',
DEFAULT_IMPORT_INCREMENTAL, bool)
ignore = ui.config_val(config, 'beets', 'ignore', DEFAULT_IGNORE, list)
per_disc_numbering = ui.config_val(config, 'beets', 'per_disc_numbering',
DEFAULT_PER_DISC_NUMBERING, bool)
# Resume has three options: yes, no, and "ask" (None).
resume = opts.resume if opts.resume is not None else \
@@ -753,6 +782,11 @@ def import_func(lib, config, opts, args):
else:
resume = None
# Special case: --copy flag suppresses import_move (which would
# otherwise take precedence).
if opts.copy:
move = False
if quiet_fallback_str == 'asis':
quiet_fallback = importer.action.ASIS
else:
@@ -765,26 +799,23 @@ def import_func(lib, config, opts, args):
query = None
paths = args
import_files(lib, paths, copy, write, autot, logpath, art, threaded,
import_files(lib, paths, copy, move, write, autot, logpath, threaded,
color, delete, quiet, resume, quiet_fallback, singletons,
timid, query, incremental, ignore)
timid, query, incremental, ignore, per_disc_numbering)
import_cmd.func = import_func
default_commands.append(import_cmd)
# list: Query and show library contents.
DEFAULT_LIST_FORMAT_ITEM = '$artist - $album - $title'
DEFAULT_LIST_FORMAT_ALBUM = '$albumartist - $album'
def list_items(lib, query, album, path, fmt):
"""Print out items in lib matching query. If album, then search for
albums instead of single items. If path, print the matched objects'
paths instead of human-readable information about them.
"""
if fmt is None:
# If no specific template is supplied, use a default.
if album:
fmt = u'$albumartist - $album'
else:
fmt = u'$artist - $album - $title'
template = Template(fmt)
if album:
@@ -792,13 +823,13 @@ def list_items(lib, query, album, path, fmt):
if path:
print_(album.item_dir())
elif fmt is not None:
print_(template.substitute(album._record))
print_(album.evaluate_template(template))
else:
for item in lib.items(query):
if path:
print_(item.path)
elif fmt is not None:
print_(template.substitute(item.record))
print_(item.evaluate_template(template, lib))
list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',))
list_cmd.parser.add_option('-a', '--album', action='store_true',
@@ -808,7 +839,16 @@ list_cmd.parser.add_option('-p', '--path', action='store_true',
list_cmd.parser.add_option('-f', '--format', action='store',
help='print with custom format', default=None)
def list_func(lib, config, opts, args):
list_items(lib, decargs(args), opts.album, opts.path, opts.format)
fmt = opts.format
if not fmt:
# If no format is specified, fall back to a default.
if opts.album:
fmt = ui.config_val(config, 'beets', 'list_format_album',
DEFAULT_LIST_FORMAT_ALBUM)
else:
fmt = ui.config_val(config, 'beets', 'list_format_item',
DEFAULT_LIST_FORMAT_ITEM)
list_items(lib, decargs(args), opts.album, opts.path, fmt)
list_cmd.func = list_func
default_commands.append(list_cmd)
@@ -819,89 +859,89 @@ def update_items(lib, query, album, move, color, pretend):
"""For all the items matched by the query, update the library to
reflect the item's embedded tags.
"""
items, _ = _do_query(lib, query, album)
with lib.transaction():
items, _ = _do_query(lib, query, album)
# Walk through the items and pick up their changes.
affected_albums = set()
for item in items:
# Item deleted?
if not os.path.exists(syspath(item.path)):
print_(u'X %s - %s' % (item.artist, item.title))
if not pretend:
lib.remove(item, True)
affected_albums.add(item.album_id)
continue
# Did the item change since last checked?
if item.current_mtime() <= item.mtime:
log.debug(u'skipping %s because mtime is up to date (%i)' %
(displayable_path(item.path), item.mtime))
continue
# Read new data.
old_data = dict(item.record)
item.read()
# Special-case album artist when it matches track artist. (Hacky
# but necessary for preserving album-level metadata for non-
# autotagged imports.)
if not item.albumartist and \
old_data['albumartist'] == old_data['artist'] == item.artist:
item.albumartist = old_data['albumartist']
item.dirty['albumartist'] = False
# Get and save metadata changes.
changes = {}
for key in library.ITEM_KEYS_META:
if item.dirty[key]:
changes[key] = old_data[key], getattr(item, key)
if changes:
# Something changed.
print_(u'* %s - %s' % (item.artist, item.title))
for key, (oldval, newval) in changes.iteritems():
_showdiff(key, oldval, newval, color)
# If we're just pretending, then don't move or save.
if pretend:
# Walk through the items and pick up their changes.
affected_albums = set()
for item in items:
# Item deleted?
if not os.path.exists(syspath(item.path)):
print_(u'X %s - %s' % (item.artist, item.title))
if not pretend:
lib.remove(item, True)
affected_albums.add(item.album_id)
continue
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
lib.move(item)
# Did the item change since last checked?
if item.current_mtime() <= item.mtime:
log.debug(u'skipping %s because mtime is up to date (%i)' %
(displayable_path(item.path), item.mtime))
continue
lib.store(item)
affected_albums.add(item.album_id)
elif not pretend:
# The file's mtime was different, but there were no changes
# to the metadata. Store the new mtime, which is set in the
# call to read(), so we don't check this again in the
# future.
lib.store(item)
# Read new data.
old_data = dict(item.record)
item.read()
# Skip album changes while pretending.
if pretend:
return
# Special-case album artist when it matches track artist. (Hacky
# but necessary for preserving album-level metadata for non-
# autotagged imports.)
if not item.albumartist and \
old_data['albumartist'] == old_data['artist'] == \
item.artist:
item.albumartist = old_data['albumartist']
item.dirty['albumartist'] = False
# Modify affected albums to reflect changes in their items.
for album_id in affected_albums:
if album_id is None: # Singletons.
continue
album = lib.get_album(album_id)
if not album: # Empty albums have already been removed.
log.debug('emptied album %i' % album_id)
continue
al_items = list(album.items())
# Get and save metadata changes.
changes = {}
for key in library.ITEM_KEYS_META:
if item.dirty[key]:
changes[key] = old_data[key], getattr(item, key)
if changes:
# Something changed.
print_(u'* %s - %s' % (item.artist, item.title))
for key, (oldval, newval) in changes.iteritems():
_showdiff(key, oldval, newval, color)
# Update album structure to reflect an item in it.
for key in library.ALBUM_KEYS_ITEM:
setattr(album, key, getattr(al_items[0], key))
# If we're just pretending, then don't move or save.
if pretend:
continue
# Move album art (and any inconsistent items).
if move and lib.directory in ancestry(al_items[0].path):
log.debug('moving album %i' % album_id)
album.move()
# Move the item if it's in the library.
if move and lib.directory in ancestry(item.path):
lib.move(item)
lib.save()
lib.store(item)
affected_albums.add(item.album_id)
elif not pretend:
# The file's mtime was different, but there were no changes
# to the metadata. Store the new mtime, which is set in the
# call to read(), so we don't check this again in the
# future.
lib.store(item)
# Skip album changes while pretending.
if pretend:
return
# Modify affected albums to reflect changes in their items.
for album_id in affected_albums:
if album_id is None: # Singletons.
continue
album = lib.get_album(album_id)
if not album: # Empty albums have already been removed.
log.debug('emptied album %i' % album_id)
continue
al_items = list(album.items())
# Update album structure to reflect an item in it.
for key in library.ALBUM_KEYS_ITEM:
setattr(album, key, getattr(al_items[0], key))
# Move album art (and any inconsistent items).
if move and lib.directory in ancestry(al_items[0].path):
log.debug('moving album %i' % album_id)
album.move()
update_cmd = ui.Subcommand('update',
help='update the library', aliases=('upd','up',))
@@ -942,14 +982,13 @@ def remove_items(lib, query, album, delete=False):
return
# Remove (and possibly delete) items.
if album:
for al in albums:
al.remove(delete)
else:
for item in items:
lib.remove(item, delete)
lib.save()
with lib.transaction():
if album:
for al in albums:
al.remove(delete)
else:
for item in items:
lib.remove(item, delete)
remove_cmd = ui.Subcommand('remove',
help='remove matching items from the library', aliases=('rm',))
@@ -1007,16 +1046,18 @@ default_commands.append(stats_cmd)
# version: Show current beets version.
def show_version(lib, config, opts, args):
print 'beets version %s' % lib.beets.__version__
print_('beets version %s' % lib.beets.__version__)
# Show plugins.
names = []
for plugin in plugins.find_plugins():
modname = plugin.__module__
names.append(modname.split('.')[-1])
if names:
print 'plugins:', ', '.join(names)
print_('plugins:', ', '.join(names))
else:
print 'no plugins loaded'
print_('no plugins loaded')
version_cmd = ui.Subcommand('version',
help='output version information')
version_cmd.func = show_version
@@ -1061,23 +1102,23 @@ def modify_items(lib, mods, query, write, move, album, color, confirm):
return
# Apply changes to database.
for obj in objs:
for field, value in fsets.iteritems():
setattr(obj, field, value)
with lib.transaction():
for obj in objs:
for field, value in fsets.iteritems():
setattr(obj, field, value)
if move:
cur_path = obj.item_dir() if album else obj.path
if lib.directory in ancestry(cur_path): # In library?
log.debug('moving object %s' % cur_path)
if album:
obj.move()
else:
lib.move(obj)
if move:
cur_path = obj.item_dir() if album else obj.path
if lib.directory in ancestry(cur_path): # In library?
log.debug('moving object %s' % cur_path)
if album:
obj.move()
else:
lib.move(obj)
# When modifying items, we have to store them to the database.
if not album:
lib.store(obj)
lib.save()
# When modifying items, we have to store them to the database.
if not album:
lib.store(obj)
# Apply tags if requested.
if write:
@@ -1136,7 +1177,6 @@ def move_items(lib, dest, query, copy, album):
else:
lib.move(obj, copy, basedir=dest)
lib.store(obj)
lib.save()
move_cmd = ui.Subcommand('move',
help='move or copy items', aliases=('mv',))

View File

@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2011, Adrian Sampson.
# Copyright 2012, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -8,20 +8,102 @@
# 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.
"""Miscellaneous utility functions."""
from __future__ import division
import os
import sys
import re
import shutil
import fnmatch
from collections import defaultdict
import traceback
MAX_FILENAME_LENGTH = 200
class HumanReadableException(Exception):
"""An Exception that can include a human-readable error message to
be logged without a traceback. Can preserve a traceback for
debugging purposes as well.
Has at least two fields: `reason`, the underlying exception or a
string describing the problem; and `verb`, the action being
performed during the error.
If `tb` is provided, it is a string containing a traceback for the
associated exception. (Note that this is not necessary in Python 3.x
and should be removed when we make the transition.)
"""
error_kind = 'Error' # Human-readable description of error type.
def __init__(self, reason, verb, tb=None):
self.reason = reason
self.verb = verb
self.tb = tb
super(HumanReadableException, self).__init__(self.get_message())
def _gerund(self):
"""Generate a (likely) gerund form of the English verb.
"""
if ' ' in self.verb:
return self.verb
gerund = self.verb[:-1] if self.verb.endswith('e') else self.verb
gerund += 'ing'
return gerund
def _reasonstr(self):
"""Get the reason as a string."""
if isinstance(self.reason, basestring):
return self.reason
elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError
return self.reason.strerror
else:
return u'"{0}"'.format(self.reason)
def get_message(self):
"""Create the human-readable description of the error, sans
introduction.
"""
raise NotImplementedError
def log(self, logger):
"""Log to the provided `logger` a human-readable message as an
error and a verbose traceback as a debug message.
"""
if self.tb:
logger.debug(self.tb)
logger.error(u'{0}: {1}'.format(self.error_kind, self.args[0]))
class FilesystemError(HumanReadableException):
"""An error that occurred while performing a filesystem manipulation
via a function in this module. The `paths` field is a sequence of
pathnames involved in the operation.
"""
def __init__(self, reason, verb, paths, tb=None):
self.paths = paths
super(FilesystemError, self).__init__(reason, verb, tb)
def get_message(self):
# Use a nicer English phrasing for some specific verbs.
if self.verb in ('move', 'copy', 'rename'):
clause = 'while {0} {1} to {2}'.format(
self._gerund(), repr(self.paths[0]), repr(self.paths[1])
)
elif self.verb in ('delete',):
clause = 'while {0} {1}'.format(
self._gerund(), repr(self.paths[0])
)
else:
clause = 'during {0} of paths {1}'.format(
self.verb, u', '.join(repr(p) for p in self.paths)
)
return u'{0} {1}'.format(self._reasonstr(), clause)
def normpath(path):
"""Provide the canonical form of the path suitable for storing in
the database.
@@ -39,11 +121,11 @@ def ancestry(path, pathmod=None):
last_path = None
while path:
path = pathmod.dirname(path)
if path == last_path:
break
last_path = path
if path: # don't yield ''
out.insert(0, path)
return out
@@ -59,7 +141,9 @@ def sorted_walk(path, ignore=()):
# Get all the directories and files at this level.
dirs = []
files = []
for base in os.listdir(path):
for base in os.listdir(syspath(path)):
base = bytestring_path(base)
# Skip ignored filenames.
skip = False
for pat in ignore:
@@ -84,7 +168,7 @@ def sorted_walk(path, ignore=()):
# Recurse into directories.
for base in dirs:
cur = os.path.join(path, base)
# yield from _sorted_walk(cur)
# yield from sorted_walk(...)
for res in sorted_walk(cur, ignore):
yield res
@@ -149,13 +233,13 @@ def components(path, pathmod=None):
comp = pathmod.basename(anc)
if comp:
comps.append(comp)
else: # root
else: # root
comps.append(anc)
last = pathmod.basename(path)
if last:
comps.append(last)
return comps
def bytestring_path(path):
@@ -168,6 +252,13 @@ def bytestring_path(path):
# Try to encode with default encodings, but fall back to UTF8.
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
if encoding == 'mbcs':
# On Windows, a broken encoding known to Python as "MBCS" is
# used for the filesystem. However, we only use the Unicode API
# for Windows paths, so the encoding is actually immaterial so
# we can avoid dealing with this nastiness. We arbitrarily
# choose UTF-8.
encoding = 'utf8'
try:
return path.encode(encoding)
except (UnicodeError, LookupError):
@@ -202,12 +293,16 @@ def syspath(path, pathmod=None):
return path
if not isinstance(path, unicode):
# Try to decode with default encodings, but fall back to UTF8.
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
# Beets currently represents Windows paths internally with UTF-8
# arbitrarily. But earlier versions used MBCS because it is
# reported as the FS encoding by Windows. Try both.
try:
path = path.decode(encoding, 'replace')
path = path.decode('utf8')
except UnicodeError:
path = path.decode('utf8', 'replace')
# The encoding should always be MBCS, Windows' broken
# Unicode representation.
encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
path = path.decode(encoding, 'replace')
# Add the magic prefix if it isn't already there
if not path.startswith(u'\\\\?\\'):
@@ -219,42 +314,63 @@ def samefile(p1, p2):
"""Safer equality for paths."""
return shutil._samefile(syspath(p1), syspath(p2))
def soft_remove(path):
"""Remove the file if it exists."""
def remove(path, soft=True):
"""Remove the file. If `soft`, then no error will be raised if the
file does not exist.
"""
path = syspath(path)
if os.path.exists(path):
if soft and not os.path.exists(path):
return
try:
os.remove(path)
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
def _assert_not_exists(path, pathmod=None):
"""Raises an OSError if the path exists."""
pathmod = pathmod or os.path
if pathmod.exists(path):
raise OSError('file exists: %s' % path)
def copy(path, dest, replace=False, pathmod=None):
"""Copy a plain file. Permissions are not copied. If dest already
exists, raises an OSError unless replace is True. Has no effect if
path is the same as dest. Paths are translated to system paths
before the syscall.
def copy(path, dest, replace=False, pathmod=os.path):
"""Copy a plain file. Permissions are not copied. If `dest` already
exists, raises a FilesystemError unless `replace` is True. Has no
effect if `path` is the same as `dest`. Paths are translated to
system paths before the syscall.
"""
if samefile(path, dest):
return
path = syspath(path)
dest = syspath(dest)
_assert_not_exists(dest, pathmod)
return shutil.copyfile(path, dest)
if not replace and pathmod.exists(dest):
raise FilesystemError('file exists', 'copy', (path, dest))
try:
shutil.copyfile(path, dest)
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'copy', (path, dest),
traceback.format_exc())
def move(path, dest, replace=False, pathmod=None):
"""Rename a file. dest may not be a directory. If dest already
exists, raises an OSError unless replace is True. Hos no effect if
path is the same as dest. Paths are translated to system paths.
def move(path, dest, replace=False, pathmod=os.path):
"""Rename a file. `dest` may not be a directory. If `dest` already
exists, raises an OSError unless `replace` is True. Has no effect if
`path` is the same as `dest`. If the paths are on different
filesystems (or the rename otherwise fails), a copy is attempted
instead, in which case metadata will *not* be preserved. Paths are
translated to system paths.
"""
if samefile(path, dest):
return
path = syspath(path)
dest = syspath(dest)
_assert_not_exists(dest, pathmod)
return shutil.move(path, dest)
if pathmod.exists(dest):
raise FilesystemError('file exists', 'rename', (path, dest),
traceback.format_exc())
# First, try renaming the file.
try:
os.rename(path, dest)
except OSError:
# Otherwise, copy and delete the original.
try:
shutil.copyfile(path, dest)
os.remove(path)
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'move', (path, dest),
traceback.format_exc())
def unique_path(path):
"""Returns a version of ``path`` that does not exist on the
@@ -277,33 +393,33 @@ def unique_path(path):
if not os.path.exists(new_path):
return new_path
# Note: POSIX actually supports \ and : -- I just think they're
# a pain. And ? has caused problems for some.
# Note: The Windows "reserved characters" are, of course, allowed on
# Unix. They are forbidden here because they cause problems on Samba
# shares, which are sufficiently common as to cause frequent problems.
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
CHAR_REPLACE = [
(re.compile(r'[\\/\?"]|^\.'), '_'),
(re.compile(r':'), '-'),
]
CHAR_REPLACE_WINDOWS = [
(re.compile(r'["\*<>\|]|^\.|\.$|\s+$'), '_'),
(re.compile(ur'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
(re.compile(ur'^\.'), u'_'), # Leading dot (hidden files on Unix).
(re.compile(ur'[\x00-\x1f]'), u''), # Control characters.
(re.compile(ur'[<>:"\?\*\|]'), u'_'), # Windows "reserved characters".
(re.compile(ur'\.$'), u'_'), # Trailing dots.
(re.compile(ur'\s+$'), u''), # Trailing whitespace.
]
def sanitize_path(path, pathmod=None, replacements=None):
"""Takes a path and makes sure that it is legal. Returns a new path.
Only works with fragments; won't work reliably on Windows when a
path begins with a drive letter. Path separators (including altsep!)
should already be cleaned from the path components. If replacements
is specified, it is used *instead* of the default set of
replacements for the platform; it must be a list of (compiled regex,
replacement string) pairs.
"""Takes a path (as a Unicode string) and makes sure that it is
legal. Returns a new path. Only works with fragments; won't work
reliably on Windows when a path begins with a drive letter. Path
separators (including altsep!) should already be cleaned from the
path components. If replacements is specified, it is used *instead*
of the default set of replacements for the platform; it must be a
list of (compiled regex, replacement string) pairs.
"""
pathmod = pathmod or os.path
windows = pathmod.__name__ == 'ntpath'
# Choose the appropriate replacements.
if not replacements:
replacements = list(CHAR_REPLACE)
if windows:
replacements += CHAR_REPLACE_WINDOWS
comps = components(path, pathmod)
if not comps:
return ''
@@ -311,10 +427,10 @@ def sanitize_path(path, pathmod=None, replacements=None):
# Replace special characters.
for regex, repl in replacements:
comp = regex.sub(repl, comp)
# Truncate each component.
comp = comp[:MAX_FILENAME_LENGTH]
comps[i] = comp
return pathmod.join(*comps)
@@ -336,10 +452,10 @@ def sanitize_for_path(value, pathmod, key=None):
value = u'%02i' % (value or 0)
elif key == 'bitrate':
# Bitrate gets formatted as kbps.
value = u'%ikbps' % ((value or 0) / 1000)
value = u'%ikbps' % ((value or 0) // 1000)
elif key == 'samplerate':
# Sample rate formatted as kHz.
value = u'%ikHz' % ((value or 0) / 1000)
value = u'%ikHz' % ((value or 0) // 1000)
else:
value = unicode(value)
return value
@@ -360,7 +476,7 @@ def levenshtein(s1, s2):
return levenshtein(s2, s1)
if not s1:
return len(s2)
previous_row = xrange(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
@@ -370,7 +486,7 @@ def levenshtein(s1, s2):
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def plurality(objs):

562
lib/beets/util/bluelet.py Normal file
View File

@@ -0,0 +1,562 @@
"""Extremely simple pure-Python implementation of coroutine-style
asynchronous socket I/O. Inspired by, but inferior to, Eventlet.
Bluelet can also be thought of as a less-terrible replacement for
asyncore.
Bluelet: easy concurrency without all the messy parallelism.
"""
import socket
import select
import sys
import types
import errno
import traceback
import time
import collections
# A little bit of "six" (Python 2/3 compatibility): cope with PEP 3109 syntax
# changes.
PY3 = sys.version_info[0] == 3
if PY3:
def _reraise(typ, exc, tb):
raise exc.with_traceback(tb)
else:
exec("""
def _reraise(typ, exc, tb):
raise typ, exc, tb""")
# Basic events used for thread scheduling.
class Event(object):
"""Just a base class identifying Bluelet events. An event is an
object yielded from a Bluelet thread coroutine to suspend operation
and communicate with the scheduler.
"""
pass
class WaitableEvent(Event):
"""A waitable event is one encapsulating an action that can be
waited for using a select() call. That is, it's an event with an
associated file descriptor.
"""
def waitables(self):
"""Return "waitable" objects to pass to select(). Should return
three iterables for input readiness, output readiness, and
exceptional conditions (i.e., the three lists passed to
select()).
"""
return (), (), ()
def fire(self):
"""Called when an assoicated file descriptor becomes ready
(i.e., is returned from a select() call).
"""
pass
class ValueEvent(Event):
"""An event that does nothing but return a fixed value."""
def __init__(self, value):
self.value = value
class ExceptionEvent(Event):
"""Raise an exception at the yield point. Used internally."""
def __init__(self, exc_info):
self.exc_info = exc_info
class SpawnEvent(Event):
"""Add a new coroutine thread to the scheduler."""
def __init__(self, coro):
self.spawned = coro
class JoinEvent(Event):
"""Suspend the thread until the specified child thread has
completed.
"""
def __init__(self, child):
self.child = child
class DelegationEvent(Event):
"""Suspend execution of the current thread, start a new thread and,
once the child thread finished, return control to the parent
thread.
"""
def __init__(self, coro):
self.spawned = coro
class ReturnEvent(Event):
"""Return a value the current thread's delegator at the point of
delegation. Ends the current (delegate) thread.
"""
def __init__(self, value):
self.value = value
class SleepEvent(WaitableEvent):
"""Suspend the thread for a given duration.
"""
def __init__(self, duration):
self.wakeup_time = time.time() + duration
def time_left(self):
return max(self.wakeup_time - time.time(), 0.0)
class ReadEvent(WaitableEvent):
"""Reads from a file-like object."""
def __init__(self, fd, bufsize):
self.fd = fd
self.bufsize = bufsize
def waitables(self):
return (self.fd,), (), ()
def fire(self):
return self.fd.read(self.bufsize)
class WriteEvent(WaitableEvent):
"""Writes to a file-like object."""
def __init__(self, fd, data):
self.fd = fd
self.data = data
def waitable(self):
return (), (self.fd,), ()
def fire(self):
self.fd.write(self.data)
# Core logic for executing and scheduling threads.
def _event_select(events):
"""Perform a select() over all the Events provided, returning the
ones ready to be fired. Only WaitableEvents (including SleepEvents)
matter here; all other events are ignored (and thus postponed).
"""
# Gather waitables and wakeup times.
waitable_to_event = {}
rlist, wlist, xlist = [], [], []
earliest_wakeup = None
for event in events:
if isinstance(event, SleepEvent):
if not earliest_wakeup:
earliest_wakeup = event.wakeup_time
else:
earliest_wakeup = min(earliest_wakeup, event.wakeup_time)
elif isinstance(event, WaitableEvent):
r, w, x = event.waitables()
rlist += r
wlist += w
xlist += x
for waitable in r:
waitable_to_event[('r', waitable)] = event
for waitable in w:
waitable_to_event[('w', waitable)] = event
for waitable in x:
waitable_to_event[('x', waitable)] = event
# If we have a any sleeping threads, determine how long to sleep.
if earliest_wakeup:
timeout = max(earliest_wakeup - time.time(), 0.0)
else:
timeout = None
# Perform select() if we have any waitables.
if rlist or wlist or xlist:
rready, wready, xready = select.select(rlist, wlist, xlist, timeout)
else:
rready, wready, xready = (), (), ()
if timeout:
time.sleep(timeout)
# Gather ready events corresponding to the ready waitables.
ready_events = set()
for ready in rready:
ready_events.add(waitable_to_event[('r', ready)])
for ready in wready:
ready_events.add(waitable_to_event[('w', ready)])
for ready in xready:
ready_events.add(waitable_to_event[('x', ready)])
# Gather any finished sleeps.
for event in events:
if isinstance(event, SleepEvent) and event.time_left() == 0.0:
ready_events.add(event)
return ready_events
class ThreadException(Exception):
def __init__(self, coro, exc_info):
self.coro = coro
self.exc_info = exc_info
def reraise(self):
_reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2])
SUSPENDED = Event() # Special sentinel placeholder for suspended threads.
def run(root_coro):
"""Schedules a coroutine, running it to completion. This
encapsulates the Bluelet scheduler, which the root coroutine can
add to by spawning new coroutines.
"""
# The "threads" dictionary keeps track of all the currently-
# executing and suspended coroutines. It maps coroutines to their
# currently "blocking" event. The event value may be SUSPENDED if
# the coroutine is waiting on some other condition: namely, a
# delegated coroutine or a joined coroutine. In this case, the
# coroutine should *also* appear as a value in one of the below
# dictionaries `delegators` or `joiners`.
threads = {root_coro: ValueEvent(None)}
# Maps child coroutines to delegating parents.
delegators = {}
# Maps child coroutines to joining (exit-waiting) parents.
joiners = collections.defaultdict(list)
def complete_thread(coro, return_value):
"""Remove a coroutine from the scheduling pool, awaking
delegators and joiners as necessary and returning the specified
value to any delegating parent.
"""
del threads[coro]
# Resume delegator.
if coro in delegators:
threads[delegators[coro]] = ValueEvent(return_value)
del delegators[coro]
# Resume joiners.
if coro in joiners:
for parent in joiners[coro]:
threads[parent] = ValueEvent(None)
del joiners[coro]
def advance_thread(coro, value, is_exc=False):
"""After an event is fired, run a given coroutine associated with
it in the threads dict until it yields again. If the coroutine
exits, then the thread is removed from the pool. If the coroutine
raises an exception, it is reraised in a ThreadException. If
is_exc is True, then the value must be an exc_info tuple and the
exception is thrown into the coroutine.
"""
try:
if is_exc:
next_event = coro.throw(*value)
else:
next_event = coro.send(value)
except StopIteration:
# Thread is done.
complete_thread(coro, None)
except:
# Thread raised some other exception.
del threads[coro]
raise ThreadException(coro, sys.exc_info())
else:
if isinstance(next_event, types.GeneratorType):
# Automatically invoke sub-coroutines. (Shorthand for
# explicit bluelet.call().)
next_event = DelegationEvent(next_event)
threads[coro] = next_event
# Continue advancing threads until root thread exits.
exit_te = None
while threads:
try:
# Look for events that can be run immediately. Continue
# running immediate events until nothing is ready.
while True:
have_ready = False
for coro, event in list(threads.items()):
if isinstance(event, SpawnEvent):
threads[event.spawned] = ValueEvent(None) # Spawn.
advance_thread(coro, None)
have_ready = True
elif isinstance(event, ValueEvent):
advance_thread(coro, event.value)
have_ready = True
elif isinstance(event, ExceptionEvent):
advance_thread(coro, event.exc_info, True)
have_ready = True
elif isinstance(event, DelegationEvent):
threads[coro] = SUSPENDED # Suspend.
threads[event.spawned] = ValueEvent(None) # Spawn.
delegators[event.spawned] = coro
have_ready = True
elif isinstance(event, ReturnEvent):
# Thread is done.
complete_thread(coro, event.value)
have_ready = True
elif isinstance(event, JoinEvent):
threads[coro] = SUSPENDED # Suspend.
joiners[event.child].append(coro)
have_ready = True
# Only start the select when nothing else is ready.
if not have_ready:
break
# Wait and fire.
event2coro = dict((v,k) for k,v in threads.items())
for event in _event_select(threads.values()):
# Run the IO operation, but catch socket errors.
try:
value = event.fire()
except socket.error as exc:
if isinstance(exc.args, tuple) and \
exc.args[0] == errno.EPIPE:
# Broken pipe. Remote host disconnected.
pass
else:
traceback.print_exc()
# Abort the coroutine.
threads[event2coro[event]] = ReturnEvent(None)
else:
advance_thread(event2coro[event], value)
except ThreadException as te:
# Exception raised from inside a thread.
event = ExceptionEvent(te.exc_info)
if te.coro in delegators:
# The thread is a delegate. Raise exception in its
# delegator.
threads[delegators[te.coro]] = event
del delegators[te.coro]
else:
# The thread is root-level. Raise in client code.
exit_te = te
break
except:
# For instance, KeyboardInterrupt during select(). Raise
# into root thread and terminate others.
threads = {root_coro: ExceptionEvent(sys.exc_info())}
# If any threads still remain, kill them.
for coro in threads:
coro.close()
# If we're exiting with an exception, raise it in the client.
if exit_te:
exit_te.reraise()
# Sockets and their associated events.
class Listener(object):
"""A socket wrapper object for listening sockets.
"""
def __init__(self, host, port):
"""Create a listening socket on the given hostname and port.
"""
self.host = host
self.port = port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((host, port))
self.sock.listen(5)
def accept(self):
"""An event that waits for a connection on the listening socket.
When a connection is made, the event returns a Connection
object.
"""
return AcceptEvent(self)
def close(self):
"""Immediately close the listening socket. (Not an event.)
"""
self.sock.close()
class Connection(object):
"""A socket wrapper object for connected sockets.
"""
def __init__(self, sock, addr):
self.sock = sock
self.addr = addr
self._buf = b''
def close(self):
"""Close the connection."""
self.sock.close()
def recv(self, size):
"""Read at most size bytes of data from the socket."""
if self._buf:
# We already have data read previously.
out = self._buf[:size]
self._buf = self._buf[size:]
return ValueEvent(out)
else:
return ReceiveEvent(self, size)
def send(self, data):
"""Sends data on the socket, returning the number of bytes
successfully sent.
"""
return SendEvent(self, data)
def sendall(self, data):
"""Send all of data on the socket."""
return SendEvent(self, data, True)
def readline(self, terminator=b"\n", bufsize=1024):
"""Reads a line (delimited by terminator) from the socket."""
while True:
if terminator in self._buf:
line, self._buf = self._buf.split(terminator, 1)
line += terminator
yield ReturnEvent(line)
break
data = yield ReceiveEvent(self, bufsize)
if data:
self._buf += data
else:
line = self._buf
self._buf = b''
yield ReturnEvent(line)
break
class AcceptEvent(WaitableEvent):
"""An event for Listener objects (listening sockets) that suspends
execution until the socket gets a connection.
"""
def __init__(self, listener):
self.listener = listener
def waitables(self):
return (self.listener.sock,), (), ()
def fire(self):
sock, addr = self.listener.sock.accept()
return Connection(sock, addr)
class ReceiveEvent(WaitableEvent):
"""An event for Connection objects (connected sockets) for
asynchronously reading data.
"""
def __init__(self, conn, bufsize):
self.conn = conn
self.bufsize = bufsize
def waitables(self):
return (self.conn.sock,), (), ()
def fire(self):
return self.conn.sock.recv(self.bufsize)
class SendEvent(WaitableEvent):
"""An event for Connection objects (connected sockets) for
asynchronously writing data.
"""
def __init__(self, conn, data, sendall=False):
self.conn = conn
self.data = data
self.sendall = sendall
def waitables(self):
return (), (self.conn.sock,), ()
def fire(self):
if self.sendall:
return self.conn.sock.sendall(self.data)
else:
return self.conn.sock.send(self.data)
# Public interface for threads; each returns an event object that
# can immediately be "yield"ed.
def null():
"""Event: yield to the scheduler without doing anything special.
"""
return ValueEvent(None)
def spawn(coro):
"""Event: add another coroutine to the scheduler. Both the parent
and child coroutines run concurrently.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % str(coro))
return SpawnEvent(coro)
def call(coro):
"""Event: delegate to another coroutine. The current coroutine
is resumed once the sub-coroutine finishes. If the sub-coroutine
returns a value using end(), then this event returns that value.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % str(coro))
return DelegationEvent(coro)
def end(value = None):
"""Event: ends the coroutine and returns a value to its
delegator.
"""
return ReturnEvent(value)
def read(fd, bufsize = None):
"""Event: read from a file descriptor asynchronously."""
if bufsize is None:
# Read all.
def reader():
buf = []
while True:
data = yield read(fd, 1024)
if not data:
break
buf.append(data)
yield ReturnEvent(''.join(buf))
return DelegationEvent(reader())
else:
return ReadEvent(fd, bufsize)
def write(fd, data):
"""Event: write to a file descriptor asynchronously."""
return WriteEvent(fd, data)
def connect(host, port):
"""Event: connect to a network address and return a Connection
object for communicating on the socket.
"""
addr = (host, port)
sock = socket.create_connection(addr)
return ValueEvent(Connection(sock, addr))
def sleep(duration):
"""Event: suspend the thread for ``duration`` seconds.
"""
return SleepEvent(duration)
def join(coro):
"""Suspend the thread until another, previously `spawn`ed thread
completes.
"""
return JoinEvent(coro)
# Convenience function for running socket servers.
def server(host, port, func):
"""A coroutine that runs a network server. Host and port specify the
listening address. func should be a coroutine that takes a single
parameter, a Connection object. The coroutine is invoked for every
incoming connection on the listening socket.
"""
def handler(conn):
try:
yield func(conn)
finally:
conn.close()
listener = Listener(host, port)
try:
while True:
conn = yield listener.accept()
yield spawn(handler(conn))
except KeyboardInterrupt:
pass
finally:
listener.close()

View File

@@ -8,7 +8,7 @@
# 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.
@@ -35,7 +35,7 @@ how you would expect them to.
'west'
>>> Direction.north < Direction.west
True
Enumerations are classes; their instances represent the possible values
of the enumeration. Because Python classes must have names, you may
provide a `name` parameter to `enum`; if you don't, a meaningless one
@@ -45,31 +45,31 @@ import random
class Enumeration(type):
"""A metaclass whose classes are enumerations.
The `values` attribute of the class is used to populate the
enumeration. Values may either be a list of enumerated names or a
string containing a space-separated list of names. When the class
is created, it is instantiated for each name value in `values`.
Each such instance is the name of the enumerated item as the sole
argument.
The `Enumerated` class is a good choice for a superclass.
"""
def __init__(cls, name, bases, dic):
super(Enumeration, cls).__init__(name, bases, dic)
if 'values' not in dic:
# Do nothing if no values are provided (i.e., with
# Enumerated itself).
return
# May be called with a single string, in which case we split on
# whitespace for convenience.
values = dic['values']
if isinstance(values, basestring):
values = values.split()
# Create the Enumerated instances for each value. We have to use
# super's __setattr__ here because we disallow setattr below.
super(Enumeration, cls).__setattr__('_items_dict', {})
@@ -78,56 +78,56 @@ class Enumeration(type):
item = cls(value, len(cls._items_list))
cls._items_dict[value] = item
cls._items_list.append(item)
def __getattr__(cls, key):
try:
return cls._items_dict[key]
except KeyError:
raise AttributeError("enumeration '" + cls.__name__ +
"' has no item '" + key + "'")
def __setattr__(cls, key, val):
raise TypeError("enumerations do not support attribute assignment")
def __getitem__(cls, key):
if isinstance(key, int):
return cls._items_list[key]
else:
return getattr(cls, key)
def __len__(cls):
return len(cls._items_list)
def __iter__(cls):
return iter(cls._items_list)
def __nonzero__(cls):
# Ensures that __len__ doesn't get called before __init__ by
# pydoc.
return True
class Enumerated(object):
"""An item in an enumeration.
Contains instance methods inherited by enumerated objects. The
metaclass is preset to `Enumeration` for your convenience.
Instance attributes:
Instance attributes:
name -- The name of the item.
index -- The index of the item in its enumeration.
>>> from enumeration import Enumerated
>>> class Garment(Enumerated):
... values = 'hat glove belt poncho lederhosen suspenders'
... def wear(self):
... print 'now wearing a ' + self.name
... print('now wearing a ' + self.name)
...
>>> Garment.poncho.wear()
now wearing a poncho
"""
__metaclass__ = Enumeration
def __init__(self, name, index):
self.name = name
self.index = index
@@ -149,18 +149,18 @@ class Enumerated(object):
def enum(*values, **kwargs):
"""Shorthand for creating a new Enumeration class.
Call with enumeration values as a list, a space-delimited string, or
just an argument list. To give the class a name, pass it as the
`name` keyword argument. Otherwise, a name will be chosen for you.
The following are all equivalent:
enum('pinkie ring middle index thumb')
enum('pinkie', 'ring', 'middle', 'index', 'thumb')
enum(['pinkie', 'ring', 'middle', 'index', 'thumb'])
"""
if ('name' not in kwargs) or kwargs['name'] is None:
# Create a probably-unique name. It doesn't really have to be
# unique, but getting distinct names each time helps with
@@ -168,11 +168,11 @@ def enum(*values, **kwargs):
name = 'Enumeration' + hex(random.randint(0,0xfffffff))[2:].upper()
else:
name = kwargs['name']
if len(values) == 1:
# If there's only one value, we have a couple of alternate calling
# styles.
if isinstance(values[0], basestring) or hasattr(values[0], '__iter__'):
values = values[0]
return type(name, (Enumerated,), {'values': values})

View File

@@ -8,7 +8,7 @@
# 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.
@@ -25,7 +25,12 @@ library: unknown symbols are left intact.
This is sort of like a tiny, horrible degeneration of a real templating
engine like Jinja2 or Mustache.
"""
from __future__ import print_function
import re
import ast
import dis
import types
SYMBOL_DELIM = u'$'
FUNC_DELIM = u'%'
@@ -34,6 +39,9 @@ GROUP_CLOSE = u'}'
ARG_SEP = u','
ESCAPE_CHAR = u'$'
VARIABLE_PREFIX = '__var_'
FUNCTION_PREFIX = '__func_'
class Environment(object):
"""Contains the values and functions to be substituted into a
template.
@@ -42,6 +50,88 @@ class Environment(object):
self.values = values
self.functions = functions
# Code generation helpers.
def ex_lvalue(name):
"""A variable load expression."""
return ast.Name(name, ast.Store())
def ex_rvalue(name):
"""A variable store expression."""
return ast.Name(name, ast.Load())
def ex_literal(val):
"""An int, float, long, bool, string, or None literal with the given
value.
"""
if val is None:
return ast.Name('None', ast.Load())
elif isinstance(val, (int, float, long)):
return ast.Num(val)
elif isinstance(val, bool):
return ast.Name(str(val), ast.Load())
elif isinstance(val, basestring):
return ast.Str(val)
raise TypeError('no literal for {0}'.format(type(val)))
def ex_varassign(name, expr):
"""Assign an expression into a single variable. The expression may
either be an `ast.expr` object or a value to be used as a literal.
"""
if not isinstance(expr, ast.expr):
expr = ex_literal(expr)
return ast.Assign([ex_lvalue(name)], expr)
def ex_call(func, args):
"""A function-call expression with only positional parameters. The
function may be an expression or the name of a function. Each
argument may be an expression or a value to be used as a literal.
"""
if isinstance(func, basestring):
func = ex_rvalue(func)
args = list(args)
for i in range(len(args)):
if not isinstance(args[i], ast.expr):
args[i] = ex_literal(args[i])
return ast.Call(func, args, [], None, None)
def compile_func(arg_names, statements, name='_the_func', debug=False):
"""Compile a list of statements as the body of a function and return
the resulting Python function. If `debug`, then print out the
bytecode of the compiled function.
"""
func_def = ast.FunctionDef(
name,
ast.arguments(
[ast.Name(n, ast.Param()) for n in arg_names],
None, None,
[ex_literal(None) for _ in arg_names],
),
statements,
[],
)
mod = ast.Module([func_def])
ast.fix_missing_locations(mod)
prog = compile(mod, '<generated>', 'exec')
# Debug: show bytecode.
if debug:
dis.dis(prog)
for const in prog.co_consts:
if isinstance(const, types.CodeType):
dis.dis(const)
the_locals = {}
exec prog in {}, the_locals
return the_locals[name]
# AST nodes for the template language.
class Symbol(object):
"""A variable-substitution symbol in a template."""
def __init__(self, ident, original):
@@ -62,6 +152,11 @@ class Symbol(object):
# Keep original text.
return self.original
def translate(self):
"""Compile the variable lookup."""
expr = ex_rvalue(VARIABLE_PREFIX + self.ident.encode('utf8'))
return [expr], set([self.ident.encode('utf8')]), set()
class Call(object):
"""A function call in a template."""
def __init__(self, ident, args, original):
@@ -81,7 +176,7 @@ class Call(object):
arg_vals = [expr.evaluate(env) for expr in self.args]
try:
out = env.functions[self.ident](*arg_vals)
except Exception, exc:
except Exception as exc:
# Function raised exception! Maybe inlining the name of
# the exception will help debug.
return u'<%s>' % unicode(exc)
@@ -89,6 +184,36 @@ class Call(object):
else:
return self.original
def translate(self):
"""Compile the function call."""
varnames = set()
funcnames = set([self.ident.encode('utf8')])
arg_exprs = []
for arg in self.args:
subexprs, subvars, subfuncs = arg.translate()
varnames.update(subvars)
funcnames.update(subfuncs)
# Create a subexpression that joins the result components of
# the arguments.
arg_exprs.append(ex_call(
ast.Attribute(ex_literal(u''), 'join', ast.Load()),
[ex_call(
'map',
[
ex_rvalue('unicode'),
ast.List(subexprs, ast.Load()),
]
)],
))
subexpr_call = ex_call(
FUNCTION_PREFIX + self.ident.encode('utf8'),
arg_exprs
)
return [subexpr_call], varnames, funcnames
class Expression(object):
"""Top-level template construct: contains a list of text blobs,
Symbols, and Calls.
@@ -111,6 +236,26 @@ class Expression(object):
out.append(part.evaluate(env))
return u''.join(map(unicode, out))
def translate(self):
"""Compile the expression to a list of Python AST expressions, a
set of variable names used, and a set of function names.
"""
expressions = []
varnames = set()
funcnames = set()
for part in self.parts:
if isinstance(part, basestring):
expressions.append(ex_literal(part))
else:
e, v, f = part.translate()
expressions.extend(e)
varnames.update(v)
funcnames.update(f)
return expressions, varnames, funcnames
# Parser.
class ParseError(Exception):
pass
@@ -266,7 +411,7 @@ class Parser(object):
# No function name.
self.parts.append(FUNC_DELIM)
return
if self.pos >= len(self.string):
# Identifier terminates string.
self.parts.append(self.string[start_pos:self.pos])
@@ -304,7 +449,7 @@ class Parser(object):
# Extract and advance past the parsed expression.
expressions.append(Expression(subparser.parts))
self.pos += subparser.pos
self.pos += subparser.pos
if self.pos >= len(self.string) or \
self.string[self.pos] == GROUP_CLOSE:
@@ -340,14 +485,74 @@ def _parse(template):
parts.append(remainder)
return Expression(parts)
# External interface.
class Template(object):
"""A string template, including text, Symbols, and Calls.
"""
def __init__(self, template):
self.expr = _parse(template)
self.original = template
self.compiled = self.translate()
def interpret(self, values={}, functions={}):
"""Like `substitute`, but forces the interpreter (rather than
the compiled version) to be used. The interpreter includes
exception-handling code for missing variables and buggy template
functions but is much slower.
"""
return self.expr.evaluate(Environment(values, functions))
def substitute(self, values={}, functions={}):
"""Evaluate the template given the values and functions.
"""
return self.expr.evaluate(Environment(values, functions))
try:
res = self.compiled(values, functions)
except: # Handle any exceptions thrown by compiled version.
res = self.interpret(values, functions)
return res
def translate(self):
"""Compile the template to a Python function."""
expressions, varnames, funcnames = self.expr.translate()
argnames = []
for varname in varnames:
argnames.append(VARIABLE_PREFIX.encode('utf8') + varname)
for funcname in funcnames:
argnames.append(FUNCTION_PREFIX.encode('utf8') + funcname)
func = compile_func(
argnames,
[ast.Return(ast.List(expressions, ast.Load()))],
)
def wrapper_func(values={}, functions={}):
args = {}
for varname in varnames:
args[VARIABLE_PREFIX + varname] = values[varname]
for funcname in funcnames:
args[FUNCTION_PREFIX + funcname] = functions[funcname]
parts = func(**args)
return u''.join(parts)
return wrapper_func
# Performance tests.
if __name__ == '__main__':
import timeit
_tmpl = Template(u'foo $bar %baz{foozle $bar barzle} $bar')
_vars = {'bar': 'qux'}
_funcs = {'baz': unicode.upper}
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
print(interp_time)
comp_time = timeit.timeit('_tmpl.substitute(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
print(comp_time)
print('Speedup:', interp_time / comp_time)

View File

@@ -8,7 +8,7 @@
# 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.
@@ -30,7 +30,8 @@ up a bottleneck stage by dividing its work among multiple threads.
To do so, pass an iterable of coroutines to the Pipeline constructor
in place of any single coroutine.
"""
from __future__ import with_statement # for Python 2.5
from __future__ import print_function
import Queue
from threading import Thread, Lock
import sys
@@ -177,23 +178,23 @@ class FirstPipelineThread(PipelineThread):
self.coro = coro
self.out_queue = out_queue
self.out_queue.acquire()
self.abort_lock = Lock()
self.abort_flag = False
def run(self):
try:
while True:
with self.abort_lock:
if self.abort_flag:
return
# Get the value from the generator.
try:
msg = self.coro.next()
except StopIteration:
break
# Send messages to the next stage.
for msg in _allmsgs(msg):
with self.abort_lock:
@@ -207,7 +208,7 @@ class FirstPipelineThread(PipelineThread):
# Generator finished; shut down the pipeline.
self.out_queue.release()
class MiddlePipelineThread(PipelineThread):
"""A thread running any stage in the pipeline except the first or
last.
@@ -223,7 +224,7 @@ class MiddlePipelineThread(PipelineThread):
try:
# Prime the coroutine.
self.coro.next()
while True:
with self.abort_lock:
if self.abort_flag:
@@ -233,14 +234,14 @@ class MiddlePipelineThread(PipelineThread):
msg = self.in_queue.get()
if msg is POISON:
break
with self.abort_lock:
if self.abort_flag:
return
# Invoke the current stage.
out = self.coro.send(msg)
# Send messages to next stage.
for msg in _allmsgs(out):
with self.abort_lock:
@@ -251,7 +252,7 @@ class MiddlePipelineThread(PipelineThread):
except:
self.abort_all(sys.exc_info())
return
# Pipeline is shutting down normally.
self.out_queue.release()
@@ -273,12 +274,12 @@ class LastPipelineThread(PipelineThread):
with self.abort_lock:
if self.abort_flag:
return
# Get the message from the previous stage.
msg = self.in_queue.get()
if msg is POISON:
break
with self.abort_lock:
if self.abort_flag:
return
@@ -308,7 +309,7 @@ class Pipeline(object):
self.stages.append((stage,))
else:
self.stages.append(stage)
def run_sequential(self):
"""Run the pipeline sequentially in the current thread. The
stages are run one after the other. Only the first coroutine
@@ -319,7 +320,7 @@ class Pipeline(object):
# "Prime" the coroutines.
for coro in coros[1:]:
coro.next()
# Begin the pipeline.
for out in coros[0]:
msgs = _allmsgs(out)
@@ -329,7 +330,7 @@ class Pipeline(object):
out = coro.send(msg)
next_msgs.extend(_allmsgs(out))
msgs = next_msgs
def run_parallel(self, queue_size=DEFAULT_QUEUE_SIZE):
"""Run the pipeline in parallel using one thread per stage. The
messages between the stages are stored in queues of the given
@@ -354,11 +355,11 @@ class Pipeline(object):
threads.append(
LastPipelineThread(coro, queues[-1], threads)
)
# Start threads.
for thread in threads:
thread.start()
# Wait for termination. The final thread lasts the longest.
try:
# Using a timeout allows us to receive KeyboardInterrupt
@@ -371,7 +372,7 @@ class Pipeline(object):
for thread in threads:
thread.abort()
raise
finally:
# Make completely sure that all the threads have finished
# before we return. They should already be either finished,
@@ -388,25 +389,25 @@ class Pipeline(object):
# Smoke test.
if __name__ == '__main__':
import time
# Test a normally-terminating pipeline both in sequence and
# in parallel.
def produce():
for i in range(5):
print 'generating %i' % i
print('generating %i' % i)
time.sleep(1)
yield i
def work():
num = yield
while True:
print 'processing %i' % num
print('processing %i' % num)
time.sleep(2)
num = yield num*2
def consume():
while True:
num = yield
time.sleep(1)
print 'received %i' % num
print('received %i' % num)
ts_start = time.time()
Pipeline([produce(), work(), consume()]).run_sequential()
ts_seq = time.time()
@@ -414,21 +415,21 @@ if __name__ == '__main__':
ts_par = time.time()
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
ts_end = time.time()
print 'Sequential time:', ts_seq - ts_start
print 'Parallel time:', ts_par - ts_seq
print 'Multiply-parallel time:', ts_end - ts_par
print
print('Sequential time:', ts_seq - ts_start)
print('Parallel time:', ts_par - ts_seq)
print('Multiply-parallel time:', ts_end - ts_par)
print()
# Test a pipeline that raises an exception.
def exc_produce():
for i in range(10):
print 'generating %i' % i
print('generating %i' % i)
time.sleep(1)
yield i
def exc_work():
num = yield
while True:
print 'processing %i' % num
print('processing %i' % num)
time.sleep(3)
if num == 3:
raise Exception()
@@ -438,5 +439,5 @@ if __name__ == '__main__':
num = yield
#if num == 4:
# raise Exception()
print 'received %i' % num
print('received %i' % num)
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)

View File

@@ -8,7 +8,7 @@
# 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.