Update vendored beets to 1.6.0

Updates colorama to 0.4.6
Adds confuse version 1.7.0
Updates jellyfish to 0.9.0
Adds mediafile 0.10.1
Updates munkres to 1.1.4
Updates musicbrainzngs to 0.7.1
Updates mutagen to 1.46.0
Updates pyyaml to 6.0
Updates unidecode to 1.3.6
This commit is contained in:
Labrys of Knossos 2022-11-28 18:02:40 -05:00
commit 56c6773c6b
385 changed files with 25143 additions and 18080 deletions

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -15,7 +14,6 @@
"""A namespace package for beets plugins."""
from __future__ import division, absolute_import, print_function
# Make this a namespace package.
from pkgutil import extend_path

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Pieter Mulder.
#
@ -16,7 +15,6 @@
"""Calculate acoustic information and submit to AcousticBrainz.
"""
from __future__ import division, absolute_import, print_function
import errno
import hashlib
@ -32,6 +30,9 @@ from beets import plugins
from beets import util
from beets import ui
# We use this field to check whether AcousticBrainz info is present.
PROBE_FIELD = 'mood_acoustic'
class ABSubmitError(Exception):
"""Raised when failing to analyse file with extractor."""
@ -43,19 +44,23 @@ def call(args):
Raise a AnalysisABSubmitError on failure.
"""
try:
return util.command_output(args)
return util.command_output(args).stdout
except subprocess.CalledProcessError as e:
raise ABSubmitError(
u'{0} exited with status {1}'.format(args[0], e.returncode)
'{} exited with status {}'.format(args[0], e.returncode)
)
class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def __init__(self):
super(AcousticBrainzSubmitPlugin, self).__init__()
super().__init__()
self.config.add({'extractor': u''})
self.config.add({
'extractor': '',
'force': False,
'pretend': False
})
self.extractor = self.config['extractor'].as_str()
if self.extractor:
@ -63,7 +68,7 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
# Expicit path to extractor
if not os.path.isfile(self.extractor):
raise ui.UserError(
u'Extractor command does not exist: {0}.'.
'Extractor command does not exist: {0}.'.
format(self.extractor)
)
else:
@ -73,8 +78,8 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
call([self.extractor])
except OSError:
raise ui.UserError(
u'No extractor command found: please install the '
u'extractor binary from http://acousticbrainz.org/download'
'No extractor command found: please install the extractor'
' binary from https://acousticbrainz.org/download'
)
except ABSubmitError:
# Extractor found, will exit with an error if not called with
@ -96,7 +101,18 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def commands(self):
cmd = ui.Subcommand(
'absubmit',
help=u'calculate and submit AcousticBrainz analysis'
help='calculate and submit AcousticBrainz analysis'
)
cmd.parser.add_option(
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help='re-download data when already present'
)
cmd.parser.add_option(
'-p', '--pretend', dest='pretend_fetch',
action='store_true', default=False,
help='pretend to perform action, but show \
only files which would be processed'
)
cmd.func = self.command
return [cmd]
@ -104,17 +120,30 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
for item in items:
analysis = self._get_analysis(item)
if analysis:
self._submit_data(item, analysis)
self.opts = opts
util.par_map(self.analyze_submit, items)
def analyze_submit(self, item):
analysis = self._get_analysis(item)
if analysis:
self._submit_data(item, analysis)
def _get_analysis(self, item):
mbid = item['mb_trackid']
# If file has no mbid skip it.
# Avoid re-analyzing files that already have AB data.
if not self.opts.force_refetch and not self.config['force']:
if item.get(PROBE_FIELD):
return None
# If file has no MBID, skip it.
if not mbid:
self._log.info(u'Not analysing {}, missing '
u'musicbrainz track id.', item)
self._log.info('Not analysing {}, missing '
'musicbrainz track id.', item)
return None
if self.opts.pretend_fetch or self.config['pretend']:
self._log.info('pretend action - extract item: {}', item)
return None
# Temporary file to save extractor output to, extractor only works
@ -129,11 +158,11 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
call([self.extractor, util.syspath(item.path), filename])
except ABSubmitError as e:
self._log.warning(
u'Failed to analyse {item} for AcousticBrainz: {error}',
'Failed to analyse {item} for AcousticBrainz: {error}',
item=item, error=e
)
return None
with open(filename, 'rb') as tmp_file:
with open(filename) as tmp_file:
analysis = json.load(tmp_file)
# Add the hash to the output.
analysis['metadata']['version']['essentia_build_sha'] = \
@ -157,11 +186,11 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
try:
message = response.json()['message']
except (ValueError, KeyError) as e:
message = u'unable to get error message: {}'.format(e)
message = f'unable to get error message: {e}'
self._log.error(
u'Failed to submit AcousticBrainz analysis of {item}: '
u'{message}).', item=item, message=message
'Failed to submit AcousticBrainz analysis of {item}: '
'{message}).', item=item, message=message
)
else:
self._log.debug(u'Successfully submitted AcousticBrainz analysis '
u'for {}.', item)
self._log.debug('Successfully submitted AcousticBrainz analysis '
'for {}.', item)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2015-2016, Ohm Patel.
#
@ -15,12 +14,13 @@
"""Fetch various AcousticBrainz metadata using MBID.
"""
from __future__ import division, absolute_import, print_function
from collections import defaultdict
import requests
from collections import defaultdict
from beets import plugins, ui
from beets.dbcore import types
ACOUSTIC_BASE = "https://acousticbrainz.org/"
LEVELS = ["/low-level", "/high-level"]
@ -72,6 +72,9 @@ ABSCHEME = {
'sad': 'mood_sad'
}
},
'moods_mirex': {
'value': 'moods_mirex'
},
'ismir04_rhythm': {
'value': 'rhythm'
},
@ -80,6 +83,9 @@ ABSCHEME = {
'tonal': 'tonal'
}
},
'timbre': {
'value': 'timbre'
},
'voice_instrumental': {
'value': 'voice_instrumental'
},
@ -104,8 +110,33 @@ ABSCHEME = {
class AcousticPlugin(plugins.BeetsPlugin):
item_types = {
'average_loudness': types.Float(6),
'chords_changes_rate': types.Float(6),
'chords_key': types.STRING,
'chords_number_rate': types.Float(6),
'chords_scale': types.STRING,
'danceable': types.Float(6),
'gender': types.STRING,
'genre_rosamerica': types.STRING,
'initial_key': types.STRING,
'key_strength': types.Float(6),
'mood_acoustic': types.Float(6),
'mood_aggressive': types.Float(6),
'mood_electronic': types.Float(6),
'mood_happy': types.Float(6),
'mood_party': types.Float(6),
'mood_relaxed': types.Float(6),
'mood_sad': types.Float(6),
'moods_mirex': types.STRING,
'rhythm': types.Float(6),
'timbre': types.STRING,
'tonal': types.Float(6),
'voice_instrumental': types.STRING,
}
def __init__(self):
super(AcousticPlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
@ -119,11 +150,11 @@ class AcousticPlugin(plugins.BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('acousticbrainz',
help=u"fetch metadata from AcousticBrainz")
help="fetch metadata from AcousticBrainz")
cmd.parser.add_option(
u'-f', u'--force', dest='force_refetch',
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help=u're-download data when already present'
help='re-download data when already present'
)
def func(lib, opts, args):
@ -142,22 +173,22 @@ class AcousticPlugin(plugins.BeetsPlugin):
def _get_data(self, mbid):
data = {}
for url in _generate_urls(mbid):
self._log.debug(u'fetching URL: {}', url)
self._log.debug('fetching URL: {}', url)
try:
res = requests.get(url)
except requests.RequestException as exc:
self._log.info(u'request error: {}', exc)
self._log.info('request error: {}', exc)
return {}
if res.status_code == 404:
self._log.info(u'recording ID {} not found', mbid)
self._log.info('recording ID {} not found', mbid)
return {}
try:
data.update(res.json())
except ValueError:
self._log.debug(u'Invalid Response: {}', res.text)
self._log.debug('Invalid Response: {}', res.text)
return {}
return data
@ -172,28 +203,28 @@ class AcousticPlugin(plugins.BeetsPlugin):
# representative field name to check for previously fetched
# data.
if not force:
mood_str = item.get('mood_acoustic', u'')
mood_str = item.get('mood_acoustic', '')
if mood_str:
self._log.info(u'data already present for: {}', item)
self._log.info('data already present for: {}', item)
continue
# We can only fetch data for tracks with MBIDs.
if not item.mb_trackid:
continue
self._log.info(u'getting data for: {}', item)
self._log.info('getting data for: {}', item)
data = self._get_data(item.mb_trackid)
if data:
for attr, val in self._map_data_to_scheme(data, ABSCHEME):
if not tags or attr in tags:
self._log.debug(u'attribute {} of {} set to {}',
self._log.debug('attribute {} of {} set to {}',
attr,
item,
val)
setattr(item, attr, val)
else:
self._log.debug(u'skipping attribute {} of {}'
u' (value {}) due to config',
self._log.debug('skipping attribute {} of {}'
' (value {}) due to config',
attr,
item,
val)
@ -255,10 +286,9 @@ class AcousticPlugin(plugins.BeetsPlugin):
# The recursive traversal.
composites = defaultdict(list)
for attr, val in self._data_to_scheme_child(data,
scheme,
composites):
yield attr, val
yield from self._data_to_scheme_child(data,
scheme,
composites)
# When composites has been populated, yield the composite attributes
# by joining their parts.
@ -278,10 +308,9 @@ class AcousticPlugin(plugins.BeetsPlugin):
for k, v in subscheme.items():
if k in subdata:
if type(v) == dict:
for attr, val in self._data_to_scheme_child(subdata[k],
v,
composites):
yield attr, val
yield from self._data_to_scheme_child(subdata[k],
v,
composites)
elif type(v) == tuple:
composite_attribute, part_number = v
attribute_parts = composites[composite_attribute]
@ -292,10 +321,10 @@ class AcousticPlugin(plugins.BeetsPlugin):
else:
yield v, subdata[k]
else:
self._log.warning(u'Acousticbrainz did not provide info'
u'about {}', k)
self._log.debug(u'Data {} could not be mapped to scheme {} '
u'because key {} was not found', subdata, v, k)
self._log.warning('Acousticbrainz did not provide info'
'about {}', k)
self._log.debug('Data {} could not be mapped to scheme {} '
'because key {} was not found', subdata, v, k)
def _generate_urls(mbid):

View file

@ -0,0 +1,65 @@
# This file is part of beets.
# Copyright 2021, Edgars Supe.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds an album template field for formatted album types."""
from beets.autotag.mb import VARIOUS_ARTISTS_ID
from beets.library import Album
from beets.plugins import BeetsPlugin
class AlbumTypesPlugin(BeetsPlugin):
"""Adds an album template field for formatted album types."""
def __init__(self):
"""Init AlbumTypesPlugin."""
super().__init__()
self.album_template_fields['atypes'] = self._atypes
self.config.add({
'types': [
('ep', 'EP'),
('single', 'Single'),
('soundtrack', 'OST'),
('live', 'Live'),
('compilation', 'Anthology'),
('remix', 'Remix')
],
'ignore_va': ['compilation'],
'bracket': '[]'
})
def _atypes(self, item: Album):
"""Returns a formatted string based on album's types."""
types = self.config['types'].as_pairs()
ignore_va = self.config['ignore_va'].as_str_seq()
bracket = self.config['bracket'].as_str()
# Assign a left and right bracket or leave blank if argument is empty.
if len(bracket) == 2:
bracket_l = bracket[0]
bracket_r = bracket[1]
else:
bracket_l = ''
bracket_r = ''
res = ''
albumtypes = item.albumtypes.split('; ')
is_va = item.mb_albumartistid == VARIOUS_ARTISTS_ID
for type in types:
if type[0] in albumtypes and type[1]:
if not is_va or (type[0] not in ignore_va and is_va):
res += f'{bracket_l}{type[1]}{bracket_r}'
return res

View file

@ -0,0 +1,984 @@
# This file is part of beets.
# Copyright 2020, Callum Brown.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""An AURA server using Flask."""
from mimetypes import guess_type
import re
import os.path
from os.path import isfile, getsize
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, _open_library
from beets import config
from beets.util import py3_path
from beets.library import Item, Album
from beets.dbcore.query import (
MatchQuery,
NotQuery,
RegexpQuery,
AndQuery,
FixedFieldSort,
SlowFieldSort,
MultipleSort,
)
from flask import (
Blueprint,
Flask,
current_app,
send_file,
make_response,
request,
)
# Constants
# AURA server information
# TODO: Add version information
SERVER_INFO = {
"aura-version": "0",
"server": "beets-aura",
"server-version": "0.1",
"auth-required": False,
"features": ["albums", "artists", "images"],
}
# Maps AURA Track attribute to beets Item attribute
TRACK_ATTR_MAP = {
# Required
"title": "title",
"artist": "artist",
# Optional
"album": "album",
"track": "track", # Track number on album
"tracktotal": "tracktotal",
"disc": "disc",
"disctotal": "disctotal",
"year": "year",
"month": "month",
"day": "day",
"bpm": "bpm",
"genre": "genre",
"recording-mbid": "mb_trackid", # beets trackid is MB recording
"track-mbid": "mb_releasetrackid",
"composer": "composer",
"albumartist": "albumartist",
"comments": "comments",
# Optional for Audio Metadata
# TODO: Support the mimetype attribute, format != mime type
# "mimetype": track.format,
"duration": "length",
"framerate": "samplerate",
# I don't think beets has a framecount field
# "framecount": ???,
"channels": "channels",
"bitrate": "bitrate",
"bitdepth": "bitdepth",
"size": "filesize",
}
# Maps AURA Album attribute to beets Album attribute
ALBUM_ATTR_MAP = {
# Required
"title": "album",
"artist": "albumartist",
# Optional
"tracktotal": "albumtotal",
"disctotal": "disctotal",
"year": "year",
"month": "month",
"day": "day",
"genre": "genre",
"release-mbid": "mb_albumid",
"release-group-mbid": "mb_releasegroupid",
}
# Maps AURA Artist attribute to beets Item field
# Artists are not first-class in beets, so information is extracted from
# beets Items.
ARTIST_ATTR_MAP = {
# Required
"name": "artist",
# Optional
"artist-mbid": "mb_artistid",
}
class AURADocument:
"""Base class for building AURA documents."""
@staticmethod
def error(status, title, detail):
"""Make a response for an error following the JSON:API spec.
Args:
status: An HTTP status code string, e.g. "404 Not Found".
title: A short, human-readable summary of the problem.
detail: A human-readable explanation specific to this
occurrence of the problem.
"""
document = {
"errors": [{"status": status, "title": title, "detail": detail}]
}
return make_response(document, status)
def translate_filters(self):
"""Translate filters from request arguments to a beets Query."""
# The format of each filter key in the request parameter is:
# filter[<attribute>]. This regex extracts <attribute>.
pattern = re.compile(r"filter\[(?P<attribute>[a-zA-Z0-9_-]+)\]")
queries = []
for key, value in request.args.items():
match = pattern.match(key)
if match:
# Extract attribute name from key
aura_attr = match.group("attribute")
# Get the beets version of the attribute name
beets_attr = self.attribute_map.get(aura_attr, aura_attr)
converter = self.get_attribute_converter(beets_attr)
value = converter(value)
# Add exact match query to list
# Use a slow query so it works with all fields
queries.append(MatchQuery(beets_attr, value, fast=False))
# NOTE: AURA doesn't officially support multiple queries
return AndQuery(queries)
def translate_sorts(self, sort_arg):
"""Translate an AURA sort parameter into a beets Sort.
Args:
sort_arg: The value of the 'sort' query parameter; a comma
separated list of fields to sort by, in order.
E.g. "-year,title".
"""
# Change HTTP query parameter to a list
aura_sorts = sort_arg.strip(",").split(",")
sorts = []
for aura_attr in aura_sorts:
if aura_attr[0] == "-":
ascending = False
# Remove leading "-"
aura_attr = aura_attr[1:]
else:
# JSON:API default
ascending = True
# Get the beets version of the attribute name
beets_attr = self.attribute_map.get(aura_attr, aura_attr)
# Use slow sort so it works with all fields (inc. computed)
sorts.append(SlowFieldSort(beets_attr, ascending=ascending))
return MultipleSort(sorts)
def paginate(self, collection):
"""Get a page of the collection and the URL to the next page.
Args:
collection: The raw data from which resource objects can be
built. Could be an sqlite3.Cursor object (tracks and
albums) or a list of strings (artists).
"""
# Pages start from zero
page = request.args.get("page", 0, int)
# Use page limit defined in config by default.
default_limit = config["aura"]["page_limit"].get(int)
limit = request.args.get("limit", default_limit, int)
# start = offset of first item to return
start = page * limit
# end = offset of last item + 1
end = start + limit
if end > len(collection):
end = len(collection)
next_url = None
else:
# Not the last page so work out links.next url
if not request.args:
# No existing arguments, so current page is 0
next_url = request.url + "?page=1"
elif not request.args.get("page", None):
# No existing page argument, so add one to the end
next_url = request.url + "&page=1"
else:
# Increment page token by 1
next_url = request.url.replace(
f"page={page}", "page={}".format(page + 1)
)
# Get only the items in the page range
data = [self.resource_object(collection[i]) for i in range(start, end)]
return data, next_url
def get_included(self, data, include_str):
"""Build a list of resource objects for inclusion.
Args:
data: An array of dicts in the form of resource objects.
include_str: A comma separated list of resource types to
include. E.g. "tracks,images".
"""
# Change HTTP query parameter to a list
to_include = include_str.strip(",").split(",")
# Build a list of unique type and id combinations
# For each resource object in the primary data, iterate over it's
# relationships. If a relationship matches one of the types
# requested for inclusion (e.g. "albums") then add each type-id pair
# under the "data" key to unique_identifiers, checking first that
# it has not already been added. This ensures that no resources are
# included more than once.
unique_identifiers = []
for res_obj in data:
for rel_name, rel_obj in res_obj["relationships"].items():
if rel_name in to_include:
# NOTE: Assumes relationship is to-many
for identifier in rel_obj["data"]:
if identifier not in unique_identifiers:
unique_identifiers.append(identifier)
# TODO: I think this could be improved
included = []
for identifier in unique_identifiers:
res_type = identifier["type"]
if res_type == "track":
track_id = int(identifier["id"])
track = current_app.config["lib"].get_item(track_id)
included.append(TrackDocument.resource_object(track))
elif res_type == "album":
album_id = int(identifier["id"])
album = current_app.config["lib"].get_album(album_id)
included.append(AlbumDocument.resource_object(album))
elif res_type == "artist":
artist_id = identifier["id"]
included.append(ArtistDocument.resource_object(artist_id))
elif res_type == "image":
image_id = identifier["id"]
included.append(ImageDocument.resource_object(image_id))
else:
raise ValueError(f"Invalid resource type: {res_type}")
return included
def all_resources(self):
"""Build document for /tracks, /albums or /artists."""
query = self.translate_filters()
sort_arg = request.args.get("sort", None)
if sort_arg:
sort = self.translate_sorts(sort_arg)
# For each sort field add a query which ensures all results
# have a non-empty, non-zero value for that field.
for s in sort.sorts:
query.subqueries.append(
NotQuery(
# Match empty fields (^$) or zero fields, (^0$)
RegexpQuery(s.field, "(^$|^0$)", fast=False)
)
)
else:
sort = None
# Get information from the library
collection = self.get_collection(query=query, sort=sort)
# Convert info to AURA form and paginate it
data, next_url = self.paginate(collection)
document = {"data": data}
# If there are more pages then provide a way to access them
if next_url:
document["links"] = {"next": next_url}
# Include related resources for each element in "data"
include_str = request.args.get("include", None)
if include_str:
document["included"] = self.get_included(data, include_str)
return document
def single_resource_document(self, resource_object):
"""Build document for a specific requested resource.
Args:
resource_object: A dictionary in the form of a JSON:API
resource object.
"""
document = {"data": resource_object}
include_str = request.args.get("include", None)
if include_str:
# [document["data"]] is because arg needs to be list
document["included"] = self.get_included(
[document["data"]], include_str
)
return document
class TrackDocument(AURADocument):
"""Class for building documents for /tracks endpoints."""
attribute_map = TRACK_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get Item objects from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].items(query, sort)
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
# filesize is a special field (read from disk not db?)
if beets_attr == "filesize":
converter = int
else:
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(track):
"""Construct a JSON:API resource object from a beets Item.
Args:
track: A beets Item object.
"""
attributes = {}
# Use aura => beets attribute map, e.g. size => filesize
for aura_attr, beets_attr in TRACK_ATTR_MAP.items():
a = getattr(track, beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could result in required attributes not being set
if a:
attributes[aura_attr] = a
# JSON:API one-to-many relationship to parent album
relationships = {
"artists": {"data": [{"type": "artist", "id": track.artist}]}
}
# Only add album relationship if not singleton
if not track.singleton:
relationships["albums"] = {
"data": [{"type": "album", "id": str(track.album_id)}]
}
return {
"type": "track",
"id": str(track.id),
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, track_id):
"""Get track from the library and build a document.
Args:
track_id: The beets id of the track (integer).
"""
track = current_app.config["lib"].get_item(track_id)
if not track:
return self.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
)
return self.single_resource_document(self.resource_object(track))
class AlbumDocument(AURADocument):
"""Class for building documents for /albums endpoints."""
attribute_map = ALBUM_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get Album objects from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].albums(query, sort)
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
try:
# Look for field in list of Album fields
# and get python type of database type.
# See beets.library.Album and beets.dbcore.types
converter = Album._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(album):
"""Construct a JSON:API resource object from a beets Album.
Args:
album: A beets Album object.
"""
attributes = {}
# Use aura => beets attribute name map
for aura_attr, beets_attr in ALBUM_ATTR_MAP.items():
a = getattr(album, beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could mean required attributes are not set
if a:
attributes[aura_attr] = a
# Get beets Item objects for all tracks in the album sorted by
# track number. Sorting is not required but it's nice.
query = MatchQuery("album_id", album.id)
sort = FixedFieldSort("track", ascending=True)
tracks = current_app.config["lib"].items(query, sort)
# JSON:API one-to-many relationship to tracks on the album
relationships = {
"tracks": {
"data": [{"type": "track", "id": str(t.id)} for t in tracks]
}
}
# Add images relationship if album has associated images
if album.artpath:
path = py3_path(album.artpath)
filename = path.split("/")[-1]
image_id = f"album-{album.id}-{filename}"
relationships["images"] = {
"data": [{"type": "image", "id": image_id}]
}
# Add artist relationship if artist name is same on tracks
# Tracks are used to define artists so don't albumartist
# Check for all tracks in case some have featured artists
if album.albumartist in [t.artist for t in tracks]:
relationships["artists"] = {
"data": [{"type": "artist", "id": album.albumartist}]
}
return {
"type": "album",
"id": str(album.id),
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, album_id):
"""Get album from the library and build a document.
Args:
album_id: The beets id of the album (integer).
"""
album = current_app.config["lib"].get_album(album_id)
if not album:
return self.error(
"404 Not Found",
"No album with the requested id.",
"There is no album with an id of {} in the library.".format(
album_id
),
)
return self.single_resource_document(self.resource_object(album))
class ArtistDocument(AURADocument):
"""Class for building documents for /artists endpoints."""
attribute_map = ARTIST_ATTR_MAP
def get_collection(self, query=None, sort=None):
"""Get a list of artist names from the library.
Args:
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
# Gets only tracks with matching artist information
tracks = current_app.config["lib"].items(query, sort)
collection = []
for track in tracks:
# Do not add duplicates
if track.artist not in collection:
collection.append(track.artist)
return collection
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "artist".
"""
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(artist_id):
"""Construct a JSON:API resource object for the given artist.
Args:
artist_id: A string which is the artist's name.
"""
# Get tracks where artist field exactly matches artist_id
query = MatchQuery("artist", artist_id)
tracks = current_app.config["lib"].items(query)
if not tracks:
return None
# Get artist information from the first track
# NOTE: It could be that the first track doesn't have a
# MusicBrainz id but later tracks do, which isn't ideal.
attributes = {}
# Use aura => beets attribute map, e.g. artist => name
for aura_attr, beets_attr in ARTIST_ATTR_MAP.items():
a = getattr(tracks[0], beets_attr)
# Only set attribute if it's not None, 0, "", etc.
# NOTE: This could mean required attributes are not set
if a:
attributes[aura_attr] = a
relationships = {
"tracks": {
"data": [{"type": "track", "id": str(t.id)} for t in tracks]
}
}
album_query = MatchQuery("albumartist", artist_id)
albums = current_app.config["lib"].albums(query=album_query)
if len(albums) != 0:
relationships["albums"] = {
"data": [{"type": "album", "id": str(a.id)} for a in albums]
}
return {
"type": "artist",
"id": artist_id,
"attributes": attributes,
"relationships": relationships,
}
def single_resource(self, artist_id):
"""Get info for the requested artist and build a document.
Args:
artist_id: A string which is the artist's name.
"""
artist_resource = self.resource_object(artist_id)
if not artist_resource:
return self.error(
"404 Not Found",
"No artist with the requested id.",
"There is no artist with an id of {} in the library.".format(
artist_id
),
)
return self.single_resource_document(artist_resource)
def safe_filename(fn):
"""Check whether a string is a simple (non-path) filename.
For example, `foo.txt` is safe because it is a "plain" filename. But
`foo/bar.txt` and `../foo.txt` and `.` are all non-safe because they
can traverse to other directories other than the current one.
"""
# Rule out any directories.
if os.path.basename(fn) != fn:
return False
# In single names, rule out Unix directory traversal names.
if fn in ('.', '..'):
return False
return True
class ImageDocument(AURADocument):
"""Class for building documents for /images/(id) endpoints."""
@staticmethod
def get_image_path(image_id):
"""Works out the full path to the image with the given id.
Returns None if there is no such image.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
# Split image_id into its constituent parts
id_split = image_id.split("-")
if len(id_split) < 3:
# image_id is not in the required format
return None
parent_type = id_split[0]
parent_id = id_split[1]
img_filename = "-".join(id_split[2:])
if not safe_filename(img_filename):
return None
# Get the path to the directory parent's images are in
if parent_type == "album":
album = current_app.config["lib"].get_album(int(parent_id))
if not album or not album.artpath:
return None
# Cut the filename off of artpath
# This is in preparation for supporting images in the same
# directory that are not tracked by beets.
artpath = py3_path(album.artpath)
dir_path = "/".join(artpath.split("/")[:-1])
else:
# Images for other resource types are not supported
return None
img_path = os.path.join(dir_path, img_filename)
# Check the image actually exists
if isfile(img_path):
return img_path
else:
return None
@staticmethod
def resource_object(image_id):
"""Construct a JSON:API resource object for the given image.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
# Could be called as a static method, so can't use
# self.get_image_path()
image_path = ImageDocument.get_image_path(image_id)
if not image_path:
return None
attributes = {
"role": "cover",
"mimetype": guess_type(image_path)[0],
"size": getsize(image_path),
}
try:
from PIL import Image
except ImportError:
pass
else:
im = Image.open(image_path)
attributes["width"] = im.width
attributes["height"] = im.height
relationships = {}
# Split id into [parent_type, parent_id, filename]
id_split = image_id.split("-")
relationships[id_split[0] + "s"] = {
"data": [{"type": id_split[0], "id": id_split[1]}]
}
return {
"id": image_id,
"type": "image",
# Remove attributes that are None, 0, "", etc.
"attributes": {k: v for k, v in attributes.items() if v},
"relationships": relationships,
}
def single_resource(self, image_id):
"""Get info for the requested image and build a document.
Args:
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
image_resource = self.resource_object(image_id)
if not image_resource:
return self.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library.".format(
image_id
),
)
return self.single_resource_document(image_resource)
# Initialise flask blueprint
aura_bp = Blueprint("aura_bp", __name__)
@aura_bp.route("/server")
def server_info():
"""Respond with info about the server."""
return {"data": {"type": "server", "id": "0", "attributes": SERVER_INFO}}
# Track endpoints
@aura_bp.route("/tracks")
def all_tracks():
"""Respond with a list of all tracks and related information."""
doc = TrackDocument()
return doc.all_resources()
@aura_bp.route("/tracks/<int:track_id>")
def single_track(track_id):
"""Respond with info about the specified track.
Args:
track_id: The id of the track provided in the URL (integer).
"""
doc = TrackDocument()
return doc.single_resource(track_id)
@aura_bp.route("/tracks/<int:track_id>/audio")
def audio_file(track_id):
"""Supply an audio file for the specified track.
Args:
track_id: The id of the track provided in the URL (integer).
"""
track = current_app.config["lib"].get_item(track_id)
if not track:
return AURADocument.error(
"404 Not Found",
"No track with the requested id.",
"There is no track with an id of {} in the library.".format(
track_id
),
)
path = py3_path(track.path)
if not isfile(path):
return AURADocument.error(
"404 Not Found",
"No audio file for the requested track.",
(
"There is no audio file for track {} at the expected location"
).format(track_id),
)
file_mimetype = guess_type(path)[0]
if not file_mimetype:
return AURADocument.error(
"500 Internal Server Error",
"Requested audio file has an unknown mimetype.",
(
"The audio file for track {} has an unknown mimetype. "
"Its file extension is {}."
).format(track_id, path.split(".")[-1]),
)
# Check that the Accept header contains the file's mimetype
# Takes into account */* and audio/*
# Adding support for the bitrate parameter would require some effort so I
# left it out. This means the client could be sent an error even if the
# audio doesn't need transcoding.
if not request.accept_mimetypes.best_match([file_mimetype]):
return AURADocument.error(
"406 Not Acceptable",
"Unsupported MIME type or bitrate parameter in Accept header.",
(
"The audio file for track {} is only available as {} and "
"bitrate parameters are not supported."
).format(track_id, file_mimetype),
)
return send_file(
path,
mimetype=file_mimetype,
# Handles filename in Content-Disposition header
as_attachment=True,
# Tries to upgrade the stream to support range requests
conditional=True,
)
# Album endpoints
@aura_bp.route("/albums")
def all_albums():
"""Respond with a list of all albums and related information."""
doc = AlbumDocument()
return doc.all_resources()
@aura_bp.route("/albums/<int:album_id>")
def single_album(album_id):
"""Respond with info about the specified album.
Args:
album_id: The id of the album provided in the URL (integer).
"""
doc = AlbumDocument()
return doc.single_resource(album_id)
# Artist endpoints
# Artist ids are their names
@aura_bp.route("/artists")
def all_artists():
"""Respond with a list of all artists and related information."""
doc = ArtistDocument()
return doc.all_resources()
# Using the path converter allows slashes in artist_id
@aura_bp.route("/artists/<path:artist_id>")
def single_artist(artist_id):
"""Respond with info about the specified artist.
Args:
artist_id: The id of the artist provided in the URL. A string
which is the artist's name.
"""
doc = ArtistDocument()
return doc.single_resource(artist_id)
# Image endpoints
# Image ids are in the form <parent_type>-<parent_id>-<img_filename>
# For example: album-13-cover.jpg
@aura_bp.route("/images/<string:image_id>")
def single_image(image_id):
"""Respond with info about the specified image.
Args:
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
doc = ImageDocument()
return doc.single_resource(image_id)
@aura_bp.route("/images/<string:image_id>/file")
def image_file(image_id):
"""Supply an image file for the specified image.
Args:
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
img_path = ImageDocument.get_image_path(image_id)
if not img_path:
return AURADocument.error(
"404 Not Found",
"No image with the requested id.",
"There is no image with an id of {} in the library".format(
image_id
),
)
return send_file(img_path)
# WSGI app
def create_app():
"""An application factory for use by a WSGI server."""
config["aura"].add(
{
"host": "127.0.0.1",
"port": 8337,
"cors": [],
"cors_supports_credentials": False,
"page_limit": 500,
}
)
app = Flask(__name__)
# Register AURA blueprint view functions under a URL prefix
app.register_blueprint(aura_bp, url_prefix="/aura")
# AURA specifies mimetype MUST be this
app.config["JSONIFY_MIMETYPE"] = "application/vnd.api+json"
# Disable auto-sorting of JSON keys
app.config["JSON_SORT_KEYS"] = False
# Provide a way to access the beets library
# The normal method of using the Library and config provided in the
# command function is not used because create_app() could be called
# by an external WSGI server.
# NOTE: this uses a 'private' function from beets.ui.__init__
app.config["lib"] = _open_library(config)
# Enable CORS if required
cors = config["aura"]["cors"].as_str_seq(list)
if cors:
from flask_cors import CORS
# "Accept" is the only header clients use
app.config["CORS_ALLOW_HEADERS"] = "Accept"
app.config["CORS_RESOURCES"] = {r"/aura/*": {"origins": cors}}
app.config["CORS_SUPPORTS_CREDENTIALS"] = config["aura"][
"cors_supports_credentials"
].get(bool)
CORS(app)
return app
# Beets Plugin Hook
class AURAPlugin(BeetsPlugin):
"""The BeetsPlugin subclass for the AURA server plugin."""
def __init__(self):
"""Add configuration options for the AURA plugin."""
super().__init__()
def commands(self):
"""Add subcommand used to run the AURA server."""
def run_aura(lib, opts, args):
"""Run the application using Flask's built in-server.
Args:
lib: A beets Library object (not used).
opts: Command line options. An optparse.Values object.
args: The list of arguments to process (not used).
"""
app = create_app()
# Start the built-in server (not intended for production)
app.run(
host=self.config["host"].get(str),
port=self.config["port"].get(int),
debug=opts.debug,
threaded=True,
)
run_aura_cmd = Subcommand("aura", help="run an AURA server")
run_aura_cmd.parser.add_option(
"-d",
"--debug",
action="store_true",
default=False,
help="use Flask debug mode",
)
run_aura_cmd.func = run_aura
return [run_aura_cmd]

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, François-Xavier Thomas.
#
@ -16,18 +15,19 @@
"""Use command-line tools to check for audio file corruption.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.util import displayable_path, confit
from beets import ui
from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT
import shlex
import os
import errno
import sys
import six
import confuse
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.util import displayable_path, par_map
from beets import ui
from beets import importer
class CheckerCommandException(Exception):
@ -48,8 +48,17 @@ class CheckerCommandException(Exception):
class BadFiles(BeetsPlugin):
def __init__(self):
super().__init__()
self.verbose = False
self.register_listener('import_task_start',
self.on_import_task_start)
self.register_listener('import_task_before_choice',
self.on_import_task_before_choice)
def run_command(self, cmd):
self._log.debug(u"running command: {}",
self._log.debug("running command: {}",
displayable_path(list2cmdline(cmd)))
try:
output = check_output(cmd, stderr=STDOUT)
@ -61,7 +70,7 @@ class BadFiles(BeetsPlugin):
status = e.returncode
except OSError as e:
raise CheckerCommandException(cmd, e)
output = output.decode(sys.getfilesystemencoding())
output = output.decode(sys.getdefaultencoding(), 'replace')
return status, errors, [line for line in output.split("\n") if line]
def check_mp3val(self, path):
@ -85,68 +94,122 @@ class BadFiles(BeetsPlugin):
ext = ext.lower()
try:
command = self.config['commands'].get(dict).get(ext)
except confit.NotFoundError:
except confuse.NotFoundError:
command = None
if command:
return self.check_custom(command)
elif ext == "mp3":
if ext == "mp3":
return self.check_mp3val
elif ext == "flac":
if ext == "flac":
return self.check_flac
def check_bad(self, lib, opts, args):
for item in lib.items(ui.decargs(args)):
def check_item(self, item):
# First, check whether the path exists. If not, the user
# should probably run `beet update` to cleanup your library.
dpath = displayable_path(item.path)
self._log.debug("checking path: {}", dpath)
if not os.path.exists(item.path):
ui.print_("{}: file does not exist".format(
ui.colorize('text_error', dpath)))
# First, check whether the path exists. If not, the user
# should probably run `beet update` to cleanup your library.
dpath = displayable_path(item.path)
self._log.debug(u"checking path: {}", dpath)
if not os.path.exists(item.path):
ui.print_(u"{}: file does not exist".format(
ui.colorize('text_error', dpath)))
# Run the checker against the file if one is found
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
checker = self.get_checker(ext)
if not checker:
self._log.error("no checker specified in the config for {}",
ext)
return []
path = item.path
if not isinstance(path, str):
path = item.path.decode(sys.getfilesystemencoding())
try:
status, errors, output = checker(path)
except CheckerCommandException as e:
if e.errno == errno.ENOENT:
self._log.error(
"command not found: {} when validating file: {}",
e.checker,
e.path
)
else:
self._log.error("error invoking {}: {}", e.checker, e.msg)
return []
# Run the checker against the file if one is found
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
checker = self.get_checker(ext)
if not checker:
self._log.error(u"no checker specified in the config for {}",
ext)
continue
path = item.path
if not isinstance(path, six.text_type):
path = item.path.decode(sys.getfilesystemencoding())
try:
status, errors, output = checker(path)
except CheckerCommandException as e:
if e.errno == errno.ENOENT:
self._log.error(
u"command not found: {} when validating file: {}",
e.checker,
e.path
)
else:
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
continue
if status > 0:
ui.print_(u"{}: checker exited with status {}"
.format(ui.colorize('text_error', dpath), status))
for line in output:
ui.print_(u" {}".format(displayable_path(line)))
elif errors > 0:
ui.print_(u"{}: checker found {} errors or warnings"
.format(ui.colorize('text_warning', dpath), errors))
for line in output:
ui.print_(u" {}".format(displayable_path(line)))
elif opts.verbose:
ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath)))
error_lines = []
if status > 0:
error_lines.append(
"{}: checker exited with status {}"
.format(ui.colorize('text_error', dpath), status))
for line in output:
error_lines.append(f" {line}")
elif errors > 0:
error_lines.append(
"{}: checker found {} errors or warnings"
.format(ui.colorize('text_warning', dpath), errors))
for line in output:
error_lines.append(f" {line}")
elif self.verbose:
error_lines.append(
"{}: ok".format(ui.colorize('text_success', dpath)))
return error_lines
def on_import_task_start(self, task, session):
if not self.config['check_on_import'].get(False):
return
checks_failed = []
for item in task.items:
error_lines = self.check_item(item)
if error_lines:
checks_failed.append(error_lines)
if checks_failed:
task._badfiles_checks_failed = checks_failed
def on_import_task_before_choice(self, task, session):
if hasattr(task, '_badfiles_checks_failed'):
ui.print_('{} one or more files failed checks:'
.format(ui.colorize('text_warning', 'BAD')))
for error in task._badfiles_checks_failed:
for error_line in error:
ui.print_(error_line)
ui.print_()
ui.print_('What would you like to do?')
sel = ui.input_options(['aBort', 'skip', 'continue'])
if sel == 's':
return importer.action.SKIP
elif sel == 'c':
return None
elif sel == 'b':
raise importer.ImportAbort()
else:
raise Exception(f'Unexpected selection: {sel}')
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
self.verbose = opts.verbose
def check_and_print(item):
for error_line in self.check_item(item):
ui.print_(error_line)
par_map(check_and_print, items)
def commands(self):
bad_command = Subcommand('bad',
help=u'check for corrupt or missing files')
help='check for corrupt or missing files')
bad_command.parser.add_option(
u'-v', u'--verbose',
'-v', '--verbose',
action='store_true', default=False, dest='verbose',
help=u'view results for both the bad and uncorrupted files'
help='view results for both the bad and uncorrupted files'
)
bad_command.func = self.check_bad
bad_command.func = self.command
return [bad_command]

View file

@ -0,0 +1,82 @@
# This file is part of beets.
# Copyright 2016, Philippe Mongeau.
# Copyright 2021, Graham R. Cobb.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and ascociated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# This module is adapted from Fuzzy in accordance to the licence of
# that module
"""Provides a bare-ASCII matching query."""
from beets import ui
from beets.ui import print_, decargs
from beets.plugins import BeetsPlugin
from beets.dbcore.query import StringFieldQuery
from unidecode import unidecode
class BareascQuery(StringFieldQuery):
"""Compare items using bare ASCII, without accents etc."""
@classmethod
def string_match(cls, pattern, val):
"""Convert both pattern and string to plain ASCII before matching.
If pattern is all lower case, also convert string to lower case so
match is also case insensitive
"""
# smartcase
if pattern.islower():
val = val.lower()
pattern = unidecode(pattern)
val = unidecode(val)
return pattern in val
class BareascPlugin(BeetsPlugin):
"""Plugin to provide bare-ASCII option for beets matching."""
def __init__(self):
"""Default prefix for selecting bare-ASCII matching is #."""
super().__init__()
self.config.add({
'prefix': '#',
})
def queries(self):
"""Register bare-ASCII matching."""
prefix = self.config['prefix'].as_str()
return {prefix: BareascQuery}
def commands(self):
"""Add bareasc command as unidecode version of 'list'."""
cmd = ui.Subcommand('bareasc',
help='unidecode version of beet list command')
cmd.parser.usage += "\n" \
'Example: %prog -f \'$album: $title\' artist:beatles'
cmd.parser.add_all_common_options()
cmd.func = self.unidecode_list
return [cmd]
def unidecode_list(self, lib, opts, args):
"""Emulate normal 'list' command but with unidecode output."""
query = decargs(args)
album = opts.album
# Copied from commands.py - list_items
if album:
for album in lib.albums(query):
bare = unidecode(str(album))
print_(bare)
else:
for item in lib.items(query):
bare = unidecode(str(item))
print_(bare)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -15,11 +14,9 @@
"""Adds Beatport release and track search support to the autotagger
"""
from __future__ import division, absolute_import, print_function
import json
import re
import six
from datetime import datetime, timedelta
from requests_oauthlib import OAuth1Session
@ -28,35 +25,35 @@ from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing,
import beets
import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin
from beets.util import confit
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
import confuse
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
USER_AGENT = f'beets/{beets.__version__} +https://beets.io/'
class BeatportAPIError(Exception):
pass
class BeatportObject(object):
class BeatportObject:
def __init__(self, data):
self.beatport_id = data['id']
self.name = six.text_type(data['name'])
self.name = str(data['name'])
if 'releaseDate' in data:
self.release_date = datetime.strptime(data['releaseDate'],
'%Y-%m-%d')
if 'artists' in data:
self.artists = [(x['id'], six.text_type(x['name']))
self.artists = [(x['id'], str(x['name']))
for x in data['artists']]
if 'genres' in data:
self.genres = [six.text_type(x['name'])
self.genres = [str(x['name'])
for x in data['genres']]
class BeatportClient(object):
class BeatportClient:
_api_base = 'https://oauth-api.beatport.com'
def __init__(self, c_key, c_secret, auth_key=None, auth_secret=None):
@ -109,7 +106,7 @@ class BeatportClient(object):
:rtype: (unicode, unicode) tuple
"""
self.api.parse_authorization_response(
"http://beets.io/auth?" + auth_data)
"https://beets.io/auth?" + auth_data)
access_data = self.api.fetch_access_token(
self._make_url('/identity/1/oauth/access-token'))
return access_data['oauth_token'], access_data['oauth_token_secret']
@ -131,7 +128,7 @@ class BeatportClient(object):
"""
response = self._get('catalog/3/search',
query=query, perPage=5,
facets=['fieldType:{0}'.format(release_type)])
facets=[f'fieldType:{release_type}'])
for item in response:
if release_type == 'release':
if details:
@ -150,9 +147,11 @@ class BeatportClient(object):
:rtype: :py:class:`BeatportRelease`
"""
response = self._get('/catalog/3/releases', id=beatport_id)
release = BeatportRelease(response[0])
release.tracks = self.get_release_tracks(beatport_id)
return release
if response:
release = BeatportRelease(response[0])
release.tracks = self.get_release_tracks(beatport_id)
return release
return None
def get_release_tracks(self, beatport_id):
""" Get all tracks for a given release.
@ -191,7 +190,7 @@ class BeatportClient(object):
response = self.api.get(self._make_url(endpoint), params=kwargs)
except Exception as e:
raise BeatportAPIError("Error connecting to Beatport API: {}"
.format(e.message))
.format(e))
if not response:
raise BeatportAPIError(
"Error {0.status_code} for '{0.request.path_url}"
@ -199,21 +198,20 @@ class BeatportClient(object):
return response.json()['results']
@six.python_2_unicode_compatible
class BeatportRelease(BeatportObject):
def __str__(self):
if len(self.artists) < 4:
artist_str = ", ".join(x[1] for x in self.artists)
else:
artist_str = "Various Artists"
return u"<BeatportRelease: {0} - {1} ({2})>".format(
return "<BeatportRelease: {} - {} ({})>".format(
artist_str,
self.name,
self.catalog_number,
)
def __repr__(self):
return six.text_type(self).encode('utf-8')
return str(self).encode('utf-8')
def __init__(self, data):
BeatportObject.__init__(self, data)
@ -224,26 +222,26 @@ class BeatportRelease(BeatportObject):
if 'category' in data:
self.category = data['category']
if 'slug' in data:
self.url = "http://beatport.com/release/{0}/{1}".format(
self.url = "https://beatport.com/release/{}/{}".format(
data['slug'], data['id'])
self.genre = data.get('genre')
@six.python_2_unicode_compatible
class BeatportTrack(BeatportObject):
def __str__(self):
artist_str = ", ".join(x[1] for x in self.artists)
return (u"<BeatportTrack: {0} - {1} ({2})>"
return ("<BeatportTrack: {} - {} ({})>"
.format(artist_str, self.name, self.mix_name))
def __repr__(self):
return six.text_type(self).encode('utf-8')
return str(self).encode('utf-8')
def __init__(self, data):
BeatportObject.__init__(self, data)
if 'title' in data:
self.title = six.text_type(data['title'])
self.title = str(data['title'])
if 'mixName' in data:
self.mix_name = six.text_type(data['mixName'])
self.mix_name = str(data['mixName'])
self.length = timedelta(milliseconds=data.get('lengthMs', 0) or 0)
if not self.length:
try:
@ -252,14 +250,26 @@ class BeatportTrack(BeatportObject):
except ValueError:
pass
if 'slug' in data:
self.url = "http://beatport.com/track/{0}/{1}".format(data['slug'],
data['id'])
self.url = "https://beatport.com/track/{}/{}" \
.format(data['slug'], data['id'])
self.track_number = data.get('trackNumber')
self.bpm = data.get('bpm')
self.initial_key = str(
(data.get('key') or {}).get('shortName')
)
# Use 'subgenre' and if not present, 'genre' as a fallback.
if data.get('subGenres'):
self.genre = str(data['subGenres'][0].get('name'))
elif data.get('genres'):
self.genre = str(data['genres'][0].get('name'))
class BeatportPlugin(BeetsPlugin):
data_source = 'Beatport'
def __init__(self):
super(BeatportPlugin, self).__init__()
super().__init__()
self.config.add({
'apikey': '57713c3906af6f5def151b33601389176b37b429',
'apisecret': 'b3fe08c93c80aefd749fe871a16cd2bb32e2b954',
@ -279,7 +289,7 @@ class BeatportPlugin(BeetsPlugin):
try:
with open(self._tokenfile()) as f:
tokendata = json.load(f)
except IOError:
except OSError:
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
else:
@ -294,22 +304,22 @@ class BeatportPlugin(BeetsPlugin):
try:
url = auth_client.get_authorize_url()
except AUTH_ERRORS as e:
self._log.debug(u'authentication error: {0}', e)
raise beets.ui.UserError(u'communication with Beatport failed')
self._log.debug('authentication error: {0}', e)
raise beets.ui.UserError('communication with Beatport failed')
beets.ui.print_(u"To authenticate with Beatport, visit:")
beets.ui.print_("To authenticate with Beatport, visit:")
beets.ui.print_(url)
# Ask for the verifier data and validate it.
data = beets.ui.input_(u"Enter the string displayed in your browser:")
data = beets.ui.input_("Enter the string displayed in your browser:")
try:
token, secret = auth_client.get_access_token(data)
except AUTH_ERRORS as e:
self._log.debug(u'authentication error: {0}', e)
raise beets.ui.UserError(u'Beatport token request failed')
self._log.debug('authentication error: {0}', e)
raise beets.ui.UserError('Beatport token request failed')
# Save the token for later use.
self._log.debug(u'Beatport token {0}, secret {1}', token, secret)
self._log.debug('Beatport token {0}, secret {1}', token, secret)
with open(self._tokenfile(), 'w') as f:
json.dump({'token': token, 'secret': secret}, f)
@ -318,74 +328,80 @@ class BeatportPlugin(BeetsPlugin):
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
return self.config['tokenfile'].get(confit.Filename(in_app_dir=True))
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
def album_distance(self, items, album_info, mapping):
"""Returns the beatport source weight and the maximum source weight
"""Returns the Beatport source weight and the maximum source weight
for albums.
"""
dist = Distance()
if album_info.data_source == 'Beatport':
dist.add('source', self.config['source_weight'].as_number())
return dist
return get_distance(
data_source=self.data_source,
info=album_info,
config=self.config
)
def track_distance(self, item, track_info):
"""Returns the beatport source weight and the maximum source weight
"""Returns the Beatport source weight and the maximum source weight
for individual tracks.
"""
dist = Distance()
if track_info.data_source == 'Beatport':
dist.add('source', self.config['source_weight'].as_number())
return dist
return get_distance(
data_source=self.data_source,
info=track_info,
config=self.config
)
def candidates(self, items, artist, release, va_likely):
def candidates(self, items, artist, release, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for beatport search results
matching release and artist (if not various).
"""
if va_likely:
query = release
else:
query = '%s %s' % (artist, release)
query = f'{artist} {release}'
try:
return self._get_releases(query)
except BeatportAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
self._log.debug('API Error: {0} (query: {1})', e, query)
return []
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for beatport search results
matching title and artist.
"""
query = '%s %s' % (artist, title)
query = f'{artist} {title}'
try:
return self._get_tracks(query)
except BeatportAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
self._log.debug('API Error: {0} (query: {1})', e, query)
return []
def album_for_id(self, release_id):
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
or None if the release is not found.
or None if the query is not a valid ID or release is not found.
"""
self._log.debug(u'Searching for release {0}', release_id)
self._log.debug('Searching for release {0}', release_id)
match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id)
if not match:
self._log.debug('Not a valid Beatport release ID.')
return None
release = self.client.get_release(match.group(2))
album = self._get_album_info(release)
return album
if release:
return self._get_album_info(release)
return None
def track_for_id(self, track_id):
"""Fetches a track by its Beatport ID and returns a TrackInfo object
or None if the track is not found.
or None if the track is not a valid Beatport ID or track is not found.
"""
self._log.debug(u'Searching for track {0}', track_id)
self._log.debug('Searching for track {0}', track_id)
match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id)
if not match:
self._log.debug('Not a valid Beatport track ID.')
return None
bp_track = self.client.get_track(match.group(2))
track = self._get_track_info(bp_track)
return track
if bp_track is not None:
return self._get_track_info(bp_track)
return None
def _get_releases(self, query):
"""Returns a list of AlbumInfo objects for a beatport search query.
@ -408,7 +424,7 @@ class BeatportPlugin(BeetsPlugin):
va = len(release.artists) > 3
artist, artist_id = self._get_artist(release.artists)
if va:
artist = u"Various Artists"
artist = "Various Artists"
tracks = [self._get_track_info(x) for x in release.tracks]
return AlbumInfo(album=release.name, album_id=release.beatport_id,
@ -418,40 +434,33 @@ class BeatportPlugin(BeetsPlugin):
month=release.release_date.month,
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number, media=u'Digital',
data_source=u'Beatport', data_url=release.url)
catalognum=release.catalog_number, media='Digital',
data_source=self.data_source, data_url=release.url,
genre=release.genre)
def _get_track_info(self, track):
"""Returns a TrackInfo object for a Beatport Track object.
"""
title = track.name
if track.mix_name != u"Original Mix":
title += u" ({0})".format(track.mix_name)
if track.mix_name != "Original Mix":
title += f" ({track.mix_name})"
artist, artist_id = self._get_artist(track.artists)
length = track.length.total_seconds()
return TrackInfo(title=title, track_id=track.beatport_id,
artist=artist, artist_id=artist_id,
length=length, index=track.track_number,
medium_index=track.track_number,
data_source=u'Beatport', data_url=track.url)
data_source=self.data_source, data_url=track.url,
bpm=track.bpm, initial_key=track.initial_key,
genre=track.genre)
def _get_artist(self, artists):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of Beatport release or track artists.
"""
artist_id = None
bits = []
for artist in artists:
if not artist_id:
artist_id = artist[0]
name = artist[1]
# Strip disambiguation number.
name = re.sub(r' \(\d+\)$', '', name)
# Move articles to the front.
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
bits.append(name)
artist = ', '.join(bits).replace(' ,', ',') or None
return artist, artist_id
return MetadataSourcePlugin.get_artist(
artists=artists, id_key=0, name_key=1
)
def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query.

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -16,7 +15,6 @@
"""Some simple performance benchmarks for beets.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -17,15 +16,13 @@
music player.
"""
from __future__ import division, absolute_import, print_function
import six
import sys
import time
from six.moves import _thread
import _thread
import os
import copy
from six.moves import urllib
import urllib
from beets import ui
import gi
@ -40,7 +37,7 @@ class QueryError(Exception):
pass
class GstPlayer(object):
class GstPlayer:
"""A music player abstracting GStreamer's Playbin element.
Create a player object, then call run() to start a thread with a
@ -64,7 +61,8 @@ class GstPlayer(object):
"""
# Set up the Gstreamer player. From the pygst tutorial:
# http://pygstdocs.berlios.de/pygst-tutorial/playbin.html
# https://pygstdocs.berlios.de/pygst-tutorial/playbin.html (gone)
# https://brettviren.github.io/pygst-tutorial-org/pygst-tutorial.html
####
# Updated to GStreamer 1.0 with:
# https://wiki.ubuntu.com/Novacut/GStreamer1.0
@ -109,7 +107,7 @@ class GstPlayer(object):
# error
self.player.set_state(Gst.State.NULL)
err, debug = message.parse_error()
print(u"Error: {0}".format(err))
print(f"Error: {err}")
self.playing = False
def _set_volume(self, volume):
@ -129,7 +127,7 @@ class GstPlayer(object):
path.
"""
self.player.set_state(Gst.State.NULL)
if isinstance(path, six.text_type):
if isinstance(path, str):
path = path.encode('utf-8')
uri = 'file://' + urllib.parse.quote(path)
self.player.set_property("uri", uri)
@ -177,12 +175,12 @@ class GstPlayer(object):
posq = self.player.query_position(fmt)
if not posq[0]:
raise QueryError("query_position failed")
pos = posq[1] // (10 ** 9)
pos = posq[1] / (10 ** 9)
lengthq = self.player.query_duration(fmt)
if not lengthq[0]:
raise QueryError("query_duration failed")
length = lengthq[1] // (10 ** 9)
length = lengthq[1] / (10 ** 9)
self.cached_time = (pos, length)
return (pos, length)
@ -215,6 +213,59 @@ class GstPlayer(object):
while self.playing:
time.sleep(1)
def get_decoders(self):
return get_decoders()
def get_decoders():
"""Get supported audio decoders from GStreamer.
Returns a dict mapping decoder element names to the associated media types
and file extensions.
"""
# We only care about audio decoder elements.
filt = (Gst.ELEMENT_FACTORY_TYPE_DEPAYLOADER |
Gst.ELEMENT_FACTORY_TYPE_DEMUXER |
Gst.ELEMENT_FACTORY_TYPE_PARSER |
Gst.ELEMENT_FACTORY_TYPE_DECODER |
Gst.ELEMENT_FACTORY_TYPE_MEDIA_AUDIO)
decoders = {}
mime_types = set()
for f in Gst.ElementFactory.list_get_elements(filt, Gst.Rank.NONE):
for pad in f.get_static_pad_templates():
if pad.direction == Gst.PadDirection.SINK:
caps = pad.static_caps.get()
mimes = set()
for i in range(caps.get_size()):
struct = caps.get_structure(i)
mime = struct.get_name()
if mime == 'unknown/unknown':
continue
mimes.add(mime)
mime_types.add(mime)
if mimes:
decoders[f.get_name()] = (mimes, set())
# Check all the TypeFindFactory plugin features form the registry. If they
# are associated with an audio media type that we found above, get the list
# of corresponding file extensions.
mime_extensions = {mime: set() for mime in mime_types}
for feat in Gst.Registry.get().get_feature_list(Gst.TypeFindFactory):
caps = feat.get_caps()
if caps:
for i in range(caps.get_size()):
struct = caps.get_structure(i)
mime = struct.get_name()
if mime in mime_types:
mime_extensions[mime].update(feat.get_extensions())
# Fill in the slot we left for file extensions.
for name, (mimes, exts) in decoders.items():
for mime in mimes:
exts.update(mime_extensions[mime])
return decoders
def play_simple(paths):
"""Play the files in paths in a straightforward way, without

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, aroquen
#
@ -15,10 +14,8 @@
"""Determine BPM by pressing a key to the rhythm."""
from __future__ import division, absolute_import, print_function
import time
from six.moves import input
from beets import ui
from beets.plugins import BeetsPlugin
@ -51,16 +48,16 @@ def bpm(max_strokes):
class BPMPlugin(BeetsPlugin):
def __init__(self):
super(BPMPlugin, self).__init__()
super().__init__()
self.config.add({
u'max_strokes': 3,
u'overwrite': True,
'max_strokes': 3,
'overwrite': True,
})
def commands(self):
cmd = ui.Subcommand('bpm',
help=u'determine bpm of a song by pressing '
u'a key to the rhythm')
help='determine bpm of a song by pressing '
'a key to the rhythm')
cmd.func = self.command
return [cmd]
@ -72,19 +69,19 @@ class BPMPlugin(BeetsPlugin):
def get_bpm(self, items, write=False):
overwrite = self.config['overwrite'].get(bool)
if len(items) > 1:
raise ValueError(u'Can only get bpm of one song at time')
raise ValueError('Can only get bpm of one song at time')
item = items[0]
if item['bpm']:
self._log.info(u'Found bpm {0}', item['bpm'])
self._log.info('Found bpm {0}', item['bpm'])
if not overwrite:
return
self._log.info(u'Press Enter {0} times to the rhythm or Ctrl-D '
u'to exit', self.config['max_strokes'].get(int))
self._log.info('Press Enter {0} times to the rhythm or Ctrl-D '
'to exit', self.config['max_strokes'].get(int))
new_bpm = bpm(self.config['max_strokes'].get(int))
item['bpm'] = int(new_bpm)
if write:
item.try_write()
item.store()
self._log.info(u'Added new bpm {0}', item['bpm'])
self._log.info('Added new bpm {0}', item['bpm'])

View file

@ -0,0 +1,186 @@
# This file is part of beets.
# Copyright 2019, Rahul Ahuja.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Update library's tags using Beatport.
"""
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from .beatport import BeatportPlugin
class BPSyncPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.beatport_plugin = BeatportPlugin()
self.beatport_plugin.setup()
def commands(self):
cmd = ui.Subcommand('bpsync', help='update metadata from Beatport')
cmd.parser.add_option(
'-p',
'--pretend',
action='store_true',
help='show all changes but do nothing',
)
cmd.parser.add_option(
'-m',
'--move',
action='store_true',
dest='move',
help="move files in the library directory",
)
cmd.parser.add_option(
'-M',
'--nomove',
action='store_false',
dest='move',
help="don't move files in library",
)
cmd.parser.add_option(
'-W',
'--nowrite',
action='store_false',
default=None,
dest='write',
help="don't write updated metadata to files",
)
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
def func(self, lib, opts, args):
"""Command handler for the bpsync function.
"""
move = ui.should_move(opts.move)
pretend = opts.pretend
write = ui.should_write(opts.write)
query = ui.decargs(args)
self.singletons(lib, query, move, pretend, write)
self.albums(lib, query, move, pretend, write)
def singletons(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + ['singleton:true']):
if not item.mb_trackid:
self._log.info(
'Skipping singleton with no mb_trackid: {}', item
)
continue
if not self.is_beatport_track(item):
self._log.info(
'Skipping non-{} singleton: {}',
self.beatport_plugin.data_source,
item,
)
continue
# Apply.
trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid)
with lib.transaction():
autotag.apply_item_metadata(item, trackinfo)
apply_item_changes(lib, item, move, pretend, write)
@staticmethod
def is_beatport_track(item):
return (
item.get('data_source') == BeatportPlugin.data_source
and item.mb_trackid.isnumeric()
)
def get_album_tracks(self, album):
if not album.mb_albumid:
self._log.info('Skipping album with no mb_albumid: {}', album)
return False
if not album.mb_albumid.isnumeric():
self._log.info(
'Skipping album with invalid {} ID: {}',
self.beatport_plugin.data_source,
album,
)
return False
items = list(album.items())
if album.get('data_source') == self.beatport_plugin.data_source:
return items
if not all(self.is_beatport_track(item) for item in items):
self._log.info(
'Skipping non-{} release: {}',
self.beatport_plugin.data_source,
album,
)
return False
return items
def albums(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for albums matched by
query and their items.
"""
# Process matching albums.
for album in lib.albums(query):
# Do we have a valid Beatport album?
items = self.get_album_tracks(album)
if not items:
continue
# Get the Beatport album information.
albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
if not albuminfo:
self._log.info(
'Release ID {} not found for album {}',
album.mb_albumid,
album,
)
continue
beatport_trackid_to_trackinfo = {
track.track_id: track for track in albuminfo.tracks
}
library_trackid_to_item = {
int(item.mb_trackid): item for item in items
}
item_to_trackinfo = {
item: beatport_trackid_to_trackinfo[track_id]
for track_id, item in library_trackid_to_item.items()
}
self._log.info('applying changes to {}', album)
with lib.transaction():
autotag.apply_metadata(albuminfo, item_to_trackinfo)
changed = False
# Find any changed item to apply Beatport changes to album.
any_changed_item = items[0]
for item in items:
item_changed = ui.show_model_changes(item)
changed |= item_changed
if item_changed:
any_changed_item = item
apply_item_changes(lib, item, move, pretend, write)
if pretend or not changed:
continue
# Update album structure to reflect an item in it.
for key in library.Album.item_keys:
album[key] = any_changed_item[key]
album.store()
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
self._log.debug('moving album {}', album)
album.move()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Fabrice Laporte.
#
@ -16,12 +15,10 @@
"""Provides the %bucket{} function for path formatting.
"""
from __future__ import division, absolute_import, print_function
from datetime import datetime
import re
import string
from six.moves import zip
from itertools import tee
from beets import plugins, ui
@ -49,7 +46,7 @@ def span_from_str(span_str):
"""Convert string to a 4 digits year
"""
if yearfrom < 100:
raise BucketError(u"%d must be expressed on 4 digits" % yearfrom)
raise BucketError("%d must be expressed on 4 digits" % yearfrom)
# if two digits only, pick closest year that ends by these two
# digits starting from yearfrom
@ -60,14 +57,14 @@ def span_from_str(span_str):
d = (yearfrom - yearfrom % 100) + d
return d
years = [int(x) for x in re.findall('\d+', span_str)]
years = [int(x) for x in re.findall(r'\d+', span_str)]
if not years:
raise ui.UserError(u"invalid range defined for year bucket '%s': no "
u"year found" % span_str)
raise ui.UserError("invalid range defined for year bucket '%s': no "
"year found" % span_str)
try:
years = [normalize_year(x, years[0]) for x in years]
except BucketError as exc:
raise ui.UserError(u"invalid range defined for year bucket '%s': %s" %
raise ui.UserError("invalid range defined for year bucket '%s': %s" %
(span_str, exc))
res = {'from': years[0], 'str': span_str}
@ -128,10 +125,10 @@ def str2fmt(s):
res = {'fromnchars': len(m.group('fromyear')),
'tonchars': len(m.group('toyear'))}
res['fmt'] = "%s%%s%s%s%s" % (m.group('bef'),
m.group('sep'),
'%s' if res['tonchars'] else '',
m.group('after'))
res['fmt'] = "{}%s{}{}{}".format(m.group('bef'),
m.group('sep'),
'%s' if res['tonchars'] else '',
m.group('after'))
return res
@ -170,8 +167,8 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
begin_index = ASCII_DIGITS.index(bucket[0])
end_index = ASCII_DIGITS.index(bucket[-1])
else:
raise ui.UserError(u"invalid range defined for alpha bucket "
u"'%s': no alphanumeric character found" %
raise ui.UserError("invalid range defined for alpha bucket "
"'%s': no alphanumeric character found" %
elem)
spans.append(
re.compile(
@ -184,7 +181,7 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
class BucketPlugin(plugins.BeetsPlugin):
def __init__(self):
super(BucketPlugin, self).__init__()
super().__init__()
self.template_funcs['bucket'] = self._tmpl_bucket
self.config.add({

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -16,16 +15,17 @@
"""Adds Chromaprint/Acoustid acoustic fingerprinting support to the
autotagger. Requires the pyacoustid library.
"""
from __future__ import division, absolute_import, print_function
from beets import plugins
from beets import ui
from beets import util
from beets import config
from beets.util import confit
from beets.autotag import hooks
import confuse
import acoustid
from collections import defaultdict
from functools import partial
import re
API_KEY = '1vOwZtEn'
SCORE_THRESH = 0.5
@ -57,6 +57,30 @@ def prefix(it, count):
yield v
def releases_key(release, countries, original_year):
"""Used as a key to sort releases by date then preferred country
"""
date = release.get('date')
if date and original_year:
year = date.get('year', 9999)
month = date.get('month', 99)
day = date.get('day', 99)
else:
year = 9999
month = 99
day = 99
# Uses index of preferred countries to sort
country_key = 99
if release.get('country'):
for i, country in enumerate(countries):
if country.match(release['country']):
country_key = i
break
return (year, month, day, country_key)
def acoustid_match(log, path):
"""Gets metadata for a file from Acoustid and populates the
_matches, _fingerprints, and _acoustids dictionaries accordingly.
@ -64,42 +88,55 @@ def acoustid_match(log, path):
try:
duration, fp = acoustid.fingerprint_file(util.syspath(path))
except acoustid.FingerprintGenerationError as exc:
log.error(u'fingerprinting of {0} failed: {1}',
log.error('fingerprinting of {0} failed: {1}',
util.displayable_path(repr(path)), exc)
return None
fp = fp.decode()
_fingerprints[path] = fp
try:
res = acoustid.lookup(API_KEY, fp, duration,
meta='recordings releases')
except acoustid.AcoustidError as exc:
log.debug(u'fingerprint matching {0} failed: {1}',
log.debug('fingerprint matching {0} failed: {1}',
util.displayable_path(repr(path)), exc)
return None
log.debug(u'chroma: fingerprinted {0}',
log.debug('chroma: fingerprinted {0}',
util.displayable_path(repr(path)))
# Ensure the response is usable and parse it.
if res['status'] != 'ok' or not res.get('results'):
log.debug(u'no match found')
log.debug('no match found')
return None
result = res['results'][0] # Best match.
if result['score'] < SCORE_THRESH:
log.debug(u'no results above threshold')
log.debug('no results above threshold')
return None
_acoustids[path] = result['id']
# Get recording and releases from the result.
# Get recording and releases from the result
if not result.get('recordings'):
log.debug(u'no recordings found')
log.debug('no recordings found')
return None
recording_ids = []
release_ids = []
releases = []
for recording in result['recordings']:
recording_ids.append(recording['id'])
if 'releases' in recording:
release_ids += [rel['id'] for rel in recording['releases']]
releases.extend(recording['releases'])
log.debug(u'matched recordings {0} on releases {1}',
# The releases list is essentially in random order from the Acoustid lookup
# so we optionally sort it using the match.preferred configuration options.
# 'original_year' to sort the earliest first and
# 'countries' to then sort preferred countries first.
country_patterns = config['match']['preferred']['countries'].as_str_seq()
countries = [re.compile(pat, re.I) for pat in country_patterns]
original_year = config['match']['preferred']['original_year']
releases.sort(key=partial(releases_key,
countries=countries,
original_year=original_year))
release_ids = [rel['id'] for rel in releases]
log.debug('matched recordings {0} on releases {1}',
recording_ids, release_ids)
_matches[path] = recording_ids, release_ids
@ -128,7 +165,7 @@ def _all_releases(items):
class AcoustidPlugin(plugins.BeetsPlugin):
def __init__(self):
super(AcoustidPlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
@ -152,14 +189,14 @@ class AcoustidPlugin(plugins.BeetsPlugin):
dist.add_expr('track_id', info.track_id not in recording_ids)
return dist
def candidates(self, items, artist, album, va_likely):
def candidates(self, items, artist, album, va_likely, extra_tags=None):
albums = []
for relid in prefix(_all_releases(items), MAX_RELEASES):
album = hooks.album_for_mbid(relid)
if album:
albums.append(album)
self._log.debug(u'acoustid album candidates: {0}', len(albums))
self._log.debug('acoustid album candidates: {0}', len(albums))
return albums
def item_candidates(self, item, artist, title):
@ -172,24 +209,24 @@ class AcoustidPlugin(plugins.BeetsPlugin):
track = hooks.track_for_mbid(recording_id)
if track:
tracks.append(track)
self._log.debug(u'acoustid item candidates: {0}', len(tracks))
self._log.debug('acoustid item candidates: {0}', len(tracks))
return tracks
def commands(self):
submit_cmd = ui.Subcommand('submit',
help=u'submit Acoustid fingerprints')
help='submit Acoustid fingerprints')
def submit_cmd_func(lib, opts, args):
try:
apikey = config['acoustid']['apikey'].as_str()
except confit.NotFoundError:
raise ui.UserError(u'no Acoustid user API key provided')
except confuse.NotFoundError:
raise ui.UserError('no Acoustid user API key provided')
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
submit_cmd.func = submit_cmd_func
fingerprint_cmd = ui.Subcommand(
'fingerprint',
help=u'generate fingerprints for items without them'
help='generate fingerprints for items without them'
)
def fingerprint_cmd_func(lib, opts, args):
@ -232,15 +269,15 @@ def submit_items(log, userkey, items, chunksize=64):
def submit_chunk():
"""Submit the current accumulated fingerprint data."""
log.info(u'submitting {0} fingerprints', len(data))
log.info('submitting {0} fingerprints', len(data))
try:
acoustid.submit(API_KEY, userkey, data)
except acoustid.AcoustidError as exc:
log.warning(u'acoustid submission error: {0}', exc)
log.warning('acoustid submission error: {0}', exc)
del data[:]
for item in items:
fp = fingerprint_item(log, item)
fp = fingerprint_item(log, item, write=ui.should_write())
# Construct a submission dictionary for this item.
item_data = {
@ -249,7 +286,7 @@ def submit_items(log, userkey, items, chunksize=64):
}
if item.mb_trackid:
item_data['mbid'] = item.mb_trackid
log.debug(u'submitting MBID')
log.debug('submitting MBID')
else:
item_data.update({
'track': item.title,
@ -260,7 +297,7 @@ def submit_items(log, userkey, items, chunksize=64):
'trackno': item.track,
'discno': item.disc,
})
log.debug(u'submitting textual metadata')
log.debug('submitting textual metadata')
data.append(item_data)
# If we have enough data, submit a chunk.
@ -281,28 +318,28 @@ def fingerprint_item(log, item, write=False):
"""
# Get a fingerprint and length for this track.
if not item.length:
log.info(u'{0}: no duration available',
log.info('{0}: no duration available',
util.displayable_path(item.path))
elif item.acoustid_fingerprint:
if write:
log.info(u'{0}: fingerprint exists, skipping',
log.info('{0}: fingerprint exists, skipping',
util.displayable_path(item.path))
else:
log.info(u'{0}: using existing fingerprint',
log.info('{0}: using existing fingerprint',
util.displayable_path(item.path))
return item.acoustid_fingerprint
return item.acoustid_fingerprint
else:
log.info(u'{0}: fingerprinting',
log.info('{0}: fingerprinting',
util.displayable_path(item.path))
try:
_, fp = acoustid.fingerprint_file(util.syspath(item.path))
item.acoustid_fingerprint = fp
item.acoustid_fingerprint = fp.decode()
if write:
log.info(u'{0}: writing fingerprint',
log.info('{0}: writing fingerprint',
util.displayable_path(item.path))
item.try_write()
if item._db:
item.store()
return item.acoustid_fingerprint
except acoustid.FingerprintGenerationError as exc:
log.info(u'fingerprint generation failed: {0}', exc)
log.info('fingerprint generation failed: {0}', exc)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Jakob Schnitzer.
#
@ -15,20 +14,18 @@
"""Converts tracks or albums to external directory
"""
from __future__ import division, absolute_import, print_function
from beets.util import par_map, decode_commandline_path, arg_encoding
import os
import threading
import subprocess
import tempfile
import shlex
import six
from string import Template
import platform
from beets import ui, util, plugins, config
from beets.plugins import BeetsPlugin
from beets.util.confit import ConfigTypeError
from confuse import ConfigTypeError
from beets import art
from beets.util.artresizer import ArtResizer
from beets.library import parse_query_string
@ -39,8 +36,8 @@ _temp_files = [] # Keep track of temporary transcoded files for deletion.
# Some convenient alternate names for formats.
ALIASES = {
u'wma': u'windows media',
u'vorbis': u'ogg',
'wma': 'windows media',
'vorbis': 'ogg',
}
LOSSLESS_FORMATS = ['ape', 'flac', 'alac', 'wav', 'aiff']
@ -68,7 +65,7 @@ def get_format(fmt=None):
extension = format_info.get('extension', fmt)
except KeyError:
raise ui.UserError(
u'convert: format {0} needs the "command" field'
'convert: format {} needs the "command" field'
.format(fmt)
)
except ConfigTypeError:
@ -81,7 +78,7 @@ def get_format(fmt=None):
command = config['convert']['command'].as_str()
elif 'opts' in keys:
# Undocumented option for backwards compatibility with < 1.3.1.
command = u'ffmpeg -i $source -y {0} $dest'.format(
command = 'ffmpeg -i $source -y {} $dest'.format(
config['convert']['opts'].as_str()
)
if 'extension' in keys:
@ -110,70 +107,81 @@ def should_transcode(item, fmt):
class ConvertPlugin(BeetsPlugin):
def __init__(self):
super(ConvertPlugin, self).__init__()
super().__init__()
self.config.add({
u'dest': None,
u'pretend': False,
u'threads': util.cpu_count(),
u'format': u'mp3',
u'formats': {
u'aac': {
u'command': u'ffmpeg -i $source -y -vn -acodec aac '
u'-aq 1 $dest',
u'extension': u'm4a',
'dest': None,
'pretend': False,
'link': False,
'hardlink': False,
'threads': util.cpu_count(),
'format': 'mp3',
'id3v23': 'inherit',
'formats': {
'aac': {
'command': 'ffmpeg -i $source -y -vn -acodec aac '
'-aq 1 $dest',
'extension': 'm4a',
},
u'alac': {
u'command': u'ffmpeg -i $source -y -vn -acodec alac $dest',
u'extension': u'm4a',
'alac': {
'command': 'ffmpeg -i $source -y -vn -acodec alac $dest',
'extension': 'm4a',
},
u'flac': u'ffmpeg -i $source -y -vn -acodec flac $dest',
u'mp3': u'ffmpeg -i $source -y -vn -aq 2 $dest',
u'opus':
u'ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest',
u'ogg':
u'ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest',
u'wma':
u'ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest',
'flac': 'ffmpeg -i $source -y -vn -acodec flac $dest',
'mp3': 'ffmpeg -i $source -y -vn -aq 2 $dest',
'opus':
'ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest',
'ogg':
'ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest',
'wma':
'ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest',
},
u'max_bitrate': 500,
u'auto': False,
u'tmpdir': None,
u'quiet': False,
u'embed': True,
u'paths': {},
u'no_convert': u'',
u'never_convert_lossy_files': False,
u'copy_album_art': False,
u'album_art_maxwidth': 0,
'max_bitrate': 500,
'auto': False,
'tmpdir': None,
'quiet': False,
'embed': True,
'paths': {},
'no_convert': '',
'never_convert_lossy_files': False,
'copy_album_art': False,
'album_art_maxwidth': 0,
'delete_originals': False,
})
self.early_import_stages = [self.auto_convert]
self.register_listener('import_task_files', self._cleanup)
def commands(self):
cmd = ui.Subcommand('convert', help=u'convert to external location')
cmd = ui.Subcommand('convert', help='convert to external location')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help=u'show actions but do nothing')
help='show actions but do nothing')
cmd.parser.add_option('-t', '--threads', action='store', type='int',
help=u'change the number of threads, \
help='change the number of threads, \
defaults to maximum available processors')
cmd.parser.add_option('-k', '--keep-new', action='store_true',
dest='keep_new', help=u'keep only the converted \
dest='keep_new', help='keep only the converted \
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
help=u'set the destination directory')
help='set the destination directory')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help=u'set the target format of the tracks')
help='set the target format of the tracks')
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
help=u'do not ask for confirmation')
help='do not ask for confirmation')
cmd.parser.add_option('-l', '--link', action='store_true', dest='link',
help='symlink files that do not \
need transcoding.')
cmd.parser.add_option('-H', '--hardlink', action='store_true',
dest='hardlink',
help='hardlink files that do not \
need transcoding. Overrides --link.')
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
def auto_convert(self, config, task):
if self.config['auto']:
for item in task.imported_items():
self.convert_on_import(config.lib, item)
par_map(lambda item: self.convert_on_import(config.lib, item),
task.imported_items())
# Utilities converted from functions to methods on logging overhaul
@ -191,22 +199,11 @@ class ConvertPlugin(BeetsPlugin):
quiet = self.config['quiet'].get(bool)
if not quiet and not pretend:
self._log.info(u'Encoding {0}', util.displayable_path(source))
self._log.info('Encoding {0}', util.displayable_path(source))
# On Python 3, we need to construct the command to invoke as a
# Unicode string. On Unix, this is a little unfortunate---the OS is
# expecting bytes---so we use surrogate escaping and decode with the
# argument encoding, which is the same encoding that will then be
# *reversed* to recover the same bytes before invoking the OS. On
# Windows, we want to preserve the Unicode filename "as is."
if not six.PY2:
command = command.decode(util.arg_encoding(), 'surrogateescape')
if platform.system() == 'Windows':
source = source.decode(util._fsencoding())
dest = dest.decode(util._fsencoding())
else:
source = source.decode(util.arg_encoding(), 'surrogateescape')
dest = dest.decode(util.arg_encoding(), 'surrogateescape')
command = command.decode(arg_encoding(), 'surrogateescape')
source = decode_commandline_path(source)
dest = decode_commandline_path(dest)
# Substitute $source and $dest in the argument list.
args = shlex.split(command)
@ -216,22 +213,19 @@ class ConvertPlugin(BeetsPlugin):
'source': source,
'dest': dest,
})
if six.PY2:
encode_cmd.append(args[i])
else:
encode_cmd.append(args[i].encode(util.arg_encoding()))
encode_cmd.append(args[i].encode(util.arg_encoding()))
if pretend:
self._log.info(u'{0}', u' '.join(ui.decargs(args)))
self._log.info('{0}', ' '.join(ui.decargs(args)))
return
try:
util.command_output(encode_cmd)
except subprocess.CalledProcessError as exc:
# Something went wrong (probably Ctrl+C), remove temporary files
self._log.info(u'Encoding {0} failed. Cleaning up...',
self._log.info('Encoding {0} failed. Cleaning up...',
util.displayable_path(source))
self._log.debug(u'Command {0} exited with status {1}: {2}',
self._log.debug('Command {0} exited with status {1}: {2}',
args,
exc.returncode,
exc.output)
@ -240,17 +234,17 @@ class ConvertPlugin(BeetsPlugin):
raise
except OSError as exc:
raise ui.UserError(
u"convert: couldn't invoke '{0}': {1}".format(
u' '.join(ui.decargs(args)), exc
"convert: couldn't invoke '{}': {}".format(
' '.join(ui.decargs(args)), exc
)
)
if not quiet and not pretend:
self._log.info(u'Finished encoding {0}',
self._log.info('Finished encoding {0}',
util.displayable_path(source))
def convert_item(self, dest_dir, keep_new, path_formats, fmt,
pretend=False):
pretend=False, link=False, hardlink=False):
"""A pipeline thread that converts `Item` objects from a
library.
"""
@ -283,41 +277,60 @@ class ConvertPlugin(BeetsPlugin):
util.mkdirall(dest)
if os.path.exists(util.syspath(dest)):
self._log.info(u'Skipping {0} (target file exists)',
self._log.info('Skipping {0} (target file exists)',
util.displayable_path(item.path))
continue
if keep_new:
if pretend:
self._log.info(u'mv {0} {1}',
self._log.info('mv {0} {1}',
util.displayable_path(item.path),
util.displayable_path(original))
else:
self._log.info(u'Moving to {0}',
self._log.info('Moving to {0}',
util.displayable_path(original))
util.move(item.path, original)
if should_transcode(item, fmt):
linked = False
try:
self.encode(command, original, converted, pretend)
except subprocess.CalledProcessError:
continue
else:
linked = link or hardlink
if pretend:
self._log.info(u'cp {0} {1}',
msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
self._log.info('{2} {0} {1}',
util.displayable_path(original),
util.displayable_path(converted))
util.displayable_path(converted),
msg)
else:
# No transcoding necessary.
self._log.info(u'Copying {0}',
util.displayable_path(item.path))
util.copy(original, converted)
msg = 'Hardlinking' if hardlink \
else ('Linking' if link else 'Copying')
self._log.info('{1} {0}',
util.displayable_path(item.path),
msg)
if hardlink:
util.hardlink(original, converted)
elif link:
util.link(original, converted)
else:
util.copy(original, converted)
if pretend:
continue
id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit'])
if id3v23 == 'inherit':
id3v23 = None
# Write tags from the database to the converted file.
item.try_write(path=converted)
item.try_write(path=converted, id3v23=id3v23)
if keep_new:
# If we're keeping the transcoded file, read it again (after
@ -326,13 +339,13 @@ class ConvertPlugin(BeetsPlugin):
item.read()
item.store() # Store new path and audio data.
if self.config['embed']:
album = item.get_album()
if self.config['embed'] and not linked:
album = item._cached_album
if album and album.artpath:
self._log.debug(u'embedding album art from {}',
self._log.debug('embedding album art from {}',
util.displayable_path(album.artpath))
art.embed_item(self._log, item, album.artpath,
itempath=converted)
itempath=converted, id3v23=id3v23)
if keep_new:
plugins.send('after_convert', item=item,
@ -341,7 +354,8 @@ class ConvertPlugin(BeetsPlugin):
plugins.send('after_convert', item=item,
dest=converted, keepnew=False)
def copy_album_art(self, album, dest_dir, path_formats, pretend=False):
def copy_album_art(self, album, dest_dir, path_formats, pretend=False,
link=False, hardlink=False):
"""Copies or converts the associated cover art of the album. Album must
have at least one track.
"""
@ -369,7 +383,7 @@ class ConvertPlugin(BeetsPlugin):
util.mkdirall(dest)
if os.path.exists(util.syspath(dest)):
self._log.info(u'Skipping {0} (target file exists)',
self._log.info('Skipping {0} (target file exists)',
util.displayable_path(album.artpath))
return
@ -383,31 +397,43 @@ class ConvertPlugin(BeetsPlugin):
if size:
resize = size[0] > maxwidth
else:
self._log.warning(u'Could not get size of image (please see '
u'documentation for dependencies).')
self._log.warning('Could not get size of image (please see '
'documentation for dependencies).')
# Either copy or resize (while copying) the image.
if resize:
self._log.info(u'Resizing cover art from {0} to {1}',
self._log.info('Resizing cover art from {0} to {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest))
if not pretend:
ArtResizer.shared.resize(maxwidth, album.artpath, dest)
else:
if pretend:
self._log.info(u'cp {0} {1}',
msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
self._log.info('{2} {0} {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest))
util.displayable_path(dest),
msg)
else:
self._log.info(u'Copying cover art to {0}',
msg = 'Hardlinking' if hardlink \
else ('Linking' if link else 'Copying')
self._log.info('{2} cover art from {0} to {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest))
util.copy(album.artpath, dest)
util.displayable_path(dest),
msg)
if hardlink:
util.hardlink(album.artpath, dest)
elif link:
util.link(album.artpath, dest)
else:
util.copy(album.artpath, dest)
def convert_func(self, lib, opts, args):
dest = opts.dest or self.config['dest'].get()
if not dest:
raise ui.UserError(u'no convert destination set')
raise ui.UserError('no convert destination set')
dest = util.bytestring_path(dest)
threads = opts.threads or self.config['threads'].get(int)
@ -421,33 +447,46 @@ class ConvertPlugin(BeetsPlugin):
else:
pretend = self.config['pretend'].get(bool)
if opts.hardlink is not None:
hardlink = opts.hardlink
link = False
elif opts.link is not None:
hardlink = False
link = opts.link
else:
hardlink = self.config['hardlink'].get(bool)
link = self.config['link'].get(bool)
if opts.album:
albums = lib.albums(ui.decargs(args))
items = [i for a in albums for i in a.items()]
if not pretend:
for a in albums:
ui.print_(format(a, u''))
ui.print_(format(a, ''))
else:
items = list(lib.items(ui.decargs(args)))
if not pretend:
for i in items:
ui.print_(format(i, u''))
ui.print_(format(i, ''))
if not items:
self._log.error(u'Empty query result.')
self._log.error('Empty query result.')
return
if not (pretend or opts.yes or ui.input_yn(u"Convert? (Y/n)")):
if not (pretend or opts.yes or ui.input_yn("Convert? (Y/n)")):
return
if opts.album and self.config['copy_album_art']:
for album in albums:
self.copy_album_art(album, dest, path_formats, pretend)
self.copy_album_art(album, dest, path_formats, pretend,
link, hardlink)
convert = [self.convert_item(dest,
opts.keep_new,
path_formats,
fmt,
pretend)
pretend,
link,
hardlink)
for _ in range(threads)]
pipe = util.pipeline.Pipeline([iter(items), convert])
pipe.run_parallel()
@ -477,11 +516,16 @@ class ConvertPlugin(BeetsPlugin):
# Change the newly-imported database entry to point to the
# converted file.
source_path = item.path
item.path = dest
item.write()
item.read() # Load new audio information data.
item.store()
if self.config['delete_originals']:
self._log.info('Removing original file {0}', source_path)
util.remove(source_path, False)
def _cleanup(self, task, session):
for path in task.old_paths:
if path in _temp_files:

View file

@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Bruno Cauet
# Split an album-file in tracks thanks a cue file
from __future__ import division, absolute_import, print_function
import subprocess
from os import path
from glob import glob
from beets.util import command_output, displayable_path
from beets.plugins import BeetsPlugin
from beets.autotag import TrackInfo
class CuePlugin(BeetsPlugin):
def __init__(self):
super(CuePlugin, self).__init__()
# this does not seem supported by shnsplit
self.config.add({
'keep_before': .1,
'keep_after': .9,
})
# self.register_listener('import_task_start', self.look_for_cues)
def candidates(self, items, artist, album, va_likely):
import pdb
pdb.set_trace()
def item_candidates(self, item, artist, album):
dir = path.dirname(item.path)
cues = glob.glob(path.join(dir, "*.cue"))
if not cues:
return
if len(cues) > 1:
self._log.info(u"Found multiple cue files doing nothing: {0}",
list(map(displayable_path, cues)))
cue_file = cues[0]
self._log.info("Found {} for {}", displayable_path(cue_file), item)
try:
# careful: will ask for input in case of conflicts
command_output(['shnsplit', '-f', cue_file, item.path])
except (subprocess.CalledProcessError, OSError):
self._log.exception(u'shnsplit execution failed')
return
tracks = glob(path.join(dir, "*.wav"))
self._log.info("Generated {0} tracks", len(tracks))
for t in tracks:
title = "dunno lol"
track_id = "wtf"
index = int(path.basename(t)[len("split-track"):-len(".wav")])
yield TrackInfo(title, track_id, index=index, artist=artist)
# generate TrackInfo instances

View file

@ -0,0 +1,230 @@
# This file is part of beets.
# Copyright 2019, Rahul Ahuja.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds Deezer release and track search support to the autotagger
"""
import collections
import unidecode
import requests
from beets import ui
from beets.autotag import AlbumInfo, TrackInfo
from beets.plugins import MetadataSourcePlugin, BeetsPlugin
class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
data_source = 'Deezer'
# Base URLs for the Deezer API
# Documentation: https://developers.deezer.com/api/
search_url = 'https://api.deezer.com/search/'
album_url = 'https://api.deezer.com/album/'
track_url = 'https://api.deezer.com/track/'
id_regex = {
'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)',
'match_group': 4,
}
def __init__(self):
super().__init__()
def album_for_id(self, album_id):
"""Fetch an album by its Deezer ID or URL and return an
AlbumInfo object or None if the album is not found.
:param album_id: Deezer ID or URL for the album.
:type album_id: str
:return: AlbumInfo object for album.
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
deezer_id = self._get_id('album', album_id)
if deezer_id is None:
return None
album_data = requests.get(self.album_url + deezer_id).json()
artist, artist_id = self.get_artist(album_data['contributors'])
release_date = album_data['release_date']
date_parts = [int(part) for part in release_date.split('-')]
num_date_parts = len(date_parts)
if num_date_parts == 3:
year, month, day = date_parts
elif num_date_parts == 2:
year, month = date_parts
day = None
elif num_date_parts == 1:
year = date_parts[0]
month = None
day = None
else:
raise ui.UserError(
"Invalid `release_date` returned "
"by {} API: '{}'".format(self.data_source, release_date)
)
tracks_data = requests.get(
self.album_url + deezer_id + '/tracks'
).json()['data']
if not tracks_data:
return None
tracks = []
medium_totals = collections.defaultdict(int)
for i, track_data in enumerate(tracks_data, start=1):
track = self._get_track(track_data)
track.index = i
medium_totals[track.medium] += 1
tracks.append(track)
for track in tracks:
track.medium_total = medium_totals[track.medium]
return AlbumInfo(
album=album_data['title'],
album_id=deezer_id,
artist=artist,
artist_credit=self.get_artist([album_data['artist']])[0],
artist_id=artist_id,
tracks=tracks,
albumtype=album_data['record_type'],
va=len(album_data['contributors']) == 1
and artist.lower() == 'various artists',
year=year,
month=month,
day=day,
label=album_data['label'],
mediums=max(medium_totals.keys()),
data_source=self.data_source,
data_url=album_data['link'],
)
def _get_track(self, track_data):
"""Convert a Deezer track object dict to a TrackInfo object.
:param track_data: Deezer Track object dict
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo
"""
artist, artist_id = self.get_artist(
track_data.get('contributors', [track_data['artist']])
)
return TrackInfo(
title=track_data['title'],
track_id=track_data['id'],
artist=artist,
artist_id=artist_id,
length=track_data['duration'],
index=track_data['track_position'],
medium=track_data['disk_number'],
medium_index=track_data['track_position'],
data_source=self.data_source,
data_url=track_data['link'],
)
def track_for_id(self, track_id=None, track_data=None):
"""Fetch a track by its Deezer ID or URL and return a
TrackInfo object or None if the track is not found.
:param track_id: (Optional) Deezer ID or URL for the track. Either
``track_id`` or ``track_data`` must be provided.
:type track_id: str
:param track_data: (Optional) Simplified track object dict. May be
provided instead of ``track_id`` to avoid unnecessary API calls.
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo or None
"""
if track_data is None:
deezer_id = self._get_id('track', track_id)
if deezer_id is None:
return None
track_data = requests.get(self.track_url + deezer_id).json()
track = self._get_track(track_data)
# Get album's tracks to set `track.index` (position on the entire
# release) and `track.medium_total` (total number of tracks on
# the track's disc).
album_tracks_data = requests.get(
self.album_url + str(track_data['album']['id']) + '/tracks'
).json()['data']
medium_total = 0
for i, track_data in enumerate(album_tracks_data, start=1):
if track_data['disk_number'] == track.medium:
medium_total += 1
if track_data['id'] == track.track_id:
track.index = i
track.medium_total = medium_total
return track
@staticmethod
def _construct_search_query(filters=None, keywords=''):
"""Construct a query string with the specified filters and keywords to
be provided to the Deezer Search API
(https://developers.deezer.com/api/search).
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:return: Query string to be provided to the Search API.
:rtype: str
"""
query_components = [
keywords,
' '.join(f'{k}:"{v}"' for k, v in filters.items()),
]
query = ' '.join([q for q in query_components if q])
if not isinstance(query, str):
query = query.decode('utf8')
return unidecode.unidecode(query)
def _search_api(self, query_type, filters=None, keywords=''):
"""Query the Deezer Search API for the specified ``keywords``, applying
the provided ``filters``.
:param query_type: The Deezer Search API method to use. Valid types
are: 'album', 'artist', 'history', 'playlist', 'podcast',
'radio', 'track', 'user', and 'track'.
:type query_type: str
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:return: JSON data for the class:`Response <Response>` object or None
if no search results are returned.
:rtype: dict or None
"""
query = self._construct_search_query(
keywords=keywords, filters=filters
)
if not query:
return None
self._log.debug(
f"Searching {self.data_source} for '{query}'"
)
response = requests.get(
self.search_url + query_type, params={'q': query}
)
response.raise_for_status()
response_data = response.json().get('data', [])
self._log.debug(
"Found {} result(s) from {} for '{}'",
len(response_data),
self.data_source,
query,
)
return response_data

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -14,19 +13,18 @@
# included in all copies or substantial portions of the Software.
"""Adds Discogs album search support to the autotagger. Requires the
discogs-client library.
python3-discogs-client library.
"""
from __future__ import division, absolute_import, print_function
import beets.ui
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin
from beets.util import confit
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance
import confuse
from discogs_client import Release, Master, Client
from discogs_client.exceptions import DiscogsAPIError
from requests.exceptions import ConnectionError
from six.moves import http_client
import http.client
import beets
import re
import time
@ -37,10 +35,12 @@ import traceback
from string import ascii_lowercase
USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__)
USER_AGENT = f'beets/{beets.__version__} +https://beets.io/'
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
# Exceptions that discogs_client should really handle but does not.
CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
CONNECTION_ERRORS = (ConnectionError, socket.error, http.client.HTTPException,
ValueError, # JSON decoding raises a ValueError.
DiscogsAPIError)
@ -48,13 +48,15 @@ CONNECTION_ERRORS = (ConnectionError, socket.error, http_client.HTTPException,
class DiscogsPlugin(BeetsPlugin):
def __init__(self):
super(DiscogsPlugin, self).__init__()
super().__init__()
self.config.add({
'apikey': 'rAzVUQYRaoFjeBjyWuWZ',
'apisecret': 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy',
'apikey': API_KEY,
'apisecret': API_SECRET,
'tokenfile': 'discogs_token.json',
'source_weight': 0.5,
'user_token': '',
'separator': ', ',
'index_tracks': False,
})
self.config['apikey'].redact = True
self.config['apisecret'].redact = True
@ -71,6 +73,8 @@ class DiscogsPlugin(BeetsPlugin):
# Try using a configured user token (bypassing OAuth login).
user_token = self.config['user_token'].as_str()
if user_token:
# The rate limit for authenticated users goes up to 60
# requests per minute.
self.discogs_client = Client(USER_AGENT, user_token=user_token)
return
@ -78,7 +82,7 @@ class DiscogsPlugin(BeetsPlugin):
try:
with open(self._tokenfile()) as f:
tokendata = json.load(f)
except IOError:
except OSError:
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
else:
@ -97,7 +101,7 @@ class DiscogsPlugin(BeetsPlugin):
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
return self.config['tokenfile'].get(confit.Filename(in_app_dir=True))
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
def authenticate(self, c_key, c_secret):
# Get the link for the OAuth page.
@ -105,24 +109,24 @@ class DiscogsPlugin(BeetsPlugin):
try:
_, _, url = auth_client.get_authorize_url()
except CONNECTION_ERRORS as e:
self._log.debug(u'connection error: {0}', e)
raise beets.ui.UserError(u'communication with Discogs failed')
self._log.debug('connection error: {0}', e)
raise beets.ui.UserError('communication with Discogs failed')
beets.ui.print_(u"To authenticate with Discogs, visit:")
beets.ui.print_("To authenticate with Discogs, visit:")
beets.ui.print_(url)
# Ask for the code and validate it.
code = beets.ui.input_(u"Enter the code:")
code = beets.ui.input_("Enter the code:")
try:
token, secret = auth_client.get_access_token(code)
except DiscogsAPIError:
raise beets.ui.UserError(u'Discogs authorization failed')
raise beets.ui.UserError('Discogs authorization failed')
except CONNECTION_ERRORS as e:
self._log.debug(u'connection error: {0}', e)
raise beets.ui.UserError(u'Discogs token request failed')
self._log.debug('connection error: {0}', e)
raise beets.ui.UserError('Discogs token request failed')
# Save the token for later use.
self._log.debug(u'Discogs token {0}, secret {1}', token, secret)
self._log.debug('Discogs token {0}, secret {1}', token, secret)
with open(self._tokenfile(), 'w') as f:
json.dump({'token': token, 'secret': secret}, f)
@ -131,12 +135,22 @@ class DiscogsPlugin(BeetsPlugin):
def album_distance(self, items, album_info, mapping):
"""Returns the album distance.
"""
dist = Distance()
if album_info.data_source == 'Discogs':
dist.add('source', self.config['source_weight'].as_number())
return dist
return get_distance(
data_source='Discogs',
info=album_info,
config=self.config
)
def candidates(self, items, artist, album, va_likely):
def track_distance(self, item, track_info):
"""Returns the track distance.
"""
return get_distance(
data_source='Discogs',
info=track_info,
config=self.config
)
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for discogs search results
matching an album and artist (if not various).
"""
@ -146,20 +160,45 @@ class DiscogsPlugin(BeetsPlugin):
if va_likely:
query = album
else:
query = '%s %s' % (artist, album)
query = f'{artist} {album}'
try:
return self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug(u'API Error: {0} (query: {1})', e, query)
self._log.debug('API Error: {0} (query: {1})', e, query)
if e.status_code == 401:
self.reset_auth()
return self.candidates(items, artist, album, va_likely)
else:
return []
except CONNECTION_ERRORS:
self._log.debug(u'Connection error in album search', exc_info=True)
self._log.debug('Connection error in album search', exc_info=True)
return []
@staticmethod
def extract_release_id_regex(album_id):
"""Returns the Discogs_id or None."""
# Discogs-IDs are simple integers. In order to avoid confusion with
# other metadata plugins, we only look for very specific formats of the
# input string:
# - plain integer, optionally wrapped in brackets and prefixed by an
# 'r', as this is how discogs displays the release ID on its webpage.
# - legacy url format: discogs.com/<name of release>/release/<id>
# - current url format: discogs.com/release/<id>-<name of release>
# See #291, #4080 and #4085 for the discussions leading up to these
# patterns.
# Regex has been tested here https://regex101.com/r/wyLdB4/2
for pattern in [
r'^\[?r?(?P<id>\d+)\]?$',
r'discogs\.com/release/(?P<id>\d+)-',
r'discogs\.com/[^/]+/release/(?P<id>\d+)',
]:
match = re.search(pattern, album_id)
if match:
return int(match.group('id'))
return None
def album_for_id(self, album_id):
"""Fetches an album by its Discogs ID and returns an AlbumInfo object
or None if the album is not found.
@ -167,28 +206,28 @@ class DiscogsPlugin(BeetsPlugin):
if not self.discogs_client:
return
self._log.debug(u'Searching for release {0}', album_id)
# Discogs-IDs are simple integers. We only look for those at the end
# of an input string as to avoid confusion with other metadata plugins.
# An optional bracket can follow the integer, as this is how discogs
# displays the release ID on its webpage.
match = re.search(r'(^|\[*r|discogs\.com/.+/release/)(\d+)($|\])',
album_id)
if not match:
self._log.debug('Searching for release {0}', album_id)
discogs_id = self.extract_release_id_regex(album_id)
if not discogs_id:
return None
result = Release(self.discogs_client, {'id': int(match.group(2))})
result = Release(self.discogs_client, {'id': discogs_id})
# Try to obtain title to verify that we indeed have a valid Release
try:
getattr(result, 'title')
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
self._log.debug('API Error: {0} (query: {1})', e,
result.data['resource_url'])
if e.status_code == 401:
self.reset_auth()
return self.album_for_id(album_id)
return None
except CONNECTION_ERRORS:
self._log.debug(u'Connection error in album lookup', exc_info=True)
self._log.debug('Connection error in album lookup',
exc_info=True)
return None
return self.get_album_info(result)
@ -199,18 +238,17 @@ class DiscogsPlugin(BeetsPlugin):
# cause a query to return no results, even if they match the artist or
# album title. Use `re.UNICODE` flag to avoid stripping non-english
# word characters.
# FIXME: Encode as ASCII to work around a bug:
# https://github.com/beetbox/beets/issues/1051
# When the library is fixed, we should encode as UTF-8.
query = re.sub(r'(?u)\W+', ' ', query).encode('ascii', "replace")
query = re.sub(r'(?u)\W+', ' ', query)
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(br'(?i)\b(CD|disc)\s*\d+', b'', query)
query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
try:
releases = self.discogs_client.search(query,
type='release').page(1)
except CONNECTION_ERRORS:
self._log.debug(u"Communication error while searching for {0!r}",
self._log.debug("Communication error while searching for {0!r}",
query, exc_info=True)
return []
return [album for album in map(self.get_album_info, releases[:5])
@ -220,20 +258,22 @@ class DiscogsPlugin(BeetsPlugin):
"""Fetches a master release given its Discogs ID and returns its year
or None if the master release is not found.
"""
self._log.debug(u'Searching for master release {0}', master_id)
self._log.debug('Searching for master release {0}', master_id)
result = Master(self.discogs_client, {'id': master_id})
try:
year = result.fetch('year')
return year
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
self._log.debug('API Error: {0} (query: {1})', e,
result.data['resource_url'])
if e.status_code == 401:
self.reset_auth()
return self.get_master_year(master_id)
return None
except CONNECTION_ERRORS:
self._log.debug(u'Connection error in master release lookup',
self._log.debug('Connection error in master release lookup',
exc_info=True)
return None
@ -252,10 +292,12 @@ class DiscogsPlugin(BeetsPlugin):
# https://www.discogs.com/help/doc/submission-guidelines-general-rules
if not all([result.data.get(k) for k in ['artists', 'title', 'id',
'tracklist']]):
self._log.warn(u"Release does not contain the required fields")
self._log.warning("Release does not contain the required fields")
return None
artist, artist_id = self.get_artist([a.data for a in result.artists])
artist, artist_id = MetadataSourcePlugin.get_artist(
[a.data for a in result.artists]
)
album = re.sub(r' +', ' ', result.title)
album_id = result.data['id']
# Use `.data` to access the tracklist directly instead of the
@ -270,10 +312,13 @@ class DiscogsPlugin(BeetsPlugin):
mediums = [t.medium for t in tracks]
country = result.data.get('country')
data_url = result.data.get('uri')
style = self.format(result.data.get('styles'))
genre = self.format(result.data.get('genres'))
discogs_albumid = self.extract_release_id(result.data.get('uri'))
# Extract information for the optional AlbumInfo fields that are
# contained on nested discogs fields.
albumtype = media = label = catalogno = None
albumtype = media = label = catalogno = labelid = None
if result.data.get('formats'):
albumtype = ', '.join(
result.data['formats'][0].get('descriptions', [])) or None
@ -281,12 +326,13 @@ class DiscogsPlugin(BeetsPlugin):
if result.data.get('labels'):
label = result.data['labels'][0].get('name')
catalogno = result.data['labels'][0].get('catno')
labelid = result.data['labels'][0].get('id')
# Additional cleanups (various artists name, catalog number, media).
if va:
artist = config['va_name'].as_str()
if catalogno == 'none':
catalogno = None
catalogno = None
# Explicitly set the `media` for the tracks, since it is expected by
# `autotag.apply_metadata`, and set `medium_total`.
for track in tracks:
@ -302,36 +348,29 @@ class DiscogsPlugin(BeetsPlugin):
# a master release, otherwise fetch the master release.
original_year = self.get_master_year(master_id) if master_id else year
return AlbumInfo(album, album_id, artist, artist_id, tracks, asin=None,
albumtype=albumtype, va=va, year=year, month=None,
day=None, label=label, mediums=len(set(mediums)),
artist_sort=None, releasegroup_id=master_id,
catalognum=catalogno, script=None, language=None,
country=country, albumstatus=None, media=media,
albumdisambig=None, artist_credit=None,
original_year=original_year, original_month=None,
original_day=None, data_source='Discogs',
data_url=data_url)
return AlbumInfo(album=album, album_id=album_id, artist=artist,
artist_id=artist_id, tracks=tracks,
albumtype=albumtype, va=va, year=year,
label=label, mediums=len(set(mediums)),
releasegroup_id=master_id, catalognum=catalogno,
country=country, style=style, genre=genre,
media=media, original_year=original_year,
data_source='Discogs', data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid, discogs_artistid=artist_id)
def get_artist(self, artists):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of discogs album or track artists.
"""
artist_id = None
bits = []
for i, artist in enumerate(artists):
if not artist_id:
artist_id = artist['id']
name = artist['name']
# Strip disambiguation number.
name = re.sub(r' \(\d+\)$', '', name)
# Move articles to the front.
name = re.sub(r'(?i)^(.*?), (a|an|the)$', r'\2 \1', name)
bits.append(name)
if artist['join'] and i < len(artists) - 1:
bits.append(artist['join'])
artist = ' '.join(bits).replace(' ,', ',') or None
return artist, artist_id
def format(self, classification):
if classification:
return self.config['separator'].as_str() \
.join(sorted(classification))
else:
return None
def extract_release_id(self, uri):
if uri:
return uri.split("/")[-1]
else:
return None
def get_tracks(self, tracklist):
"""Returns a list of TrackInfo objects for a discogs tracklist.
@ -342,20 +381,34 @@ class DiscogsPlugin(BeetsPlugin):
# FIXME: this is an extra precaution for making sure there are no
# side effects after #2222. It should be removed after further
# testing.
self._log.debug(u'{}', traceback.format_exc())
self._log.error(u'uncaught exception in coalesce_tracks: {}', exc)
self._log.debug('{}', traceback.format_exc())
self._log.error('uncaught exception in coalesce_tracks: {}', exc)
clean_tracklist = tracklist
tracks = []
index_tracks = {}
index = 0
# Distinct works and intra-work divisions, as defined by index tracks.
divisions, next_divisions = [], []
for track in clean_tracklist:
# Only real tracks have `position`. Otherwise, it's an index track.
if track['position']:
index += 1
track_info = self.get_track_info(track, index)
if next_divisions:
# End of a block of index tracks: update the current
# divisions.
divisions += next_divisions
del next_divisions[:]
track_info = self.get_track_info(track, index, divisions)
track_info.track_alt = track['position']
tracks.append(track_info)
else:
next_divisions.append(track['title'])
# We expect new levels of division at the beginning of the
# tracklist (and possibly elsewhere).
try:
divisions.pop()
except IndexError:
pass
index_tracks[index + 1] = track['title']
# Fix up medium and medium_index for each track. Discogs position is
@ -367,7 +420,7 @@ class DiscogsPlugin(BeetsPlugin):
# If a medium has two sides (ie. vinyl or cassette), each pair of
# consecutive sides should belong to the same medium.
if all([track.medium is not None for track in tracks]):
m = sorted(set([track.medium.lower() for track in tracks]))
m = sorted({track.medium.lower() for track in tracks})
# If all track.medium are single consecutive letters, assume it is
# a 2-sided medium.
if ''.join(m) in ascii_lowercase:
@ -426,7 +479,7 @@ class DiscogsPlugin(BeetsPlugin):
# Calculate position based on first subtrack, without subindex.
idx, medium_idx, sub_idx = \
self.get_track_index(subtracks[0]['position'])
position = '%s%s' % (idx or '', medium_idx or '')
position = '{}{}'.format(idx or '', medium_idx or '')
if tracklist and not tracklist[-1]['position']:
# Assume the previous index track contains the track title.
@ -444,6 +497,12 @@ class DiscogsPlugin(BeetsPlugin):
for subtrack in subtracks:
if not subtrack.get('artists'):
subtrack['artists'] = index_track['artists']
# Concatenate index with track title when index_tracks
# option is set
if self.config['index_tracks']:
for subtrack in subtracks:
subtrack['title'] = '{}: {}'.format(
index_track['title'], subtrack['title'])
tracklist.extend(subtracks)
else:
# Merge the subtracks, pick a title, and append the new track.
@ -490,18 +549,23 @@ class DiscogsPlugin(BeetsPlugin):
return tracklist
def get_track_info(self, track, index):
def get_track_info(self, track, index, divisions):
"""Returns a TrackInfo object for a discogs track.
"""
title = track['title']
if self.config['index_tracks']:
prefix = ', '.join(divisions)
if prefix:
title = f'{prefix}: {title}'
track_id = None
medium, medium_index, _ = self.get_track_index(track['position'])
artist, artist_id = self.get_artist(track.get('artists', []))
artist, artist_id = MetadataSourcePlugin.get_artist(
track.get('artists', [])
)
length = self.get_track_length(track['duration'])
return TrackInfo(title, track_id, artist=artist, artist_id=artist_id,
length=length, index=index,
medium=medium, medium_index=medium_index,
artist_sort=None, disctitle=None, artist_credit=None)
return TrackInfo(title=title, track_id=track_id, artist=artist,
artist_id=artist_id, length=length, index=index,
medium=medium, medium_index=medium_index)
def get_track_index(self, position):
"""Returns the medium, medium index and subtrack index for a discogs
@ -528,7 +592,7 @@ class DiscogsPlugin(BeetsPlugin):
if subindex and subindex.startswith('.'):
subindex = subindex[1:]
else:
self._log.debug(u'Invalid position: {0}', position)
self._log.debug('Invalid position: {0}', position)
medium = index = subindex = None
return medium or None, index or None, subindex or None

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Pedro Silva.
#
@ -15,16 +14,15 @@
"""List duplicate tracks or albums.
"""
from __future__ import division, absolute_import, print_function
import shlex
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_, Subcommand, UserError
from beets.util import command_output, displayable_path, subprocess, \
bytestring_path, MoveOperation
bytestring_path, MoveOperation, decode_commandline_path
from beets.library import Item, Album
import six
PLUGIN = 'duplicates'
@ -33,7 +31,7 @@ class DuplicatesPlugin(BeetsPlugin):
"""List duplicate tracks or albums
"""
def __init__(self):
super(DuplicatesPlugin, self).__init__()
super().__init__()
self.config.add({
'album': False,
@ -56,54 +54,54 @@ class DuplicatesPlugin(BeetsPlugin):
help=__doc__,
aliases=['dup'])
self._command.parser.add_option(
u'-c', u'--count', dest='count',
'-c', '--count', dest='count',
action='store_true',
help=u'show duplicate counts',
help='show duplicate counts',
)
self._command.parser.add_option(
u'-C', u'--checksum', dest='checksum',
'-C', '--checksum', dest='checksum',
action='store', metavar='PROG',
help=u'report duplicates based on arbitrary command',
help='report duplicates based on arbitrary command',
)
self._command.parser.add_option(
u'-d', u'--delete', dest='delete',
'-d', '--delete', dest='delete',
action='store_true',
help=u'delete items from library and disk',
help='delete items from library and disk',
)
self._command.parser.add_option(
u'-F', u'--full', dest='full',
'-F', '--full', dest='full',
action='store_true',
help=u'show all versions of duplicate tracks or albums',
help='show all versions of duplicate tracks or albums',
)
self._command.parser.add_option(
u'-s', u'--strict', dest='strict',
'-s', '--strict', dest='strict',
action='store_true',
help=u'report duplicates only if all attributes are set',
help='report duplicates only if all attributes are set',
)
self._command.parser.add_option(
u'-k', u'--key', dest='keys',
'-k', '--key', dest='keys',
action='append', metavar='KEY',
help=u'report duplicates based on keys (use multiple times)',
help='report duplicates based on keys (use multiple times)',
)
self._command.parser.add_option(
u'-M', u'--merge', dest='merge',
'-M', '--merge', dest='merge',
action='store_true',
help=u'merge duplicate items',
help='merge duplicate items',
)
self._command.parser.add_option(
u'-m', u'--move', dest='move',
'-m', '--move', dest='move',
action='store', metavar='DEST',
help=u'move items to dest',
help='move items to dest',
)
self._command.parser.add_option(
u'-o', u'--copy', dest='copy',
'-o', '--copy', dest='copy',
action='store', metavar='DEST',
help=u'copy items to dest',
help='copy items to dest',
)
self._command.parser.add_option(
u'-t', u'--tag', dest='tag',
'-t', '--tag', dest='tag',
action='store',
help=u'tag matched items with \'k=v\' attribute',
help='tag matched items with \'k=v\' attribute',
)
self._command.parser.add_all_common_options()
@ -135,16 +133,21 @@ class DuplicatesPlugin(BeetsPlugin):
keys = ['mb_trackid', 'mb_albumid']
items = lib.items(decargs(args))
# If there's nothing to do, return early. The code below assumes
# `items` to be non-empty.
if not items:
return
if path:
fmt = u'$path'
fmt = '$path'
# Default format string for count mode.
if count and not fmt:
if album:
fmt = u'$albumartist - $album'
fmt = '$albumartist - $album'
else:
fmt = u'$albumartist - $album - $title'
fmt += u': {0}'
fmt = '$albumartist - $album - $title'
fmt += ': {0}'
if checksum:
for i in items:
@ -170,7 +173,7 @@ class DuplicatesPlugin(BeetsPlugin):
return [self._command]
def _process_item(self, item, copy=False, move=False, delete=False,
tag=False, fmt=u''):
tag=False, fmt=''):
"""Process Item `item`.
"""
print_(format(item, fmt))
@ -187,7 +190,7 @@ class DuplicatesPlugin(BeetsPlugin):
k, v = tag.split('=')
except Exception:
raise UserError(
u"{}: can't parse k=v tag: {}".format(PLUGIN, tag)
f"{PLUGIN}: can't parse k=v tag: {tag}"
)
setattr(item, k, v)
item.store()
@ -197,25 +200,26 @@ class DuplicatesPlugin(BeetsPlugin):
output as flexattr on a key that is the name of the program, and
return the key, checksum tuple.
"""
args = [p.format(file=item.path) for p in shlex.split(prog)]
args = [p.format(file=decode_commandline_path(item.path))
for p in shlex.split(prog)]
key = args[0]
checksum = getattr(item, key, False)
if not checksum:
self._log.debug(u'key {0} on item {1} not cached:'
u'computing checksum',
self._log.debug('key {0} on item {1} not cached:'
'computing checksum',
key, displayable_path(item.path))
try:
checksum = command_output(args)
checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug(u'computed checksum for {0} using {1}',
self._log.debug('computed checksum for {0} using {1}',
item.title, key)
except subprocess.CalledProcessError as e:
self._log.debug(u'failed to checksum {0}: {1}',
self._log.debug('failed to checksum {0}: {1}',
displayable_path(item.path), e)
else:
self._log.debug(u'key {0} on item {1} cached:'
u'not computing checksum',
self._log.debug('key {0} on item {1} cached:'
'not computing checksum',
key, displayable_path(item.path))
return key, checksum
@ -231,12 +235,12 @@ class DuplicatesPlugin(BeetsPlugin):
values = [getattr(obj, k, None) for k in keys]
values = [v for v in values if v not in (None, '')]
if strict and len(values) < len(keys):
self._log.debug(u'some keys {0} on item {1} are null or empty:'
u' skipping',
self._log.debug('some keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
elif (not strict and not len(values)):
self._log.debug(u'all keys {0} on item {1} are null or empty:'
u' skipping',
self._log.debug('all keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
else:
key = tuple(values)
@ -264,7 +268,7 @@ class DuplicatesPlugin(BeetsPlugin):
# between a bytes object and the empty Unicode
# string ''.
return v is not None and \
(v != '' if isinstance(v, six.text_type) else True)
(v != '' if isinstance(v, str) else True)
fields = Item.all_keys()
key = lambda x: sum(1 for f in fields if truthy(getattr(x, f)))
else:
@ -284,8 +288,8 @@ class DuplicatesPlugin(BeetsPlugin):
if getattr(objs[0], f, None) in (None, ''):
value = getattr(o, f, None)
if value:
self._log.debug(u'key {0} on item {1} is null '
u'or empty: setting from item {2}',
self._log.debug('key {0} on item {1} is null '
'or empty: setting from item {2}',
f, displayable_path(objs[0].path),
displayable_path(o.path))
setattr(objs[0], f, value)
@ -305,8 +309,8 @@ class DuplicatesPlugin(BeetsPlugin):
missing = Item.from_path(i.path)
missing.album_id = objs[0].id
missing.add(i._db)
self._log.debug(u'item {0} missing from album {1}:'
u' merging from {2} into {3}',
self._log.debug('item {0} missing from album {1}:'
' merging from {2} into {3}',
missing,
objs[0],
displayable_path(o.path),

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016
#
@ -15,7 +14,6 @@
"""Open metadata information in a text editor to let the user edit it.
"""
from __future__ import division, absolute_import, print_function
from beets import plugins
from beets import util
@ -28,7 +26,7 @@ import subprocess
import yaml
from tempfile import NamedTemporaryFile
import os
import six
import shlex
# These "safe" types can avoid the format/parse cycle that most fields go
@ -45,13 +43,13 @@ class ParseError(Exception):
def edit(filename, log):
"""Open `filename` in a text editor.
"""
cmd = util.shlex_split(util.editor_command())
cmd = shlex.split(util.editor_command())
cmd.append(filename)
log.debug(u'invoking editor command: {!r}', cmd)
log.debug('invoking editor command: {!r}', cmd)
try:
subprocess.call(cmd)
except OSError as exc:
raise ui.UserError(u'could not run editor command {!r}: {}'.format(
raise ui.UserError('could not run editor command {!r}: {}'.format(
cmd[0], exc
))
@ -74,20 +72,20 @@ def load(s):
"""
try:
out = []
for d in yaml.load_all(s):
for d in yaml.safe_load_all(s):
if not isinstance(d, dict):
raise ParseError(
u'each entry must be a dictionary; found {}'.format(
'each entry must be a dictionary; found {}'.format(
type(d).__name__
)
)
# Convert all keys to strings. They started out as strings,
# but the user may have inadvertently messed this up.
out.append({six.text_type(k): v for k, v in d.items()})
out.append({str(k): v for k, v in d.items()})
except yaml.YAMLError as e:
raise ParseError(u'invalid YAML: {}'.format(e))
raise ParseError(f'invalid YAML: {e}')
return out
@ -143,13 +141,13 @@ def apply_(obj, data):
else:
# Either the field was stringified originally or the user changed
# it from a safe type to an unsafe one. Parse it as a string.
obj.set_parse(key, six.text_type(value))
obj.set_parse(key, str(value))
class EditPlugin(plugins.BeetsPlugin):
def __init__(self):
super(EditPlugin, self).__init__()
super().__init__()
self.config.add({
# The default fields to edit.
@ -166,18 +164,18 @@ class EditPlugin(plugins.BeetsPlugin):
def commands(self):
edit_command = ui.Subcommand(
'edit',
help=u'interactively edit metadata'
help='interactively edit metadata'
)
edit_command.parser.add_option(
u'-f', u'--field',
'-f', '--field',
metavar='FIELD',
action='append',
help=u'edit this field also',
help='edit this field also',
)
edit_command.parser.add_option(
u'--all',
'--all',
action='store_true', dest='all',
help=u'edit all fields',
help='edit all fields',
)
edit_command.parser.add_album_option()
edit_command.func = self._edit_command
@ -191,7 +189,7 @@ class EditPlugin(plugins.BeetsPlugin):
items, albums = _do_query(lib, query, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_(u'Nothing to edit.')
ui.print_('Nothing to edit.')
return
# Get the fields to edit.
@ -244,15 +242,10 @@ class EditPlugin(plugins.BeetsPlugin):
old_data = [flatten(o, fields) for o in objs]
# Set up a temporary file with the initial data for editing.
if six.PY2:
new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False)
else:
new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False,
encoding='utf-8')
new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False,
encoding='utf-8')
old_str = dump(old_data)
new.write(old_str)
if six.PY2:
old_str = old_str.decode('utf-8')
new.close()
# Loop until we have parseable data and the user confirms.
@ -266,15 +259,15 @@ class EditPlugin(plugins.BeetsPlugin):
with codecs.open(new.name, encoding='utf-8') as f:
new_str = f.read()
if new_str == old_str:
ui.print_(u"No changes; aborting.")
ui.print_("No changes; aborting.")
return False
# Parse the updated data.
try:
new_data = load(new_str)
except ParseError as e:
ui.print_(u"Could not read data: {}".format(e))
if ui.input_yn(u"Edit again to fix? (Y/n)", True):
ui.print_(f"Could not read data: {e}")
if ui.input_yn("Edit again to fix? (Y/n)", True):
continue
else:
return False
@ -289,18 +282,18 @@ class EditPlugin(plugins.BeetsPlugin):
for obj, obj_old in zip(objs, objs_old):
changed |= ui.show_model_changes(obj, obj_old)
if not changed:
ui.print_(u'No changes to apply.')
ui.print_('No changes to apply.')
return False
# Confirm the changes.
choice = ui.input_options(
(u'continue Editing', u'apply', u'cancel')
('continue Editing', 'apply', 'cancel')
)
if choice == u'a': # Apply.
if choice == 'a': # Apply.
return True
elif choice == u'c': # Cancel.
elif choice == 'c': # Cancel.
return False
elif choice == u'e': # Keep editing.
elif choice == 'e': # Keep editing.
# Reset the temporary changes to the objects. I we have a
# copy from above, use that, else reload from the database.
objs = [(old_obj or obj)
@ -322,7 +315,7 @@ class EditPlugin(plugins.BeetsPlugin):
are temporary.
"""
if len(old_data) != len(new_data):
self._log.warning(u'number of objects changed from {} to {}',
self._log.warning('number of objects changed from {} to {}',
len(old_data), len(new_data))
obj_by_id = {o.id: o for o in objs}
@ -333,7 +326,7 @@ class EditPlugin(plugins.BeetsPlugin):
forbidden = False
for key in ignore_fields:
if old_dict.get(key) != new_dict.get(key):
self._log.warning(u'ignoring object whose {} changed', key)
self._log.warning('ignoring object whose {} changed', key)
forbidden = True
break
if forbidden:
@ -348,7 +341,7 @@ class EditPlugin(plugins.BeetsPlugin):
# Save to the database and possibly write tags.
for ob in objs:
if ob._dirty:
self._log.debug(u'saving changes to {}', ob)
self._log.debug('saving changes to {}', ob)
ob.try_sync(ui.should_write(), ui.should_move())
# Methods for interactive importer execution.

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -14,7 +13,6 @@
# included in all copies or substantial portions of the Software.
"""Allows beets to embed album art into file metadata."""
from __future__ import division, absolute_import, print_function
import os.path
@ -34,11 +32,11 @@ def _confirm(objs, album):
`album` is a Boolean indicating whether these are albums (as opposed
to items).
"""
noun = u'album' if album else u'file'
prompt = u'Modify artwork for {} {}{} (Y/n)?'.format(
noun = 'album' if album else 'file'
prompt = 'Modify artwork for {} {}{} (Y/n)?'.format(
len(objs),
noun,
u's' if len(objs) > 1 else u''
's' if len(objs) > 1 else ''
)
# Show all the items or albums.
@ -53,39 +51,41 @@ class EmbedCoverArtPlugin(BeetsPlugin):
"""Allows albumart to be embedded into the actual files.
"""
def __init__(self):
super(EmbedCoverArtPlugin, self).__init__()
super().__init__()
self.config.add({
'maxwidth': 0,
'auto': True,
'compare_threshold': 0,
'ifempty': False,
'remove_art_file': False
'remove_art_file': False,
'quality': 0,
})
if self.config['maxwidth'].get(int) and not ArtResizer.shared.local:
self.config['maxwidth'] = 0
self._log.warning(u"ImageMagick or PIL not found; "
u"'maxwidth' option ignored")
self._log.warning("ImageMagick or PIL not found; "
"'maxwidth' option ignored")
if self.config['compare_threshold'].get(int) and not \
ArtResizer.shared.can_compare:
self.config['compare_threshold'] = 0
self._log.warning(u"ImageMagick 6.8.7 or higher not installed; "
u"'compare_threshold' option ignored")
self._log.warning("ImageMagick 6.8.7 or higher not installed; "
"'compare_threshold' option ignored")
self.register_listener('art_set', self.process_album)
def commands(self):
# Embed command.
embed_cmd = ui.Subcommand(
'embedart', help=u'embed image files into file metadata'
'embedart', help='embed image files into file metadata'
)
embed_cmd.parser.add_option(
u'-f', u'--file', metavar='PATH', help=u'the image file to embed'
'-f', '--file', metavar='PATH', help='the image file to embed'
)
embed_cmd.parser.add_option(
u"-y", u"--yes", action="store_true", help=u"skip confirmation"
"-y", "--yes", action="store_true", help="skip confirmation"
)
maxwidth = self.config['maxwidth'].get(int)
quality = self.config['quality'].get(int)
compare_threshold = self.config['compare_threshold'].get(int)
ifempty = self.config['ifempty'].get(bool)
@ -93,7 +93,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
if opts.file:
imagepath = normpath(opts.file)
if not os.path.isfile(syspath(imagepath)):
raise ui.UserError(u'image file {0} not found'.format(
raise ui.UserError('image file {} not found'.format(
displayable_path(imagepath)
))
@ -104,8 +104,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for item in items:
art.embed_item(self._log, item, imagepath, maxwidth, None,
compare_threshold, ifempty)
art.embed_item(self._log, item, imagepath, maxwidth,
None, compare_threshold, ifempty,
quality=quality)
else:
albums = lib.albums(decargs(args))
@ -114,8 +115,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for album in albums:
art.embed_album(self._log, album, maxwidth, False,
compare_threshold, ifempty)
art.embed_album(self._log, album, maxwidth,
False, compare_threshold, ifempty,
quality=quality)
self.remove_artfile(album)
embed_cmd.func = embed_func
@ -123,15 +125,15 @@ class EmbedCoverArtPlugin(BeetsPlugin):
# Extract command.
extract_cmd = ui.Subcommand(
'extractart',
help=u'extract an image from file metadata',
help='extract an image from file metadata',
)
extract_cmd.parser.add_option(
u'-o', dest='outpath',
help=u'image output file',
'-o', dest='outpath',
help='image output file',
)
extract_cmd.parser.add_option(
u'-n', dest='filename',
help=u'image filename to create for all matched albums',
'-n', dest='filename',
help='image filename to create for all matched albums',
)
extract_cmd.parser.add_option(
'-a', dest='associate', action='store_true',
@ -147,7 +149,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
config['art_filename'].get())
if os.path.dirname(filename) != b'':
self._log.error(
u"Only specify a name rather than a path for -n")
"Only specify a name rather than a path for -n")
return
for album in lib.albums(decargs(args)):
artpath = normpath(os.path.join(album.path, filename))
@ -161,10 +163,10 @@ class EmbedCoverArtPlugin(BeetsPlugin):
# Clear command.
clear_cmd = ui.Subcommand(
'clearart',
help=u'remove images from file metadata',
help='remove images from file metadata',
)
clear_cmd.parser.add_option(
u"-y", u"--yes", action="store_true", help=u"skip confirmation"
"-y", "--yes", action="store_true", help="skip confirmation"
)
def clear_func(lib, opts, args):
@ -189,11 +191,11 @@ class EmbedCoverArtPlugin(BeetsPlugin):
def remove_artfile(self, album):
"""Possibly delete the album art file for an album (if the
appropriate configuration option is enabled.
appropriate configuration option is enabled).
"""
if self.config['remove_art_file'] and album.artpath:
if os.path.isfile(album.artpath):
self._log.debug(u'Removing album art file for {0}', album)
self._log.debug('Removing album art file for {0}', album)
os.remove(album.artpath)
album.artpath = None
album.store()

View file

@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
"""Updates the Emby Library whenever the beets library is changed.
emby:
@ -9,14 +7,11 @@
apikey: apikey
password: password
"""
from __future__ import division, absolute_import, print_function
import hashlib
import requests
from six.moves.urllib.parse import urlencode
from six.moves.urllib.parse import urljoin, parse_qs, urlsplit, urlunsplit
from urllib.parse import urlencode, urljoin, parse_qs, urlsplit, urlunsplit
from beets import config
from beets.plugins import BeetsPlugin
@ -146,14 +141,14 @@ def get_user(host, port, username):
class EmbyUpdate(BeetsPlugin):
def __init__(self):
super(EmbyUpdate, self).__init__()
super().__init__()
# Adding defaults.
config['emby'].add({
u'host': u'http://localhost',
u'port': 8096,
u'apikey': None,
u'password': None,
'host': 'http://localhost',
'port': 8096,
'apikey': None,
'password': None,
})
self.register_listener('database_change', self.listen_for_db_change)
@ -166,7 +161,7 @@ class EmbyUpdate(BeetsPlugin):
def update(self, lib):
"""When the client exists try to send refresh request to Emby.
"""
self._log.info(u'Updating Emby library...')
self._log.info('Updating Emby library...')
host = config['emby']['host'].get()
port = config['emby']['port'].get()
@ -176,13 +171,13 @@ class EmbyUpdate(BeetsPlugin):
# Check if at least a apikey or password is given.
if not any([password, token]):
self._log.warning(u'Provide at least Emby password or apikey.')
self._log.warning('Provide at least Emby password or apikey.')
return
# Get user information from the Emby API.
user = get_user(host, port, username)
if not user:
self._log.warning(u'User {0} could not be found.'.format(username))
self._log.warning(f'User {username} could not be found.')
return
if not token:
@ -194,7 +189,7 @@ class EmbyUpdate(BeetsPlugin):
token = get_token(host, port, headers, auth_data)
if not token:
self._log.warning(
u'Could not get token for user {0}', username
'Could not get token for user {0}', username
)
return
@ -205,6 +200,6 @@ class EmbyUpdate(BeetsPlugin):
url = api_url(host, port, '/Library/Refresh')
r = requests.post(url, headers=headers)
if r.status_code != 204:
self._log.warning(u'Update could not be triggered')
self._log.warning('Update could not be triggered')
else:
self._log.info(u'Update triggered.')
self._log.info('Update triggered.')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
#
# Permission is hereby granted, free of charge, to any person obtaining
@ -15,23 +14,25 @@
"""Exports data from beets
"""
from __future__ import division, absolute_import, print_function
import sys
import json
import codecs
import json
import csv
from xml.etree import ElementTree
from datetime import datetime, date
from beets.plugins import BeetsPlugin
from beets import ui
from beets import mediafile
from beetsplug.info import make_key_filter, library_data, tag_data
from beets import util
import mediafile
from beetsplug.info import library_data, tag_data
class ExportEncoder(json.JSONEncoder):
"""Deals with dates because JSON doesn't have a standard"""
def default(self, o):
if isinstance(o, datetime) or isinstance(o, date):
if isinstance(o, (datetime, date)):
return o.isoformat()
return json.JSONEncoder.default(self, o)
@ -39,12 +40,12 @@ class ExportEncoder(json.JSONEncoder):
class ExportPlugin(BeetsPlugin):
def __init__(self):
super(ExportPlugin, self).__init__()
super().__init__()
self.config.add({
'default_format': 'json',
'json': {
# json module formatting options
# JSON module formatting options.
'formatting': {
'ensure_ascii': False,
'indent': 4,
@ -52,100 +53,175 @@ class ExportPlugin(BeetsPlugin):
'sort_keys': True
}
},
'jsonlines': {
# JSON Lines formatting options.
'formatting': {
'ensure_ascii': False,
'separators': (',', ': '),
'sort_keys': True
}
},
'csv': {
# CSV module formatting options.
'formatting': {
# The delimiter used to seperate columns.
'delimiter': ',',
# The dialect to use when formating the file output.
'dialect': 'excel'
}
},
'xml': {
# XML module formatting options.
'formatting': {}
}
# TODO: Use something like the edit plugin
# 'item_fields': []
})
def commands(self):
# TODO: Add option to use albums
cmd = ui.Subcommand('export', help=u'export data from beets')
cmd = ui.Subcommand('export', help='export data from beets')
cmd.func = self.run
cmd.parser.add_option(
u'-l', u'--library', action='store_true',
help=u'show library fields instead of tags',
'-l', '--library', action='store_true',
help='show library fields instead of tags',
)
cmd.parser.add_option(
u'--append', action='store_true', default=False,
help=u'if should append data to the file',
'-a', '--album', action='store_true',
help='show album fields instead of tracks (implies "--library")',
)
cmd.parser.add_option(
u'-i', u'--include-keys', default=[],
'--append', action='store_true', default=False,
help='if should append data to the file',
)
cmd.parser.add_option(
'-i', '--include-keys', default=[],
action='append', dest='included_keys',
help=u'comma separated list of keys to show',
help='comma separated list of keys to show',
)
cmd.parser.add_option(
u'-o', u'--output',
help=u'path for the output file. If not given, will print the data'
'-o', '--output',
help='path for the output file. If not given, will print the data'
)
cmd.parser.add_option(
'-f', '--format', default='json',
help="the output format: json (default), jsonlines, csv, or xml"
)
return [cmd]
def run(self, lib, opts, args):
file_path = opts.output
file_format = self.config['default_format'].get(str)
file_mode = 'a' if opts.append else 'w'
file_format = opts.format or self.config['default_format'].get(str)
file_format_is_line_based = (file_format == 'jsonlines')
format_options = self.config[file_format]['formatting'].get(dict)
export_format = ExportFormat.factory(
file_format, **{
file_type=file_format,
**{
'file_path': file_path,
'file_mode': file_mode
}
)
items = []
data_collector = library_data if opts.library else tag_data
if opts.library or opts.album:
data_collector = library_data
else:
data_collector = tag_data
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
key_filter = make_key_filter(included_keys)
for data_emitter in data_collector(lib, ui.decargs(args)):
items = []
for data_emitter in data_collector(
lib, ui.decargs(args),
album=opts.album,
):
try:
data, item = data_emitter()
except (mediafile.UnreadableFileError, IOError) as ex:
self._log.error(u'cannot read file: {0}', ex)
data, item = data_emitter(included_keys or '*')
except (mediafile.UnreadableFileError, OSError) as ex:
self._log.error('cannot read file: {0}', ex)
continue
data = key_filter(data)
items += [data]
for key, value in data.items():
if isinstance(value, bytes):
data[key] = util.displayable_path(value)
export_format.export(items, **format_options)
class ExportFormat(object):
"""The output format type"""
@classmethod
def factory(cls, type, **kwargs):
if type == "json":
if kwargs['file_path']:
return JsonFileFormat(**kwargs)
if file_format_is_line_based:
export_format.export(data, **format_options)
else:
return JsonPrintFormat()
raise NotImplementedError()
items += [data]
def export(self, data, **kwargs):
raise NotImplementedError()
if not file_format_is_line_based:
export_format.export(items, **format_options)
class JsonPrintFormat(ExportFormat):
"""Outputs to the console"""
def export(self, data, **kwargs):
json.dump(data, sys.stdout, cls=ExportEncoder, **kwargs)
class JsonFileFormat(ExportFormat):
"""Saves in a json file"""
def __init__(self, file_path, file_mode=u'w', encoding=u'utf-8'):
class ExportFormat:
"""The output format type"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
self.path = file_path
self.mode = file_mode
self.encoding = encoding
# creates a file object to write/append or sets to stdout
self.out_stream = codecs.open(self.path, self.mode, self.encoding) \
if self.path else sys.stdout
@classmethod
def factory(cls, file_type, **kwargs):
if file_type in ["json", "jsonlines"]:
return JsonFormat(**kwargs)
elif file_type == "csv":
return CSVFormat(**kwargs)
elif file_type == "xml":
return XMLFormat(**kwargs)
else:
raise NotImplementedError()
def export(self, data, **kwargs):
with codecs.open(self.path, self.mode, self.encoding) as f:
json.dump(data, f, cls=ExportEncoder, **kwargs)
raise NotImplementedError()
class JsonFormat(ExportFormat):
"""Saves in a json file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
self.out_stream.write('\n')
class CSVFormat(ExportFormat):
"""Saves in a csv file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
header = list(data[0].keys()) if data else []
writer = csv.DictWriter(self.out_stream, fieldnames=header, **kwargs)
writer.writeheader()
writer.writerows(data)
class XMLFormat(ExportFormat):
"""Saves in a xml file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
# Creates the XML file structure.
library = ElementTree.Element('library')
tracks = ElementTree.SubElement(library, 'tracks')
if data and isinstance(data[0], dict):
for index, item in enumerate(data):
track = ElementTree.SubElement(tracks, 'track')
for key, value in item.items():
track_details = ElementTree.SubElement(track, key)
track_details.text = value
# Depending on the version of python the encoding needs to change
try:
data = ElementTree.tostring(library, encoding='unicode', **kwargs)
except LookupError:
data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
self.out_stream.write(data)

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Malte Ried.
#
@ -16,7 +15,6 @@
"""Filter imported files using a regular expression.
"""
from __future__ import division, absolute_import, print_function
import re
from beets import config
@ -27,7 +25,7 @@ from beets.importer import SingletonImportTask
class FileFilterPlugin(BeetsPlugin):
def __init__(self):
super(FileFilterPlugin, self).__init__()
super().__init__()
self.register_listener('import_task_created',
self.import_task_created_event)
self.config.add({
@ -43,8 +41,8 @@ class FileFilterPlugin(BeetsPlugin):
bytestring_path(self.config['album_path'].get()))
if 'singleton_path' in self.config:
self.path_singleton_regex = re.compile(
bytestring_path(self.config['singleton_path'].get()))
self.path_singleton_regex = re.compile(
bytestring_path(self.config['singleton_path'].get()))
def import_task_created_event(self, session, task):
if task.items and len(task.items) > 0:

View file

@ -0,0 +1,285 @@
# This file is part of beets.
# Copyright 2015, winters jean-marie.
# Copyright 2020, Justin Mayer <https://justinmayer.com>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""This plugin generates tab completions for Beets commands for the Fish shell
<https://fishshell.com/>, including completions for Beets commands, plugin
commands, and option flags. Also generated are completions for all the album
and track fields, suggesting for example `genre:` or `album:` when querying the
Beets database. Completions for the *values* of those fields are not generated
by default but can be added via the `-e` / `--extravalues` flag. For example:
`beet fish -e genre -e albumartist`
"""
from beets.plugins import BeetsPlugin
from beets import library, ui
from beets.ui import commands
from operator import attrgetter
import os
BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n"""
BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
HEAD = '''
function __fish_beet_needs_command
set cmd (commandline -opc)
if test (count $cmd) -eq 1
return 0
end
return 1
end
function __fish_beet_using_command
set cmd (commandline -opc)
set needle (count $cmd)
if test $needle -gt 1
if begin test $argv[1] = $cmd[2];
and not contains -- $cmd[$needle] $FIELDS; end
return 0
end
end
return 1
end
function __fish_beet_use_extra
set cmd (commandline -opc)
set needle (count $cmd)
if test $argv[2] = $cmd[$needle]
return 0
end
return 1
end
'''
class FishPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
cmd.func = self.run
cmd.parser.add_option('-f', '--noFields', action='store_true',
default=False,
help='omit album/track field completions')
cmd.parser.add_option(
'-e',
'--extravalues',
action='append',
type='choice',
choices=library.Item.all_keys() +
library.Album.all_keys(),
help='include specified field *values* in completions')
return [cmd]
def run(self, lib, opts, args):
# Gather the commands from Beets core and its plugins.
# Collect the album and track fields.
# If specified, also collect the values for these fields.
# Make a giant string of all the above, formatted in a way that
# allows Fish to do tab completion for the `beet` command.
home_dir = os.path.expanduser("~")
completion_dir = os.path.join(home_dir, '.config/fish/completions')
try:
os.makedirs(completion_dir)
except OSError:
if not os.path.isdir(completion_dir):
raise
completion_file_path = os.path.join(completion_dir, 'beet.fish')
nobasicfields = opts.noFields # Do not complete for album/track fields
extravalues = opts.extravalues # e.g., Also complete artists names
beetcmds = sorted(
(commands.default_commands +
commands.plugins.commands()),
key=attrgetter('name'))
fields = sorted(set(
library.Album.all_keys() + library.Item.all_keys()))
# Collect commands, their aliases, and their help text
cmd_names_help = []
for cmd in beetcmds:
names = list(cmd.aliases)
names.append(cmd.name)
for name in names:
cmd_names_help.append((name, cmd.help))
# Concatenate the string
totstring = HEAD + "\n"
totstring += get_cmds_list([name[0] for name in cmd_names_help])
totstring += '' if nobasicfields else get_standard_fields(fields)
totstring += get_extravalues(lib, extravalues) if extravalues else ''
totstring += "\n" + "# ====== {} =====".format(
"setup basic beet completion") + "\n" * 2
totstring += get_basic_beet_options()
totstring += "\n" + "# ====== {} =====".format(
"setup field completion for subcommands") + "\n"
totstring += get_subcommands(
cmd_names_help, nobasicfields, extravalues)
# Set up completion for all the command options
totstring += get_all_commands(beetcmds)
with open(completion_file_path, 'w') as fish_file:
fish_file.write(totstring)
def _escape(name):
# Escape ? in fish
if name == "?":
name = "\\" + name
return name
def get_cmds_list(cmds_names):
# Make a list of all Beets core & plugin commands
substr = ''
substr += (
"set CMDS " + " ".join(cmds_names) + ("\n" * 2)
)
return substr
def get_standard_fields(fields):
# Make a list of album/track fields and append with ':'
fields = (field + ":" for field in fields)
substr = ''
substr += (
"set FIELDS " + " ".join(fields) + ("\n" * 2)
)
return substr
def get_extravalues(lib, extravalues):
# Make a list of all values from an album/track field.
# 'beet ls albumartist: <TAB>' yields completions for ABBA, Beatles, etc.
word = ''
values_set = get_set_of_values_for_field(lib, extravalues)
for fld in extravalues:
extraname = fld.upper() + 'S'
word += (
"set " + extraname + " " + " ".join(sorted(values_set[fld]))
+ ("\n" * 2)
)
return word
def get_set_of_values_for_field(lib, fields):
# Get unique values from a specified album/track field
fields_dict = {}
for each in fields:
fields_dict[each] = set()
for item in lib.items():
for field in fields:
fields_dict[field].add(wrap(item[field]))
return fields_dict
def get_basic_beet_options():
word = (
BL_NEED2.format("-l format-item",
"-f -d 'print with custom format'") +
BL_NEED2.format("-l format-album",
"-f -d 'print with custom format'") +
BL_NEED2.format("-s l -l library",
"-f -r -d 'library database file to use'") +
BL_NEED2.format("-s d -l directory",
"-f -r -d 'destination music directory'") +
BL_NEED2.format("-s v -l verbose",
"-f -d 'print debugging information'") +
BL_NEED2.format("-s c -l config",
"-f -r -d 'path to configuration file'") +
BL_NEED2.format("-s h -l help",
"-f -d 'print this help message and exit'"))
return word
def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
# Formatting for Fish to complete our fields/values
word = ""
for cmdname, cmdhelp in cmd_name_and_help:
cmdname = _escape(cmdname)
word += "\n" + "# ------ {} -------".format(
"fieldsetups for " + cmdname) + "\n"
word += (
BL_NEED2.format(
("-a " + cmdname),
("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
if nobasicfields is False:
word += (
BL_USE3.format(
cmdname,
("-a " + wrap("$FIELDS")),
("-f " + "-d " + wrap("fieldname"))))
if extravalues:
for f in extravalues:
setvar = wrap("$" + f.upper() + "S")
word += " ".join(BL_EXTRA3.format(
(cmdname + " " + f + ":"),
('-f ' + '-A ' + '-a ' + setvar),
('-d ' + wrap(f))).split()) + "\n"
return word
def get_all_commands(beetcmds):
# Formatting for Fish to complete command options
word = ""
for cmd in beetcmds:
names = list(cmd.aliases)
names.append(cmd.name)
for name in names:
name = _escape(name)
word += "\n"
word += ("\n" * 2) + "# ====== {} =====".format(
"completions for " + name) + "\n"
for option in cmd.parser._get_all_options()[1:]:
cmd_l = (" -l " + option._long_opts[0].replace('--', '')
)if option._long_opts else ''
cmd_s = (" -s " + option._short_opts[0].replace('-', '')
) if option._short_opts else ''
cmd_need_arg = ' -r ' if option.nargs in [1] else ''
cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
) if option.help else ''
cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
) if option.choices else ''
word += " ".join(BL_USE3.format(
name,
(cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
cmd_helpstr).split()) + "\n"
word = (word + " ".join(BL_USE3.format(
name,
("-s " + "h " + "-l " + "help" + " -f "),
('-d ' + wrap("print help") + "\n")
).split()))
return word
def clean_whitespace(word):
# Remove excess whitespace and tabs in a string
return " ".join(word.split())
def wrap(word):
# Need " or ' around strings but watch out if they're in the string
sptoken = '\"'
if ('"') in word and ("'") in word:
word.replace('"', sptoken)
return '"' + word + '"'
tok = '"' if "'" in word else "'"
return tok + word + tok

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Matt Lichtenberg.
#
@ -16,7 +15,6 @@
"""Creates freedesktop.org-compliant .directory files on an album level.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui
@ -26,12 +24,12 @@ class FreedesktopPlugin(BeetsPlugin):
def commands(self):
deprecated = ui.Subcommand(
"freedesktop",
help=u"Print a message to redirect to thumbnails --dolphin")
help="Print a message to redirect to thumbnails --dolphin")
deprecated.func = self.deprecation_message
return [deprecated]
def deprecation_message(self, lib, opts, args):
ui.print_(u"This plugin is deprecated. Its functionality is "
u"superseded by the 'thumbnails' plugin")
ui.print_(u"'thumbnails --dolphin' replaces freedesktop. See doc & "
u"changelog for more information")
ui.print_("This plugin is deprecated. Its functionality is "
"superseded by the 'thumbnails' plugin")
ui.print_("'thumbnails --dolphin' replaces freedesktop. See doc & "
"changelog for more information")

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Jan-Erik Dahlin
#
@ -16,13 +15,11 @@
"""If the title is empty, try to extract track and title from the
filename.
"""
from __future__ import division, absolute_import, print_function
from beets import plugins
from beets.util import displayable_path
import os
import re
import six
# Filename field extraction patterns.
@ -124,7 +121,7 @@ def apply_matches(d):
# Apply the title and track.
for item in d:
if bad_title(item.title):
item.title = six.text_type(d[item][title_field])
item.title = str(d[item][title_field])
if 'track' in d[item] and item.track == 0:
item.track = int(d[item]['track'])
@ -133,7 +130,7 @@ def apply_matches(d):
class FromFilenamePlugin(plugins.BeetsPlugin):
def __init__(self):
super(FromFilenamePlugin, self).__init__()
super().__init__()
self.register_listener('import_task_start', filename_task)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Verrus, <github.com/Verrus/beets-plugin-featInTitle>
#
@ -15,7 +14,6 @@
"""Moves "featured" artists to the title from the artist field.
"""
from __future__ import division, absolute_import, print_function
import re
@ -75,22 +73,22 @@ def find_feat_part(artist, albumartist):
class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self):
super(FtInTitlePlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
'drop': False,
'format': u'feat. {0}',
'format': 'feat. {0}',
})
self._command = ui.Subcommand(
'ftintitle',
help=u'move featured artists to the title field')
help='move featured artists to the title field')
self._command.parser.add_option(
u'-d', u'--drop', dest='drop',
'-d', '--drop', dest='drop',
action='store_true', default=None,
help=u'drop featuring from artists and ignore title update')
help='drop featuring from artists and ignore title update')
if self.config['auto']:
self.import_stages = [self.imported]
@ -127,7 +125,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
remove it from the artist field.
"""
# In all cases, update the artist fields.
self._log.info(u'artist: {0} -> {1}', item.artist, item.albumartist)
self._log.info('artist: {0} -> {1}', item.artist, item.albumartist)
item.artist = item.albumartist
if item.artist_sort:
# Just strip the featured artist from the sort name.
@ -138,8 +136,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if not drop_feat and not contains_feat(item.title):
feat_format = self.config['format'].as_str()
new_format = feat_format.format(feat_part)
new_title = u"{0} {1}".format(item.title, new_format)
self._log.info(u'title: {0} -> {1}', item.title, new_title)
new_title = f"{item.title} {new_format}"
self._log.info('title: {0} -> {1}', item.title, new_title)
item.title = new_title
def ft_in_title(self, item, drop_feat):
@ -165,4 +163,4 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if feat_part:
self.update_metadata(item, feat_part, drop_feat)
else:
self._log.info(u'no featuring artists found')
self._log.info('no featuring artists found')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Philippe Mongeau.
#
@ -16,7 +15,6 @@
"""Provides a fuzzy matching query.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.dbcore.query import StringFieldQuery
@ -37,7 +35,7 @@ class FuzzyQuery(StringFieldQuery):
class FuzzyPlugin(BeetsPlugin):
def __init__(self):
super(FuzzyPlugin, self).__init__()
super().__init__()
self.config.add({
'prefix': '~',
'threshold': 0.7,

View file

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Tigran Kostandyan.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@ -13,84 +11,15 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Upload files to Google Play Music and list songs in its library."""
from __future__ import absolute_import, division, print_function
import os.path
"""Deprecation warning for the removed gmusic plugin."""
from beets.plugins import BeetsPlugin
from beets import ui
from beets import config
from beets.ui import Subcommand
from gmusicapi import Musicmanager, Mobileclient
from gmusicapi.exceptions import NotLoggedIn
import gmusicapi.clients
class Gmusic(BeetsPlugin):
def __init__(self):
super(Gmusic, self).__init__()
# Checks for OAuth2 credentials,
# if they don't exist - performs authorization
self.m = Musicmanager()
if os.path.isfile(gmusicapi.clients.OAUTH_FILEPATH):
self.m.login()
else:
self.m.perform_oauth()
super().__init__()
def commands(self):
gupload = Subcommand('gmusic-upload',
help=u'upload your tracks to Google Play Music')
gupload.func = self.upload
search = Subcommand('gmusic-songs',
help=u'list of songs in Google Play Music library'
)
search.parser.add_option('-t', '--track', dest='track',
action='store_true',
help='Search by track name')
search.parser.add_option('-a', '--artist', dest='artist',
action='store_true',
help='Search by artist')
search.func = self.search
return [gupload, search]
def upload(self, lib, opts, args):
items = lib.items(ui.decargs(args))
files = [x.path.decode('utf-8') for x in items]
ui.print_(u'Uploading your files...')
self.m.upload(filepaths=files)
ui.print_(u'Your files were successfully added to library')
def search(self, lib, opts, args):
password = config['gmusic']['password']
email = config['gmusic']['email']
password.redact = True
email.redact = True
# Since Musicmanager doesn't support library management
# we need to use mobileclient interface
mobile = Mobileclient()
try:
mobile.login(email.as_str(), password.as_str(),
Mobileclient.FROM_MAC_ADDRESS)
files = mobile.get_all_songs()
except NotLoggedIn:
ui.print_(
u'Authentication error. Please check your email and password.'
)
return
if not args:
for i, file in enumerate(files, start=1):
print(i, ui.colorize('blue', file['artist']),
file['title'], ui.colorize('red', file['album']))
else:
if opts.track:
self.match(files, args, 'title')
else:
self.match(files, args, 'artist')
@staticmethod
def match(files, args, search_by):
for file in files:
if ' '.join(ui.decargs(args)) in file[search_by]:
print(file['artist'], file['title'], file['album'])
self._log.warning("The 'gmusic' plugin has been removed following the"
" shutdown of Google Play Music. Remove the plugin"
" from your configuration to silence this warning.")

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2015, Adrian Sampson.
#
@ -14,14 +13,13 @@
# included in all copies or substantial portions of the Software.
"""Allows custom commands to be run when an event is emitted by beets"""
from __future__ import division, absolute_import, print_function
import string
import subprocess
import six
import shlex
from beets.plugins import BeetsPlugin
from beets.util import shlex_split, arg_encoding
from beets.util import arg_encoding
class CodingFormatter(string.Formatter):
@ -46,13 +44,11 @@ class CodingFormatter(string.Formatter):
See str.format and string.Formatter.format.
"""
try:
if isinstance(format_string, bytes):
format_string = format_string.decode(self._coding)
except UnicodeEncodeError:
pass
return super(CodingFormatter, self).format(format_string, *args,
**kwargs)
return super().format(format_string, *args,
**kwargs)
def convert_field(self, value, conversion):
"""Converts the provided value given a conversion type.
@ -61,8 +57,8 @@ class CodingFormatter(string.Formatter):
See string.Formatter.convert_field.
"""
converted = super(CodingFormatter, self).convert_field(value,
conversion)
converted = super().convert_field(value,
conversion)
if isinstance(converted, bytes):
return converted.decode(self._coding)
@ -72,8 +68,9 @@ class CodingFormatter(string.Formatter):
class HookPlugin(BeetsPlugin):
"""Allows custom commands to be run when an event is emitted by beets"""
def __init__(self):
super(HookPlugin, self).__init__()
super().__init__()
self.config.add({
'hooks': []
@ -91,28 +88,28 @@ class HookPlugin(BeetsPlugin):
def create_and_register_hook(self, event, command):
def hook_function(**kwargs):
if command is None or len(command) == 0:
self._log.error('invalid command "{0}"', command)
return
if command is None or len(command) == 0:
self._log.error('invalid command "{0}"', command)
return
# Use a string formatter that works on Unicode strings.
if six.PY2:
formatter = CodingFormatter(arg_encoding())
else:
formatter = string.Formatter()
# Use a string formatter that works on Unicode strings.
formatter = CodingFormatter(arg_encoding())
command_pieces = shlex_split(command)
command_pieces = shlex.split(command)
for i, piece in enumerate(command_pieces):
command_pieces[i] = formatter.format(piece, event=event,
**kwargs)
for i, piece in enumerate(command_pieces):
command_pieces[i] = formatter.format(piece, event=event,
**kwargs)
self._log.debug(u'running command "{0}" for event {1}',
u' '.join(command_pieces), event)
self._log.debug('running command "{0}" for event {1}',
' '.join(command_pieces), event)
try:
subprocess.Popen(command_pieces).wait()
except OSError as exc:
self._log.error(u'hook for {0} failed: {1}', event, exc)
try:
subprocess.check_call(command_pieces)
except subprocess.CalledProcessError as exc:
self._log.error('hook for {0} exited with status {1}',
event, exc.returncode)
except OSError as exc:
self._log.error('hook for {0} failed: {1}', event, exc)
self.register_listener(event, hook_function)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
"""Warns you about things you hate (or even blocks import)."""
@ -33,14 +31,14 @@ def summary(task):
object.
"""
if task.is_album:
return u'{0} - {1}'.format(task.cur_artist, task.cur_album)
return f'{task.cur_artist} - {task.cur_album}'
else:
return u'{0} - {1}'.format(task.item.artist, task.item.title)
return f'{task.item.artist} - {task.item.title}'
class IHatePlugin(BeetsPlugin):
def __init__(self):
super(IHatePlugin, self).__init__()
super().__init__()
self.register_listener('import_task_choice',
self.import_task_choice_event)
self.config.add({
@ -69,14 +67,14 @@ class IHatePlugin(BeetsPlugin):
if task.choice_flag == action.APPLY:
if skip_queries or warn_queries:
self._log.debug(u'processing your hate')
self._log.debug('processing your hate')
if self.do_i_hate_this(task, skip_queries):
task.choice_flag = action.SKIP
self._log.info(u'skipped: {0}', summary(task))
self._log.info('skipped: {0}', summary(task))
return
if self.do_i_hate_this(task, warn_queries):
self._log.info(u'you may hate this: {0}', summary(task))
self._log.info('you may hate this: {0}', summary(task))
else:
self._log.debug(u'nothing to do')
self._log.debug('nothing to do')
else:
self._log.debug(u'user made a decision, nothing to do')
self._log.debug('user made a decision, nothing to do')

View file

@ -1,11 +1,8 @@
# -*- coding: utf-8 -*-
"""Populate an item's `added` and `mtime` fields by using the file
modification time (mtime) of the item's source file before import.
Reimported albums and items are skipped.
"""
from __future__ import division, absolute_import, print_function
import os
@ -16,7 +13,7 @@ from beets.plugins import BeetsPlugin
class ImportAddedPlugin(BeetsPlugin):
def __init__(self):
super(ImportAddedPlugin, self).__init__()
super().__init__()
self.config.add({
'preserve_mtimes': False,
'preserve_write_mtimes': False,
@ -27,7 +24,7 @@ class ImportAddedPlugin(BeetsPlugin):
# album.path for old albums that were replaced by a reimported album
self.replaced_album_paths = None
# item path in the library to the mtime of the source file
self.item_mtime = dict()
self.item_mtime = {}
register = self.register_listener
register('import_task_created', self.check_config)
@ -53,8 +50,8 @@ class ImportAddedPlugin(BeetsPlugin):
def record_if_inplace(self, task, session):
if not (session.config['copy'] or session.config['move'] or
session.config['link'] or session.config['hardlink']):
self._log.debug(u"In place import detected, recording mtimes from "
u"source paths")
self._log.debug("In place import detected, recording mtimes from "
"source paths")
items = [task.item] \
if isinstance(task, importer.SingletonImportTask) \
else task.items
@ -62,9 +59,9 @@ class ImportAddedPlugin(BeetsPlugin):
self.record_import_mtime(item, item.path, item.path)
def record_reimported(self, task, session):
self.reimported_item_ids = set(item.id for item, replaced_items
in task.replaced_items.items()
if replaced_items)
self.reimported_item_ids = {item.id for item, replaced_items
in task.replaced_items.items()
if replaced_items}
self.replaced_album_paths = set(task.replaced_albums.keys())
def write_file_mtime(self, path, mtime):
@ -86,14 +83,14 @@ class ImportAddedPlugin(BeetsPlugin):
"""
mtime = os.stat(util.syspath(source)).st_mtime
self.item_mtime[destination] = mtime
self._log.debug(u"Recorded mtime {0} for item '{1}' imported from "
u"'{2}'", mtime, util.displayable_path(destination),
self._log.debug("Recorded mtime {0} for item '{1}' imported from "
"'{2}'", mtime, util.displayable_path(destination),
util.displayable_path(source))
def update_album_times(self, lib, album):
if self.reimported_album(album):
self._log.debug(u"Album '{0}' is reimported, skipping import of "
u"added dates for the album and its items.",
self._log.debug("Album '{0}' is reimported, skipping import of "
"added dates for the album and its items.",
util.displayable_path(album.path))
return
@ -106,30 +103,30 @@ class ImportAddedPlugin(BeetsPlugin):
self.write_item_mtime(item, mtime)
item.store()
album.added = min(album_mtimes)
self._log.debug(u"Import of album '{0}', selected album.added={1} "
u"from item file mtimes.", album.album, album.added)
self._log.debug("Import of album '{0}', selected album.added={1} "
"from item file mtimes.", album.album, album.added)
album.store()
def update_item_times(self, lib, item):
if self.reimported_item(item):
self._log.debug(u"Item '{0}' is reimported, skipping import of "
u"added date.", util.displayable_path(item.path))
self._log.debug("Item '{0}' is reimported, skipping import of "
"added date.", util.displayable_path(item.path))
return
mtime = self.item_mtime.pop(item.path, None)
if mtime:
item.added = mtime
if self.config['preserve_mtimes'].get(bool):
self.write_item_mtime(item, mtime)
self._log.debug(u"Import of item '{0}', selected item.added={1}",
self._log.debug("Import of item '{0}', selected item.added={1}",
util.displayable_path(item.path), item.added)
item.store()
def update_after_write_time(self, item):
def update_after_write_time(self, item, path):
"""Update the mtime of the item's file with the item.added value
after each write of the item if `preserve_write_mtimes` is enabled.
"""
if item.added:
if self.config['preserve_write_mtimes'].get(bool):
self.write_item_mtime(item, item.added)
self._log.debug(u"Write of item '{0}', selected item.added={1}",
self._log.debug("Write of item '{0}', selected item.added={1}",
util.displayable_path(item.path), item.added)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Fabrice Laporte.
#
@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
"""Write paths of imported files in various formats to ease later import in a
music player. Also allow printing the new file locations to stdout in case
@ -54,11 +52,11 @@ def _write_m3u(m3u_path, items_paths):
class ImportFeedsPlugin(BeetsPlugin):
def __init__(self):
super(ImportFeedsPlugin, self).__init__()
super().__init__()
self.config.add({
'formats': [],
'm3u_name': u'imported.m3u',
'm3u_name': 'imported.m3u',
'dir': None,
'relative_to': None,
'absolute_path': False,
@ -118,9 +116,9 @@ class ImportFeedsPlugin(BeetsPlugin):
link(path, dest)
if 'echo' in formats:
self._log.info(u"Location of imported music:")
self._log.info("Location of imported music:")
for path in paths:
self._log.info(u" {0}", path)
self._log.info(" {0}", path)
def album_imported(self, lib, album):
self._record_items(lib, album.album, album.items())

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -16,19 +15,17 @@
"""Shows file metadata.
"""
from __future__ import division, absolute_import, print_function
import os
import re
from beets.plugins import BeetsPlugin
from beets import ui
from beets import mediafile
import mediafile
from beets.library import Item
from beets.util import displayable_path, normpath, syspath
def tag_data(lib, args):
def tag_data(lib, args, album=False):
query = []
for arg in args:
path = normpath(arg)
@ -42,15 +39,29 @@ def tag_data(lib, args):
yield tag_data_emitter(item.path)
def tag_fields():
fields = set(mediafile.MediaFile.readable_fields())
fields.add('art')
return fields
def tag_data_emitter(path):
def emitter():
fields = list(mediafile.MediaFile.readable_fields())
fields.remove('images')
def emitter(included_keys):
if included_keys == '*':
fields = tag_fields()
else:
fields = included_keys
if 'images' in fields:
# We can't serialize the image data.
fields.remove('images')
mf = mediafile.MediaFile(syspath(path))
tags = {}
for field in fields:
tags[field] = getattr(mf, field)
tags['art'] = mf.art is not None
if field == 'art':
tags[field] = mf.art is not None
else:
tags[field] = getattr(mf, field, None)
# create a temporary Item to take advantage of __format__
item = Item.from_path(syspath(path))
@ -58,15 +69,14 @@ def tag_data_emitter(path):
return emitter
def library_data(lib, args):
for item in lib.items(args):
def library_data(lib, args, album=False):
for item in lib.albums(args) if album else lib.items(args):
yield library_data_emitter(item)
def library_data_emitter(item):
def emitter():
data = dict(item.formatted())
data.pop('path', None) # path is fetched from item
def emitter(included_keys):
data = dict(item.formatted(included_keys=included_keys))
return data, item
return emitter
@ -98,7 +108,7 @@ def print_data(data, item=None, fmt=None):
formatted = {}
for key, value in data.items():
if isinstance(value, list):
formatted[key] = u'; '.join(value)
formatted[key] = '; '.join(value)
if value is not None:
formatted[key] = value
@ -106,7 +116,7 @@ def print_data(data, item=None, fmt=None):
return
maxwidth = max(len(key) for key in formatted)
lineformat = u'{{0:>{0}}}: {{1}}'.format(maxwidth)
lineformat = f'{{0:>{maxwidth}}}: {{1}}'
if path:
ui.print_(displayable_path(path))
@ -114,7 +124,7 @@ def print_data(data, item=None, fmt=None):
for field in sorted(formatted):
value = formatted[field]
if isinstance(value, list):
value = u'; '.join(value)
value = '; '.join(value)
ui.print_(lineformat.format(field, value))
@ -129,7 +139,7 @@ def print_data_keys(data, item=None):
if len(formatted) == 0:
return
line_format = u'{0}{{0}}'.format(u' ' * 4)
line_format = '{0}{{0}}'.format(' ' * 4)
if path:
ui.print_(displayable_path(path))
@ -140,24 +150,28 @@ def print_data_keys(data, item=None):
class InfoPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('info', help=u'show file metadata')
cmd = ui.Subcommand('info', help='show file metadata')
cmd.func = self.run
cmd.parser.add_option(
u'-l', u'--library', action='store_true',
help=u'show library fields instead of tags',
'-l', '--library', action='store_true',
help='show library fields instead of tags',
)
cmd.parser.add_option(
u'-s', u'--summarize', action='store_true',
help=u'summarize the tags of all files',
'-a', '--album', action='store_true',
help='show album fields instead of tracks (implies "--library")',
)
cmd.parser.add_option(
u'-i', u'--include-keys', default=[],
'-s', '--summarize', action='store_true',
help='summarize the tags of all files',
)
cmd.parser.add_option(
'-i', '--include-keys', default=[],
action='append', dest='included_keys',
help=u'comma separated list of keys to show',
help='comma separated list of keys to show',
)
cmd.parser.add_option(
u'-k', u'--keys-only', action='store_true',
help=u'show only the keys',
'-k', '--keys-only', action='store_true',
help='show only the keys',
)
cmd.parser.add_format_option(target='item')
return [cmd]
@ -176,7 +190,7 @@ class InfoPlugin(BeetsPlugin):
dictionary and only prints that. If two files have different values
for the same tag, the value is set to '[various]'
"""
if opts.library:
if opts.library or opts.album:
data_collector = library_data
else:
data_collector = tag_data
@ -184,18 +198,21 @@ class InfoPlugin(BeetsPlugin):
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
key_filter = make_key_filter(included_keys)
# Drop path even if user provides it multiple times
included_keys = [k for k in included_keys if k != 'path']
first = True
summary = {}
for data_emitter in data_collector(lib, ui.decargs(args)):
for data_emitter in data_collector(
lib, ui.decargs(args),
album=opts.album,
):
try:
data, item = data_emitter()
except (mediafile.UnreadableFileError, IOError) as ex:
self._log.error(u'cannot read file: {0}', ex)
data, item = data_emitter(included_keys or '*')
except (mediafile.UnreadableFileError, OSError) as ex:
self._log.error('cannot read file: {0}', ex)
continue
data = key_filter(data)
if opts.summarize:
update_summary(summary, data)
else:
@ -210,33 +227,3 @@ class InfoPlugin(BeetsPlugin):
if opts.summarize:
print_data(summary)
def make_key_filter(include):
"""Return a function that filters a dictionary.
The returned filter takes a dictionary and returns another
dictionary that only includes the key-value pairs where the key
glob-matches one of the keys in `include`.
"""
if not include:
return identity
matchers = []
for key in include:
key = re.escape(key)
key = key.replace(r'\*', '.*')
matchers.append(re.compile(key + '$'))
def filter_(data):
filtered = dict()
for key, value in data.items():
if any([m.match(key) for m in matchers]):
filtered[key] = value
return filtered
return filter_
def identity(val):
return val

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -15,25 +14,23 @@
"""Allows inline path template customization code in the config file.
"""
from __future__ import division, absolute_import, print_function
import traceback
import itertools
from beets.plugins import BeetsPlugin
from beets import config
import six
FUNC_NAME = u'__INLINE_FUNC__'
FUNC_NAME = '__INLINE_FUNC__'
class InlineError(Exception):
"""Raised when a runtime error occurs in an inline expression.
"""
def __init__(self, code, exc):
super(InlineError, self).__init__(
(u"error in inline path field code:\n"
u"%s\n%s: %s") % (code, type(exc).__name__, six.text_type(exc))
super().__init__(
("error in inline path field code:\n"
"%s\n%s: %s") % (code, type(exc).__name__, str(exc))
)
@ -41,7 +38,7 @@ def _compile_func(body):
"""Given Python code for a function body, return a compiled
callable that invokes that code.
"""
body = u'def {0}():\n {1}'.format(
body = 'def {}():\n {}'.format(
FUNC_NAME,
body.replace('\n', '\n ')
)
@ -53,7 +50,7 @@ def _compile_func(body):
class InlinePlugin(BeetsPlugin):
def __init__(self):
super(InlinePlugin, self).__init__()
super().__init__()
config.add({
'pathfields': {}, # Legacy name.
@ -64,14 +61,14 @@ class InlinePlugin(BeetsPlugin):
# Item fields.
for key, view in itertools.chain(config['item_fields'].items(),
config['pathfields'].items()):
self._log.debug(u'adding item field {0}', key)
self._log.debug('adding item field {0}', key)
func = self.compile_inline(view.as_str(), False)
if func is not None:
self.template_fields[key] = func
# Album fields.
for key, view in config['album_fields'].items():
self._log.debug(u'adding album field {0}', key)
self._log.debug('adding album field {0}', key)
func = self.compile_inline(view.as_str(), True)
if func is not None:
self.album_template_fields[key] = func
@ -84,14 +81,14 @@ class InlinePlugin(BeetsPlugin):
"""
# First, try compiling as a single function.
try:
code = compile(u'({0})'.format(python_code), 'inline', 'eval')
code = compile(f'({python_code})', 'inline', 'eval')
except SyntaxError:
# Fall back to a function body.
try:
func = _compile_func(python_code)
except SyntaxError:
self._log.error(u'syntax error in inline field definition:\n'
u'{0}', traceback.format_exc())
self._log.error('syntax error in inline field definition:\n'
'{0}', traceback.format_exc())
return
else:
is_expr = False
@ -117,9 +114,13 @@ class InlinePlugin(BeetsPlugin):
# For function bodies, invoke the function with values as global
# variables.
def _func_func(obj):
old_globals = dict(func.__globals__)
func.__globals__.update(_dict_for(obj))
try:
return func()
except Exception as exc:
raise InlineError(python_code, exc)
finally:
func.__globals__.clear()
func.__globals__.update(old_globals)
return _func_func

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
#
# Permission is hereby granted, free of charge, to any person obtaining
@ -15,7 +14,6 @@
"""Adds support for ipfs. Requires go-ipfs and a running ipfs daemon
"""
from __future__ import division, absolute_import, print_function
from beets import ui, util, library, config
from beets.plugins import BeetsPlugin
@ -29,9 +27,10 @@ import tempfile
class IPFSPlugin(BeetsPlugin):
def __init__(self):
super(IPFSPlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
'nocopy': False,
})
if self.config['auto']:
@ -116,12 +115,15 @@ class IPFSPlugin(BeetsPlugin):
self._log.info('Adding {0} to ipfs', album_dir)
cmd = "ipfs add -q -r".split()
if self.config['nocopy']:
cmd = "ipfs add --nocopy -q -r".split()
else:
cmd = "ipfs add -q -r".split()
cmd.append(album_dir)
try:
output = util.command_output(cmd).split()
output = util.command_output(cmd).stdout.split()
except (OSError, subprocess.CalledProcessError) as exc:
self._log.error(u'Failed to add {0}, error: {1}', album_dir, exc)
self._log.error('Failed to add {0}, error: {1}', album_dir, exc)
return False
length = len(output)
@ -147,6 +149,8 @@ class IPFSPlugin(BeetsPlugin):
def ipfs_get(self, lib, query):
query = query[0]
# Check if query is a hash
# TODO: generalize to other hashes; probably use a multihash
# implementation
if query.startswith("Qm") and len(query) == 46:
self.ipfs_get_from_hash(lib, query)
else:
@ -174,11 +178,14 @@ class IPFSPlugin(BeetsPlugin):
with tempfile.NamedTemporaryFile() as tmp:
self.ipfs_added_albums(lib, tmp.name)
try:
cmd = "ipfs add -q ".split()
if self.config['nocopy']:
cmd = "ipfs add --nocopy -q ".split()
else:
cmd = "ipfs add -q ".split()
cmd.append(tmp.name)
output = util.command_output(cmd)
output = util.command_output(cmd).stdout
except (OSError, subprocess.CalledProcessError) as err:
msg = "Failed to publish library. Error: {0}".format(err)
msg = f"Failed to publish library. Error: {err}"
self._log.error(msg)
return False
self._log.info("hash of library: {0}", output)
@ -190,26 +197,26 @@ class IPFSPlugin(BeetsPlugin):
else:
lib_name = _hash
lib_root = os.path.dirname(lib.path)
remote_libs = lib_root + "/remotes"
remote_libs = os.path.join(lib_root, b"remotes")
if not os.path.exists(remote_libs):
try:
os.makedirs(remote_libs)
except OSError as e:
msg = "Could not create {0}. Error: {1}".format(remote_libs, e)
msg = f"Could not create {remote_libs}. Error: {e}"
self._log.error(msg)
return False
path = remote_libs + "/" + lib_name + ".db"
path = os.path.join(remote_libs, lib_name.encode() + b".db")
if not os.path.exists(path):
cmd = "ipfs get {0} -o".format(_hash).split()
cmd = f"ipfs get {_hash} -o".split()
cmd.append(path)
try:
util.command_output(cmd)
except (OSError, subprocess.CalledProcessError):
self._log.error("Could not import {0}".format(_hash))
self._log.error(f"Could not import {_hash}")
return False
# add all albums from remotes into a combined library
jpath = remote_libs + "/joined.db"
jpath = os.path.join(remote_libs, b"joined.db")
jlib = library.Library(jpath)
nlib = library.Library(path)
for album in nlib.albums():
@ -232,12 +239,12 @@ class IPFSPlugin(BeetsPlugin):
fmt = config['format_album'].get()
try:
albums = self.query(lib, args)
except IOError:
except OSError:
ui.print_("No imported libraries yet.")
return
for album in albums:
ui.print_(format(album, fmt), " : ", album.ipfs)
ui.print_(format(album, fmt), " : ", album.ipfs.decode())
def query(self, lib, args):
rlib = self.get_remote_lib(lib)
@ -246,10 +253,10 @@ class IPFSPlugin(BeetsPlugin):
def get_remote_lib(self, lib):
lib_root = os.path.dirname(lib.path)
remote_libs = lib_root + "/remotes"
path = remote_libs + "/joined.db"
remote_libs = os.path.join(lib_root, b"remotes")
path = os.path.join(remote_libs, b"joined.db")
if not os.path.isfile(path):
raise IOError
raise OSError
return library.Library(path)
def ipfs_added_albums(self, rlib, tmpname):
@ -276,7 +283,7 @@ class IPFSPlugin(BeetsPlugin):
util._fsencoding(), 'ignore'
)
# Clear current path from item
item.path = '/ipfs/{0}/{1}'.format(album.ipfs, item_path)
item.path = f'/ipfs/{album.ipfs}/{item_path}'
item.id = None
items.append(item)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Thomas Scholtes.
#
@ -16,8 +15,8 @@
"""Uses the `KeyFinder` program to add the `initial_key` field.
"""
from __future__ import division, absolute_import, print_function
import os.path
import subprocess
from beets import ui
@ -28,11 +27,11 @@ from beets.plugins import BeetsPlugin
class KeyFinderPlugin(BeetsPlugin):
def __init__(self):
super(KeyFinderPlugin, self).__init__()
super().__init__()
self.config.add({
u'bin': u'KeyFinder',
u'auto': True,
u'overwrite': False,
'bin': 'KeyFinder',
'auto': True,
'overwrite': False,
})
if self.config['auto'].get(bool):
@ -40,7 +39,7 @@ class KeyFinderPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('keyfinder',
help=u'detect and add initial key from audio')
help='detect and add initial key from audio')
cmd.func = self.command
return [cmd]
@ -52,34 +51,45 @@ class KeyFinderPlugin(BeetsPlugin):
def find_key(self, items, write=False):
overwrite = self.config['overwrite'].get(bool)
bin = self.config['bin'].as_str()
command = [self.config['bin'].as_str()]
# The KeyFinder GUI program needs the -f flag before the path.
# keyfinder-cli is similar, but just wants the path with no flag.
if 'keyfinder-cli' not in os.path.basename(command[0]).lower():
command.append('-f')
for item in items:
if item['initial_key'] and not overwrite:
continue
try:
output = util.command_output([bin, '-f',
util.syspath(item.path)])
output = util.command_output(command + [util.syspath(
item.path)]).stdout
except (subprocess.CalledProcessError, OSError) as exc:
self._log.error(u'execution failed: {0}', exc)
self._log.error('execution failed: {0}', exc)
continue
except UnicodeEncodeError:
# Workaround for Python 2 Windows bug.
# http://bugs.python.org/issue1759845
self._log.error(u'execution failed for Unicode path: {0!r}',
# https://bugs.python.org/issue1759845
self._log.error('execution failed for Unicode path: {0!r}',
item.path)
continue
key_raw = output.rsplit(None, 1)[-1]
try:
key_raw = output.rsplit(None, 1)[-1]
except IndexError:
# Sometimes keyfinder-cli returns 0 but with no key, usually
# when the file is silent or corrupt, so we log and skip.
self._log.error('no key returned for path: {0}', item.path)
continue
try:
key = util.text_string(key_raw)
except UnicodeDecodeError:
self._log.error(u'output is invalid UTF-8')
self._log.error('output is invalid UTF-8')
continue
item['initial_key'] = key
self._log.info(u'added computed initial key {0} for {1}',
self._log.info('added computed initial key {0} for {1}',
key, util.displayable_path(item.path))
if write:

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Pauli Kettunen.
#
@ -23,18 +22,16 @@ Put something like the following in your config.yaml to configure:
user: user
pwd: secret
"""
from __future__ import division, absolute_import, print_function
import requests
from beets import config
from beets.plugins import BeetsPlugin
import six
def update_kodi(host, port, user, password):
"""Sends request to the Kodi api to start a library refresh.
"""
url = "http://{0}:{1}/jsonrpc".format(host, port)
url = f"http://{host}:{port}/jsonrpc"
"""Content-Type: application/json is mandatory
according to the kodi jsonrpc documentation"""
@ -54,14 +51,14 @@ def update_kodi(host, port, user, password):
class KodiUpdate(BeetsPlugin):
def __init__(self):
super(KodiUpdate, self).__init__()
super().__init__()
# Adding defaults.
config['kodi'].add({
u'host': u'localhost',
u'port': 8080,
u'user': u'kodi',
u'pwd': u'kodi'})
'host': 'localhost',
'port': 8080,
'user': 'kodi',
'pwd': 'kodi'})
config['kodi']['pwd'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
@ -73,7 +70,7 @@ class KodiUpdate(BeetsPlugin):
def update(self, lib):
"""When the client exists try to send refresh request to Kodi server.
"""
self._log.info(u'Requesting a Kodi library update...')
self._log.info('Requesting a Kodi library update...')
# Try to send update request.
try:
@ -85,14 +82,14 @@ class KodiUpdate(BeetsPlugin):
r.raise_for_status()
except requests.exceptions.RequestException as e:
self._log.warning(u'Kodi update failed: {0}',
six.text_type(e))
self._log.warning('Kodi update failed: {0}',
str(e))
return
json = r.json()
if json.get('result') != 'OK':
self._log.warning(u'Kodi update failed: JSON response was {0!r}',
self._log.warning('Kodi update failed: JSON response was {0!r}',
json)
return
self._log.info(u'Kodi update triggered')
self._log.info('Kodi update triggered')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -13,8 +12,6 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
import six
"""Gets genres for imported music based on Last.fm tags.
@ -46,7 +43,7 @@ PYLAST_EXCEPTIONS = (
)
REPLACE = {
u'\u2010': '-',
'\u2010': '-',
}
@ -73,7 +70,7 @@ def flatten_tree(elem, path, branches):
for sub in elem:
flatten_tree(sub, path, branches)
else:
branches.append(path + [six.text_type(elem)])
branches.append(path + [str(elem)])
def find_parents(candidate, branches):
@ -97,7 +94,7 @@ C14N_TREE = os.path.join(os.path.dirname(__file__), 'genres-tree.yaml')
class LastGenrePlugin(plugins.BeetsPlugin):
def __init__(self):
super(LastGenrePlugin, self).__init__()
super().__init__()
self.config.add({
'whitelist': True,
@ -108,8 +105,9 @@ class LastGenrePlugin(plugins.BeetsPlugin):
'source': 'album',
'force': True,
'auto': True,
'separator': u', ',
'separator': ', ',
'prefer_specific': False,
'title_case': True,
})
self.setup()
@ -132,18 +130,27 @@ class LastGenrePlugin(plugins.BeetsPlugin):
with open(wl_filename, 'rb') as f:
for line in f:
line = line.decode('utf-8').strip().lower()
if line and not line.startswith(u'#'):
if line and not line.startswith('#'):
self.whitelist.add(line)
# Read the genres tree for canonicalization if enabled.
self.c14n_branches = []
c14n_filename = self.config['canonical'].get()
if c14n_filename in (True, ''): # Default tree.
self.canonicalize = c14n_filename is not False
# Default tree
if c14n_filename in (True, ''):
c14n_filename = C14N_TREE
elif not self.canonicalize and self.config['prefer_specific'].get():
# prefer_specific requires a tree, load default tree
c14n_filename = C14N_TREE
# Read the tree
if c14n_filename:
self._log.debug('Loading canonicalization tree {0}', c14n_filename)
c14n_filename = normpath(c14n_filename)
with codecs.open(c14n_filename, 'r', encoding='utf-8') as f:
genres_tree = yaml.load(f)
genres_tree = yaml.safe_load(f)
flatten_tree(genres_tree, [], self.c14n_branches)
@property
@ -186,7 +193,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return None
count = self.config['count'].get(int)
if self.c14n_branches:
if self.canonicalize:
# Extend the list to consider tags parents in the c14n tree
tags_all = []
for tag in tags:
@ -214,12 +221,17 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# c14n only adds allowed genres but we may have had forbidden genres in
# the original tags list
tags = [x.title() for x in tags if self._is_allowed(x)]
tags = [self._format_tag(x) for x in tags if self._is_allowed(x)]
return self.config['separator'].as_str().join(
tags[:self.config['count'].get(int)]
)
def _format_tag(self, tag):
if self.config["title_case"]:
return tag.title()
return tag
def fetch_genre(self, lastfm_obj):
"""Return the genre for a pylast entity or None if no suitable genre
can be found. Ex. 'Electronic, House, Dance'
@ -251,8 +263,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if any(not s for s in args):
return None
key = u'{0}.{1}'.format(entity,
u'-'.join(six.text_type(a) for a in args))
key = '{}.{}'.format(entity,
'-'.join(str(a) for a in args))
if key in self._genre_cache:
return self._genre_cache[key]
else:
@ -270,28 +282,28 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""Return the album genre for this Item or Album.
"""
return self._last_lookup(
u'album', LASTFM.get_album, obj.albumartist, obj.album
'album', LASTFM.get_album, obj.albumartist, obj.album
)
def fetch_album_artist_genre(self, obj):
"""Return the album artist genre for this Item or Album.
"""
return self._last_lookup(
u'artist', LASTFM.get_artist, obj.albumartist
'artist', LASTFM.get_artist, obj.albumartist
)
def fetch_artist_genre(self, item):
"""Returns the track artist genre for this Item.
"""
return self._last_lookup(
u'artist', LASTFM.get_artist, item.artist
'artist', LASTFM.get_artist, item.artist
)
def fetch_track_genre(self, obj):
"""Returns the track genre for this Item.
"""
return self._last_lookup(
u'track', LASTFM.get_track, obj.artist, obj.title
'track', LASTFM.get_track, obj.artist, obj.title
)
def _get_genre(self, obj):
@ -361,38 +373,56 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return None, None
def commands(self):
lastgenre_cmd = ui.Subcommand('lastgenre', help=u'fetch genres')
lastgenre_cmd = ui.Subcommand('lastgenre', help='fetch genres')
lastgenre_cmd.parser.add_option(
u'-f', u'--force', dest='force',
action='store_true', default=False,
help=u're-download genre when already present'
'-f', '--force', dest='force',
action='store_true',
help='re-download genre when already present'
)
lastgenre_cmd.parser.add_option(
u'-s', u'--source', dest='source', type='string',
help=u'genre source: artist, album, or track'
'-s', '--source', dest='source', type='string',
help='genre source: artist, album, or track'
)
lastgenre_cmd.parser.add_option(
'-A', '--items', action='store_false', dest='album',
help='match items instead of albums')
lastgenre_cmd.parser.add_option(
'-a', '--albums', action='store_true', dest='album',
help='match albums instead of items')
lastgenre_cmd.parser.set_defaults(album=True)
def lastgenre_func(lib, opts, args):
write = ui.should_write()
self.config.set_args(opts)
for album in lib.albums(ui.decargs(args)):
album.genre, src = self._get_genre(album)
self._log.info(u'genre for album {0} ({1}): {0.genre}',
album, src)
album.store()
if opts.album:
# Fetch genres for whole albums
for album in lib.albums(ui.decargs(args)):
album.genre, src = self._get_genre(album)
self._log.info('genre for album {0} ({1}): {0.genre}',
album, src)
album.store()
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if 'track' in self.sources:
item.genre, src = self._get_genre(item)
item.store()
self._log.info(u'genre for track {0} ({1}): {0.genre}',
item, src)
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if 'track' in self.sources:
item.genre, src = self._get_genre(item)
item.store()
self._log.info(
'genre for track {0} ({1}): {0.genre}',
item, src)
if write:
item.try_write()
if write:
item.try_write()
else:
# Just query singletons, i.e. items that are not part of
# an album
for item in lib.items(ui.decargs(args)):
item.genre, src = self._get_genre(item)
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
item.store()
lastgenre_cmd.func = lastgenre_func
return [lastgenre_cmd]
@ -402,21 +432,21 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if task.is_album:
album = task.album
album.genre, src = self._get_genre(album)
self._log.debug(u'added last.fm album genre ({0}): {1}',
self._log.debug('added last.fm album genre ({0}): {1}',
src, album.genre)
album.store()
if 'track' in self.sources:
for item in album.items():
item.genre, src = self._get_genre(item)
self._log.debug(u'added last.fm item genre ({0}): {1}',
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
item.store()
else:
item = task.item
item.genre, src = self._get_genre(item)
self._log.debug(u'added last.fm item genre ({0}): {1}',
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
item.store()
@ -438,12 +468,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
try:
res = obj.get_top_tags()
except PYLAST_EXCEPTIONS as exc:
self._log.debug(u'last.fm error: {0}', exc)
self._log.debug('last.fm error: {0}', exc)
return []
except Exception as exc:
# Isolate bugs in pylast.
self._log.debug(u'{}', traceback.format_exc())
self._log.error(u'error in pylast library: {0}', exc)
self._log.debug('{}', traceback.format_exc())
self._log.error('error in pylast library: {0}', exc)
return []
# Filter by weight (optionally).

View file

@ -648,35 +648,51 @@
- glam rock
- hard rock
- heavy metal:
- alternative metal
- alternative metal:
- funk metal
- black metal:
- viking metal
- christian metal
- death metal:
- death/doom
- goregrind
- melodic death metal
- technical death metal
- doom metal
- doom metal:
- epic doom metal
- funeral doom
- drone metal
- epic metal
- folk metal:
- celtic metal
- medieval metal
- pagan metal
- funk metal
- glam metal
- gothic metal
- industrial metal:
- industrial death metal
- metalcore:
- deathcore
- mathcore:
- djent
- power metal
- synthcore
- neoclassical metal
- post-metal
- power metal:
- progressive power metal
- progressive metal
- sludge metal
- speed metal
- stoner rock
- stoner rock:
- stoner metal
- symphonic metal
- thrash metal:
- crossover thrash
- groove metal
- progressive thrash metal
- teutonic thrash metal
- traditional heavy metal
- math rock
- new wave:
- world fusion
@ -719,6 +735,7 @@
- street punk
- thrashcore
- horror punk
- oi!
- pop punk
- psychobilly
- riot grrrl

View file

@ -450,6 +450,8 @@ emo rap
emocore
emotronic
enka
epic doom metal
epic metal
eremwu eu
ethereal pop
ethereal wave
@ -1024,6 +1026,7 @@ neo-medieval
neo-prog
neo-psychedelia
neoclassical
neoclassical metal
neoclassical music
neofolk
neotraditional country
@ -1176,8 +1179,10 @@ progressive folk
progressive folk music
progressive house
progressive metal
progressive power metal
progressive rock
progressive trance
progressive thrash metal
protopunk
psych folk
psychedelic music
@ -1396,6 +1401,7 @@ symphonic metal
symphonic poem
symphonic rock
symphony
synthcore
synthpop
synthpunk
t'ong guitar
@ -1428,6 +1434,7 @@ tejano
tejano music
tekno
tembang sunda
teutonic thrash metal
texas blues
thai pop
thillana
@ -1444,6 +1451,7 @@ toeshey
togaku
trad jazz
traditional bluegrass
traditional heavy metal
traditional pop music
trallalero
trance

View file

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Rafael Bodill http://github.com/rafi
# Copyright 2016, Rafael Bodill https://github.com/rafi
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
import pylast
from pylast import TopItem, _extract, _number
@ -28,7 +26,7 @@ API_URL = 'https://ws.audioscrobbler.com/2.0/'
class LastImportPlugin(plugins.BeetsPlugin):
def __init__(self):
super(LastImportPlugin, self).__init__()
super().__init__()
config['lastfm'].add({
'user': '',
'api_key': plugins.LASTFM_KEY,
@ -43,7 +41,7 @@ class LastImportPlugin(plugins.BeetsPlugin):
}
def commands(self):
cmd = ui.Subcommand('lastimport', help=u'import last.fm play-count')
cmd = ui.Subcommand('lastimport', help='import last.fm play-count')
def func(lib, opts, args):
import_lastfm(lib, self._log)
@ -59,7 +57,7 @@ class CustomUser(pylast.User):
tracks.
"""
def __init__(self, *args, **kwargs):
super(CustomUser, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def _get_things(self, method, thing, thing_type, params=None,
cacheable=True):
@ -114,9 +112,9 @@ def import_lastfm(lib, log):
per_page = config['lastimport']['per_page'].get(int)
if not user:
raise ui.UserError(u'You must specify a user name for lastimport')
raise ui.UserError('You must specify a user name for lastimport')
log.info(u'Fetching last.fm library for @{0}', user)
log.info('Fetching last.fm library for @{0}', user)
page_total = 1
page_current = 0
@ -125,15 +123,15 @@ def import_lastfm(lib, log):
retry_limit = config['lastimport']['retry_limit'].get(int)
# Iterate through a yet to be known page total count
while page_current < page_total:
log.info(u'Querying page #{0}{1}...',
log.info('Querying page #{0}{1}...',
page_current + 1,
'/{}'.format(page_total) if page_total > 1 else '')
f'/{page_total}' if page_total > 1 else '')
for retry in range(0, retry_limit):
tracks, page_total = fetch_tracks(user, page_current + 1, per_page)
if page_total < 1:
# It means nothing to us!
raise ui.UserError(u'Last.fm reported no data.')
raise ui.UserError('Last.fm reported no data.')
if tracks:
found, unknown = process_tracks(lib, tracks, log)
@ -141,22 +139,22 @@ def import_lastfm(lib, log):
unknown_total += unknown
break
else:
log.error(u'ERROR: unable to read page #{0}',
log.error('ERROR: unable to read page #{0}',
page_current + 1)
if retry < retry_limit:
log.info(
u'Retrying page #{0}... ({1}/{2} retry)',
'Retrying page #{0}... ({1}/{2} retry)',
page_current + 1, retry + 1, retry_limit
)
else:
log.error(u'FAIL: unable to fetch page #{0}, ',
u'tried {1} times', page_current, retry + 1)
log.error('FAIL: unable to fetch page #{0}, ',
'tried {1} times', page_current, retry + 1)
page_current += 1
log.info(u'... done!')
log.info(u'finished processing {0} song pages', page_total)
log.info(u'{0} unknown play-counts', unknown_total)
log.info(u'{0} play-counts imported', found_total)
log.info('... done!')
log.info('finished processing {0} song pages', page_total)
log.info('{0} unknown play-counts', unknown_total)
log.info('{0} play-counts imported', found_total)
def fetch_tracks(user, page, limit):
@ -190,7 +188,7 @@ def process_tracks(lib, tracks, log):
total = len(tracks)
total_found = 0
total_fails = 0
log.info(u'Received {0} tracks in this page, processing...', total)
log.info('Received {0} tracks in this page, processing...', total)
for num in range(0, total):
song = None
@ -201,7 +199,7 @@ def process_tracks(lib, tracks, log):
if 'album' in tracks[num]:
album = tracks[num]['album'].get('name', '').strip()
log.debug(u'query: {0} - {1} ({2})', artist, title, album)
log.debug('query: {0} - {1} ({2})', artist, title, album)
# First try to query by musicbrainz's trackid
if trackid:
@ -211,7 +209,7 @@ def process_tracks(lib, tracks, log):
# If not, try just artist/title
if song is None:
log.debug(u'no album match, trying by artist/title')
log.debug('no album match, trying by artist/title')
query = dbcore.AndQuery([
dbcore.query.SubstringQuery('artist', artist),
dbcore.query.SubstringQuery('title', title)
@ -220,8 +218,8 @@ def process_tracks(lib, tracks, log):
# Last resort, try just replacing to utf-8 quote
if song is None:
title = title.replace("'", u'\u2019')
log.debug(u'no title match, trying utf-8 single quote')
title = title.replace("'", '\u2019')
log.debug('no title match, trying utf-8 single quote')
query = dbcore.AndQuery([
dbcore.query.SubstringQuery('artist', artist),
dbcore.query.SubstringQuery('title', title)
@ -231,19 +229,19 @@ def process_tracks(lib, tracks, log):
if song is not None:
count = int(song.get('play_count', 0))
new_count = int(tracks[num]['playcount'])
log.debug(u'match: {0} - {1} ({2}) '
u'updating: play_count {3} => {4}',
log.debug('match: {0} - {1} ({2}) '
'updating: play_count {3} => {4}',
song.artist, song.title, song.album, count, new_count)
song['play_count'] = new_count
song.store()
total_found += 1
else:
total_fails += 1
log.info(u' - No match: {0} - {1} ({2})',
log.info(' - No match: {0} - {1} ({2})',
artist, title, album)
if total_fails > 0:
log.info(u'Acquired {0}/{1} play-counts ({2} unknown)',
log.info('Acquired {0}/{1} play-counts ({2} unknown)',
total_found, total, total_fails)
return total_found, total_fails

View file

@ -0,0 +1,44 @@
# This file is part of beets.
# Copyright 2019, Jack Wilsdon <jack.wilsdon@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Load SQLite extensions.
"""
from beets.dbcore import Database
from beets.plugins import BeetsPlugin
import sqlite3
class LoadExtPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
if not Database.supports_extensions:
self._log.warn('loadext is enabled but the current SQLite '
'installation does not support extensions')
return
self.register_listener('library_opened', self.library_opened)
def library_opened(self, lib):
for v in self.config:
ext = v.as_filename()
self._log.debug('loading extension {}', ext)
try:
lib.load_extension(ext)
except sqlite3.OperationalError as e:
self._log.error('failed to load extension {}: {}', ext, e)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -16,7 +15,6 @@
"""Fetches, embeds, and displays lyrics.
"""
from __future__ import absolute_import, division, print_function
import difflib
import errno
@ -29,11 +27,11 @@ import requests
import unicodedata
from unidecode import unidecode
import warnings
import six
from six.moves import urllib
import urllib
try:
from bs4 import SoupStrainer, BeautifulSoup
import bs4
from bs4 import SoupStrainer
HAS_BEAUTIFUL_SOUP = True
except ImportError:
HAS_BEAUTIFUL_SOUP = False
@ -48,7 +46,7 @@ try:
# PY3: HTMLParseError was removed in 3.5 as strict mode
# was deprecated in 3.3.
# https://docs.python.org/3.3/library/html.parser.html
from six.moves.html_parser import HTMLParseError
from html.parser import HTMLParseError
except ImportError:
class HTMLParseError(Exception):
pass
@ -62,23 +60,23 @@ COMMENT_RE = re.compile(r'<!--.*-->', re.S)
TAG_RE = re.compile(r'<[^>]*>')
BREAK_RE = re.compile(r'\n?\s*<br([\s|/][^>]*)*>\s*\n?', re.I)
URL_CHARACTERS = {
u'\u2018': u"'",
u'\u2019': u"'",
u'\u201c': u'"',
u'\u201d': u'"',
u'\u2010': u'-',
u'\u2011': u'-',
u'\u2012': u'-',
u'\u2013': u'-',
u'\u2014': u'-',
u'\u2015': u'-',
u'\u2016': u'-',
u'\u2026': u'...',
'\u2018': "'",
'\u2019': "'",
'\u201c': '"',
'\u201d': '"',
'\u2010': '-',
'\u2011': '-',
'\u2012': '-',
'\u2013': '-',
'\u2014': '-',
'\u2015': '-',
'\u2016': '-',
'\u2026': '...',
}
USER_AGENT = 'beets/{}'.format(beets.__version__)
USER_AGENT = f'beets/{beets.__version__}'
# The content for the base index.rst generated in ReST mode.
REST_INDEX_TEMPLATE = u'''Lyrics
REST_INDEX_TEMPLATE = '''Lyrics
======
* :ref:`Song index <genindex>`
@ -94,11 +92,11 @@ Artist index:
'''
# The content for the base conf.py generated.
REST_CONF_TEMPLATE = u'''# -*- coding: utf-8 -*-
REST_CONF_TEMPLATE = '''# -*- coding: utf-8 -*-
master_doc = 'index'
project = u'Lyrics'
copyright = u'none'
author = u'Various Authors'
project = 'Lyrics'
copyright = 'none'
author = 'Various Authors'
latex_documents = [
(master_doc, 'Lyrics.tex', project,
author, 'manual'),
@ -117,7 +115,7 @@ epub_tocdup = False
def unichar(i):
try:
return six.unichr(i)
return chr(i)
except ValueError:
return struct.pack('i', i).decode('utf-32')
@ -126,12 +124,12 @@ def unescape(text):
"""Resolve &#xxx; HTML entities (and some others)."""
if isinstance(text, bytes):
text = text.decode('utf-8', 'ignore')
out = text.replace(u'&nbsp;', u' ')
out = text.replace('&nbsp;', ' ')
def replchar(m):
num = m.group(1)
return unichar(int(num))
out = re.sub(u"&#(\d+);", replchar, out)
out = re.sub("&#(\\d+);", replchar, out)
return out
@ -140,43 +138,10 @@ def extract_text_between(html, start_marker, end_marker):
_, html = html.split(start_marker, 1)
html, _ = html.split(end_marker, 1)
except ValueError:
return u''
return ''
return html
def extract_text_in(html, starttag):
"""Extract the text from a <DIV> tag in the HTML starting with
``starttag``. Returns None if parsing fails.
"""
# Strip off the leading text before opening tag.
try:
_, html = html.split(starttag, 1)
except ValueError:
return
# Walk through balanced DIV tags.
level = 0
parts = []
pos = 0
for match in DIV_RE.finditer(html):
if match.group(1): # Closing tag.
level -= 1
if level == 0:
pos = match.end()
else: # Opening tag.
if level == 0:
parts.append(html[pos:match.start()])
level += 1
if level == -1:
parts.append(html[pos:match.start()])
break
else:
print(u'no closing tag found!')
return
return u''.join(parts)
def search_pairs(item):
"""Yield a pairs of artists and titles to search for.
@ -186,6 +151,9 @@ def search_pairs(item):
In addition to the artist and title obtained from the `item` the
method tries to strip extra information like paranthesized suffixes
and featured artists from the strings and add them as candidates.
The artist sort name is added as a fallback candidate to help in
cases where artist name includes special characters or is in a
non-latin script.
The method also tries to split multiple titles separated with `/`.
"""
def generate_alternatives(string, patterns):
@ -199,19 +167,23 @@ def search_pairs(item):
alternatives.append(match.group(1))
return alternatives
title, artist = item.title, item.artist
title, artist, artist_sort = item.title, item.artist, item.artist_sort
patterns = [
# Remove any featuring artists from the artists name
r"(.*?) {0}".format(plugins.feat_tokens())]
fr"(.*?) {plugins.feat_tokens()}"]
artists = generate_alternatives(artist, patterns)
# Use the artist_sort as fallback only if it differs from artist to avoid
# repeated remote requests with the same search terms
if artist != artist_sort:
artists.append(artist_sort)
patterns = [
# Remove a parenthesized suffix from a title string. Common
# examples include (live), (remix), and (acoustic).
r"(.+?)\s+[(].*[)]$",
# Remove any featuring artists from the title
r"(.*?) {0}".format(plugins.feat_tokens(for_artist=False)),
r"(.*?) {}".format(plugins.feat_tokens(for_artist=False)),
# Remove part of title after colon ':' for songs with subtitles
r"(.+?)\s*:.*"]
titles = generate_alternatives(title, patterns)
@ -245,14 +217,27 @@ def slug(text):
return re.sub(r'\W+', '-', unidecode(text).lower().strip()).strip('-')
class Backend(object):
if HAS_BEAUTIFUL_SOUP:
def try_parse_html(html, **kwargs):
try:
return bs4.BeautifulSoup(html, 'html.parser', **kwargs)
except HTMLParseError:
return None
else:
def try_parse_html(html, **kwargs):
return None
class Backend:
REQUIRES_BS = False
def __init__(self, config, log):
self._log = log
@staticmethod
def _encode(s):
"""Encode the string for inclusion in a URL"""
if isinstance(s, six.text_type):
if isinstance(s, str):
for char, repl in URL_CHARACTERS.items():
s = s.replace(char, repl)
s = s.encode('utf-8', 'ignore')
@ -277,20 +262,21 @@ class Backend(object):
'User-Agent': USER_AGENT,
})
except requests.RequestException as exc:
self._log.debug(u'lyrics request failed: {0}', exc)
self._log.debug('lyrics request failed: {0}', exc)
return
if r.status_code == requests.codes.ok:
return r.text
else:
self._log.debug(u'failed to fetch: {0} ({1})', url, r.status_code)
self._log.debug('failed to fetch: {0} ({1})', url, r.status_code)
return None
def fetch(self, artist, title):
raise NotImplementedError()
class SymbolsReplaced(Backend):
class MusiXmatch(Backend):
REPLACEMENTS = {
r'\s+': '_',
r'\s+': '-',
'<': 'Less_Than',
'>': 'Greater_Than',
'#': 'Number_',
@ -298,39 +284,40 @@ class SymbolsReplaced(Backend):
r'[\]\}]': ')',
}
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
@classmethod
def _encode(cls, s):
for old, new in cls.REPLACEMENTS.items():
s = re.sub(old, new, s)
return super(SymbolsReplaced, cls)._encode(s)
class MusiXmatch(SymbolsReplaced):
REPLACEMENTS = dict(SymbolsReplaced.REPLACEMENTS, **{
r'\s+': '-'
})
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
return super()._encode(s)
def fetch(self, artist, title):
url = self.build_url(artist, title)
html = self.fetch_url(url)
if not html:
return
return None
if "We detected that your IP is blocked" in html:
self._log.warning(u'we are blocked at MusixMatch: url %s failed'
self._log.warning('we are blocked at MusixMatch: url %s failed'
% url)
return
html_part = html.split('<p class="mxm-lyrics__content')[-1]
lyrics = extract_text_between(html_part, '>', '</p>')
return None
html_parts = html.split('<p class="mxm-lyrics__content')
# Sometimes lyrics come in 2 or more parts
lyrics_parts = []
for html_part in html_parts:
lyrics_parts.append(extract_text_between(html_part, '>', '</p>'))
lyrics = '\n'.join(lyrics_parts)
lyrics = lyrics.strip(',"').replace('\\n', '\n')
# another odd case: sometimes only that string remains, for
# missing songs. this seems to happen after being blocked
# above, when filling in the CAPTCHA.
if "Instant lyrics for all your music." in lyrics:
return
return None
# sometimes there are non-existent lyrics with some content
if 'Lyrics | Musixmatch' in lyrics:
return None
return lyrics
@ -341,87 +328,171 @@ class Genius(Backend):
bigishdata.com/2016/09/27/getting-song-lyrics-from-geniuss-api-scraping/
"""
REQUIRES_BS = True
base_url = "https://api.genius.com"
def __init__(self, config, log):
super(Genius, self).__init__(config, log)
super().__init__(config, log)
self.api_key = config['genius_api_key'].as_str()
self.headers = {
'Authorization': "Bearer %s" % self.api_key,
'User-Agent': USER_AGENT,
}
def lyrics_from_song_api_path(self, song_api_path):
song_url = self.base_url + song_api_path
response = requests.get(song_url, headers=self.headers)
json = response.json()
path = json["response"]["song"]["path"]
# Gotta go regular html scraping... come on Genius.
page_url = "https://genius.com" + path
try:
page = requests.get(page_url)
except requests.RequestException as exc:
self._log.debug(u'Genius page request for {0} failed: {1}',
page_url, exc)
return None
html = BeautifulSoup(page.text, "html.parser")
# Remove script tags that they put in the middle of the lyrics.
[h.extract() for h in html('script')]
# At least Genius is nice and has a tag called 'lyrics'!
# Updated css where the lyrics are based in HTML.
lyrics = html.find("div", class_="lyrics").get_text()
return lyrics
def fetch(self, artist, title):
search_url = self.base_url + "/search"
data = {'q': title}
try:
response = requests.get(search_url, data=data,
headers=self.headers)
except requests.RequestException as exc:
self._log.debug(u'Genius API request failed: {0}', exc)
"""Fetch lyrics from genius.com
Because genius doesn't allow accesssing lyrics via the api,
we first query the api for a url matching our artist & title,
then attempt to scrape that url for the lyrics.
"""
json = self._search(artist, title)
if not json:
self._log.debug('Genius API request returned invalid JSON')
return None
try:
json = response.json()
except ValueError:
self._log.debug(u'Genius API request returned invalid JSON')
return None
song_info = None
# find a matching artist in the json
for hit in json["response"]["hits"]:
if hit["result"]["primary_artist"]["name"] == artist:
song_info = hit
break
hit_artist = hit["result"]["primary_artist"]["name"]
if song_info:
song_api_path = song_info["result"]["api_path"]
return self.lyrics_from_song_api_path(song_api_path)
if slug(hit_artist) == slug(artist):
html = self.fetch_url(hit["result"]["url"])
if not html:
return None
return self._scrape_lyrics_from_html(html)
self._log.debug('Genius failed to find a matching artist for \'{0}\'',
artist)
return None
class LyricsWiki(SymbolsReplaced):
"""Fetch lyrics from LyricsWiki."""
def _search(self, artist, title):
"""Searches the genius api for a given artist and title
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
https://docs.genius.com/#search-h2
def fetch(self, artist, title):
url = self.build_url(artist, title)
html = self.fetch_url(url)
if not html:
:returns: json response
"""
search_url = self.base_url + "/search"
data = {'q': title + " " + artist.lower()}
try:
response = requests.get(
search_url, data=data, headers=self.headers)
except requests.RequestException as exc:
self._log.debug('Genius API request failed: {0}', exc)
return None
try:
return response.json()
except ValueError:
return None
def _scrape_lyrics_from_html(self, html):
"""Scrape lyrics from a given genius.com html"""
soup = try_parse_html(html)
if not soup:
return
# Get the HTML fragment inside the appropriate HTML element and then
# extract the text from it.
html_frag = extract_text_in(html, u"<div class='lyricbox'>")
if html_frag:
lyrics = _scrape_strip_cruft(html_frag, True)
# Remove script tags that they put in the middle of the lyrics.
[h.extract() for h in soup('script')]
if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
return lyrics
# Most of the time, the page contains a div with class="lyrics" where
# all of the lyrics can be found already correctly formatted
# Sometimes, though, it packages the lyrics into separate divs, most
# likely for easier ad placement
lyrics_div = soup.find("div", class_="lyrics")
if not lyrics_div:
self._log.debug('Received unusual song page html')
verse_div = soup.find("div",
class_=re.compile("Lyrics__Container"))
if not verse_div:
if soup.find("div",
class_=re.compile("LyricsPlaceholder__Message"),
string="This song is an instrumental"):
self._log.debug('Detected instrumental')
return "[Instrumental]"
else:
self._log.debug("Couldn't scrape page using known layouts")
return None
lyrics_div = verse_div.parent
for br in lyrics_div.find_all("br"):
br.replace_with("\n")
ads = lyrics_div.find_all("div",
class_=re.compile("InreadAd__Container"))
for ad in ads:
ad.replace_with("\n")
return lyrics_div.get_text()
class Tekstowo(Backend):
# Fetch lyrics from Tekstowo.pl.
REQUIRES_BS = True
BASE_URL = 'http://www.tekstowo.pl'
URL_PATTERN = BASE_URL + '/wyszukaj.html?search-title=%s&search-artist=%s'
def fetch(self, artist, title):
url = self.build_url(title, artist)
search_results = self.fetch_url(url)
if not search_results:
return None
song_page_url = self.parse_search_results(search_results)
if not song_page_url:
return None
song_page_html = self.fetch_url(song_page_url)
if not song_page_html:
return None
return self.extract_lyrics(song_page_html)
def parse_search_results(self, html):
html = _scrape_strip_cruft(html)
html = _scrape_merge_paragraphs(html)
soup = try_parse_html(html)
if not soup:
return None
content_div = soup.find("div", class_="content")
if not content_div:
return None
card_div = content_div.find("div", class_="card")
if not card_div:
return None
song_rows = card_div.find_all("div", class_="box-przeboje")
if not song_rows:
return None
song_row = song_rows[0]
if not song_row:
return None
link = song_row.find('a')
if not link:
return None
return self.BASE_URL + link.get('href')
def extract_lyrics(self, html):
html = _scrape_strip_cruft(html)
html = _scrape_merge_paragraphs(html)
soup = try_parse_html(html)
if not soup:
return None
lyrics_div = soup.find("div", class_="song-text")
if not lyrics_div:
return None
return lyrics_div.get_text()
def remove_credits(text):
@ -446,7 +517,8 @@ def _scrape_strip_cruft(html, plain_text_out=False):
html = html.replace('\r', '\n') # Normalize EOL.
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'.
html = re.sub(r'<(script).*?</\1>(?s)', '', html) # Strip script tags.
html = re.sub(r'(?s)<(script).*?</\1>', '', html) # Strip script tags.
html = re.sub('\u2005', " ", html) # replace unicode with regular space
if plain_text_out: # Strip remaining HTML tags
html = COMMENT_RE.sub('', html)
@ -466,12 +538,6 @@ def scrape_lyrics_from_html(html):
"""Scrape lyrics from a URL. If no lyrics can be found, return None
instead.
"""
if not HAS_BEAUTIFUL_SOUP:
return None
if not html:
return None
def is_text_notcode(text):
length = len(text)
return (length > 20 and
@ -481,10 +547,8 @@ def scrape_lyrics_from_html(html):
html = _scrape_merge_paragraphs(html)
# extract all long text blocks that are not code
try:
soup = BeautifulSoup(html, "html.parser",
parse_only=SoupStrainer(text=is_text_notcode))
except HTMLParseError:
soup = try_parse_html(html, parse_only=SoupStrainer(text=is_text_notcode))
if not soup:
return None
# Get the longest text element (if any).
@ -498,8 +562,10 @@ def scrape_lyrics_from_html(html):
class Google(Backend):
"""Fetch lyrics from Google search results."""
REQUIRES_BS = True
def __init__(self, config, log):
super(Google, self).__init__(config, log)
super().__init__(config, log)
self.api_key = config['google_API_key'].as_str()
self.engine_id = config['google_engine_ID'].as_str()
@ -511,7 +577,7 @@ class Google(Backend):
bad_triggers_occ = []
nb_lines = text.count('\n')
if nb_lines <= 1:
self._log.debug(u"Ignoring too short lyrics '{0}'", text)
self._log.debug("Ignoring too short lyrics '{0}'", text)
return False
elif nb_lines < 5:
bad_triggers_occ.append('too_short')
@ -522,14 +588,14 @@ class Google(Backend):
bad_triggers = ['lyrics', 'copyright', 'property', 'links']
if artist:
bad_triggers_occ += [artist]
bad_triggers += [artist]
for item in bad_triggers:
bad_triggers_occ += [item] * len(re.findall(r'\W%s\W' % item,
text, re.I))
if bad_triggers_occ:
self._log.debug(u'Bad triggers detected: {0}', bad_triggers_occ)
self._log.debug('Bad triggers detected: {0}', bad_triggers_occ)
return len(bad_triggers_occ) < 2
def slugify(self, text):
@ -537,14 +603,14 @@ class Google(Backend):
"""
text = re.sub(r"[-'_\s]", '_', text)
text = re.sub(r"_+", '_', text).strip('_')
pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses
text = re.sub(pat, '\g<1>', text).strip()
pat = r"([^,\(]*)\((.*?)\)" # Remove content within parentheses
text = re.sub(pat, r'\g<1>', text).strip()
try:
text = unicodedata.normalize('NFKD', text).encode('ascii',
'ignore')
text = six.text_type(re.sub('[-\s]+', ' ', text.decode('utf-8')))
text = str(re.sub(r'[-\s]+', ' ', text.decode('utf-8')))
except UnicodeDecodeError:
self._log.exception(u"Failing to normalize '{0}'", text)
self._log.exception("Failing to normalize '{0}'", text)
return text
BY_TRANS = ['by', 'par', 'de', 'von']
@ -556,7 +622,7 @@ class Google(Backend):
"""
title = self.slugify(title.lower())
artist = self.slugify(artist.lower())
sitename = re.search(u"//([^/]+)/.*",
sitename = re.search("//([^/]+)/.*",
self.slugify(url_link.lower())).group(1)
url_title = self.slugify(url_title.lower())
@ -570,7 +636,7 @@ class Google(Backend):
[artist, sitename, sitename.replace('www.', '')] + \
self.LYRICS_TRANS
tokens = [re.escape(t) for t in tokens]
song_title = re.sub(u'(%s)' % u'|'.join(tokens), u'', url_title)
song_title = re.sub('(%s)' % '|'.join(tokens), '', url_title)
song_title = song_title.strip('_|')
typo_ratio = .9
@ -578,53 +644,57 @@ class Google(Backend):
return ratio >= typo_ratio
def fetch(self, artist, title):
query = u"%s %s" % (artist, title)
url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \
query = f"{artist} {title}"
url = 'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' \
% (self.api_key, self.engine_id,
urllib.parse.quote(query.encode('utf-8')))
data = self.fetch_url(url)
if not data:
self._log.debug(u'google backend returned no data')
self._log.debug('google backend returned no data')
return None
try:
data = json.loads(data)
except ValueError as exc:
self._log.debug(u'google backend returned malformed JSON: {}', exc)
self._log.debug('google backend returned malformed JSON: {}', exc)
if 'error' in data:
reason = data['error']['errors'][0]['reason']
self._log.debug(u'google backend error: {0}', reason)
self._log.debug('google backend error: {0}', reason)
return None
if 'items' in data.keys():
for item in data['items']:
url_link = item['link']
url_title = item.get('title', u'')
url_title = item.get('title', '')
if not self.is_page_candidate(url_link, url_title,
title, artist):
continue
html = self.fetch_url(url_link)
if not html:
continue
lyrics = scrape_lyrics_from_html(html)
if not lyrics:
continue
if self.is_lyrics(lyrics, artist):
self._log.debug(u'got lyrics from {0}',
self._log.debug('got lyrics from {0}',
item['displayLink'])
return lyrics
return None
class LyricsPlugin(plugins.BeetsPlugin):
SOURCES = ['google', 'lyricwiki', 'musixmatch', 'genius']
SOURCES = ['google', 'musixmatch', 'genius', 'tekstowo']
SOURCE_BACKENDS = {
'google': Google,
'lyricwiki': LyricsWiki,
'musixmatch': MusiXmatch,
'genius': Genius,
'tekstowo': Tekstowo,
}
def __init__(self):
super(LyricsPlugin, self).__init__()
super().__init__()
self.import_stages = [self.imported]
self.config.add({
'auto': True,
@ -632,7 +702,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
'bing_lang_from': [],
'bing_lang_to': None,
'google_API_key': None,
'google_engine_ID': u'009217259823014548361:lndtuqkycfu',
'google_engine_ID': '009217259823014548361:lndtuqkycfu',
'genius_api_key':
"Ryq93pUGm8bM6eUWwD_M3NOFFDAtp2yEE7W"
"76V-uFL5jks5dNvcGCdarqFjDhP9c",
@ -648,7 +718,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
# State information for the ReST writer.
# First, the current artist we're writing.
self.artist = u'Unknown artist'
self.artist = 'Unknown artist'
# The current album: False means no album yet.
self.album = False
# The current rest file content. None means the file is not
@ -659,40 +729,44 @@ class LyricsPlugin(plugins.BeetsPlugin):
sources = plugins.sanitize_choices(
self.config['sources'].as_str_seq(), available_sources)
if not HAS_BEAUTIFUL_SOUP:
sources = self.sanitize_bs_sources(sources)
if 'google' in sources:
if not self.config['google_API_key'].get():
# We log a *debug* message here because the default
# configuration includes `google`. This way, the source
# is silent by default but can be enabled just by
# setting an API key.
self._log.debug(u'Disabling google source: '
u'no API key configured.')
self._log.debug('Disabling google source: '
'no API key configured.')
sources.remove('google')
elif not HAS_BEAUTIFUL_SOUP:
self._log.warning(u'To use the google lyrics source, you must '
u'install the beautifulsoup4 module. See '
u'the documentation for further details.')
sources.remove('google')
if 'genius' in sources and not HAS_BEAUTIFUL_SOUP:
self._log.debug(
u'The Genius backend requires BeautifulSoup, which is not '
u'installed, so the source is disabled.'
)
sources.remove('genius')
self.config['bing_lang_from'] = [
x.lower() for x in self.config['bing_lang_from'].as_str_seq()]
self.bing_auth_token = None
if not HAS_LANGDETECT and self.config['bing_client_secret'].get():
self._log.warning(u'To use bing translations, you need to '
u'install the langdetect module. See the '
u'documentation for further details.')
self._log.warning('To use bing translations, you need to '
'install the langdetect module. See the '
'documentation for further details.')
self.backends = [self.SOURCE_BACKENDS[source](self.config, self._log)
for source in sources]
def sanitize_bs_sources(self, sources):
enabled_sources = []
for source in sources:
if self.SOURCE_BACKENDS[source].REQUIRES_BS:
self._log.debug('To use the %s lyrics source, you must '
'install the beautifulsoup4 module. See '
'the documentation for further details.'
% source)
else:
enabled_sources.append(source)
return enabled_sources
def get_bing_access_token(self):
params = {
'client_id': 'beets',
@ -708,30 +782,30 @@ class LyricsPlugin(plugins.BeetsPlugin):
if 'access_token' in oauth_token:
return "Bearer " + oauth_token['access_token']
else:
self._log.warning(u'Could not get Bing Translate API access token.'
u' Check your "bing_client_secret" password')
self._log.warning('Could not get Bing Translate API access token.'
' Check your "bing_client_secret" password')
def commands(self):
cmd = ui.Subcommand('lyrics', help='fetch song lyrics')
cmd.parser.add_option(
u'-p', u'--print', dest='printlyr',
'-p', '--print', dest='printlyr',
action='store_true', default=False,
help=u'print lyrics to console',
help='print lyrics to console',
)
cmd.parser.add_option(
u'-r', u'--write-rest', dest='writerest',
'-r', '--write-rest', dest='writerest',
action='store', default=None, metavar='dir',
help=u'write lyrics to given directory as ReST files',
help='write lyrics to given directory as ReST files',
)
cmd.parser.add_option(
u'-f', u'--force', dest='force_refetch',
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help=u'always re-download lyrics',
help='always re-download lyrics',
)
cmd.parser.add_option(
u'-l', u'--local', dest='local_only',
'-l', '--local', dest='local_only',
action='store_true', default=False,
help=u'do not fetch missing lyrics',
help='do not fetch missing lyrics',
)
def func(lib, opts, args):
@ -740,7 +814,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
write = ui.should_write()
if opts.writerest:
self.writerest_indexes(opts.writerest)
for item in lib.items(ui.decargs(args)):
items = lib.items(ui.decargs(args))
for item in items:
if not opts.local_only and not self.config['local']:
self.fetch_item_lyrics(
lib, item, write,
@ -750,51 +825,55 @@ class LyricsPlugin(plugins.BeetsPlugin):
if opts.printlyr:
ui.print_(item.lyrics)
if opts.writerest:
self.writerest(opts.writerest, item)
if opts.writerest:
# flush last artist
self.writerest(opts.writerest, None)
ui.print_(u'ReST files generated. to build, use one of:')
ui.print_(u' sphinx-build -b html %s _build/html'
self.appendrest(opts.writerest, item)
if opts.writerest and items:
# flush last artist & write to ReST
self.writerest(opts.writerest)
ui.print_('ReST files generated. to build, use one of:')
ui.print_(' sphinx-build -b html %s _build/html'
% opts.writerest)
ui.print_(u' sphinx-build -b epub %s _build/epub'
ui.print_(' sphinx-build -b epub %s _build/epub'
% opts.writerest)
ui.print_((u' sphinx-build -b latex %s _build/latex '
u'&& make -C _build/latex all-pdf')
ui.print_((' sphinx-build -b latex %s _build/latex '
'&& make -C _build/latex all-pdf')
% opts.writerest)
cmd.func = func
return [cmd]
def writerest(self, directory, item):
"""Write the item to an ReST file
def appendrest(self, directory, item):
"""Append the item to an ReST file
This will keep state (in the `rest` variable) in order to avoid
writing continuously to the same files.
"""
if item is None or slug(self.artist) != slug(item.albumartist):
if self.rest is not None:
path = os.path.join(directory, 'artists',
slug(self.artist) + u'.rst')
with open(path, 'wb') as output:
output.write(self.rest.encode('utf-8'))
self.rest = None
if item is None:
return
if slug(self.artist) != slug(item.albumartist):
# Write current file and start a new one ~ item.albumartist
self.writerest(directory)
self.artist = item.albumartist.strip()
self.rest = u"%s\n%s\n\n.. contents::\n :local:\n\n" \
self.rest = "%s\n%s\n\n.. contents::\n :local:\n\n" \
% (self.artist,
u'=' * len(self.artist))
'=' * len(self.artist))
if self.album != item.album:
tmpalbum = self.album = item.album.strip()
if self.album == '':
tmpalbum = u'Unknown album'
self.rest += u"%s\n%s\n\n" % (tmpalbum, u'-' * len(tmpalbum))
title_str = u":index:`%s`" % item.title.strip()
block = u'| ' + item.lyrics.replace(u'\n', u'\n| ')
self.rest += u"%s\n%s\n\n%s\n\n" % (title_str,
u'~' * len(title_str),
block)
tmpalbum = 'Unknown album'
self.rest += "{}\n{}\n\n".format(tmpalbum, '-' * len(tmpalbum))
title_str = ":index:`%s`" % item.title.strip()
block = '| ' + item.lyrics.replace('\n', '\n| ')
self.rest += "{}\n{}\n\n{}\n\n".format(title_str,
'~' * len(title_str),
block)
def writerest(self, directory):
"""Write self.rest to a ReST file
"""
if self.rest is not None and self.artist is not None:
path = os.path.join(directory, 'artists',
slug(self.artist) + '.rst')
with open(path, 'wb') as output:
output.write(self.rest.encode('utf-8'))
def writerest_indexes(self, directory):
"""Write conf.py and index.rst files necessary for Sphinx
@ -832,7 +911,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
"""
# Skip if the item already has lyrics.
if not force and item.lyrics:
self._log.info(u'lyrics already present: {0}', item)
self._log.info('lyrics already present: {0}', item)
return
lyrics = None
@ -841,10 +920,10 @@ class LyricsPlugin(plugins.BeetsPlugin):
if any(lyrics):
break
lyrics = u"\n\n---\n\n".join([l for l in lyrics if l])
lyrics = "\n\n---\n\n".join([l for l in lyrics if l])
if lyrics:
self._log.info(u'fetched lyrics: {0}', item)
self._log.info('fetched lyrics: {0}', item)
if HAS_LANGDETECT and self.config['bing_client_secret'].get():
lang_from = langdetect.detect(lyrics)
if self.config['bing_lang_to'].get() != lang_from and (
@ -854,7 +933,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
lyrics = self.append_translation(
lyrics, self.config['bing_lang_to'])
else:
self._log.info(u'lyrics not found: {0}', item)
self._log.info('lyrics not found: {0}', item)
fallback = self.config['fallback'].get()
if fallback:
lyrics = fallback
@ -872,12 +951,12 @@ class LyricsPlugin(plugins.BeetsPlugin):
for backend in self.backends:
lyrics = backend.fetch(artist, title)
if lyrics:
self._log.debug(u'got lyrics from backend: {0}',
self._log.debug('got lyrics from backend: {0}',
backend.__class__.__name__)
return _scrape_strip_cruft(lyrics, True)
def append_translation(self, text, to_lang):
import xml.etree.ElementTree as ET
from xml.etree import ElementTree
if not self.bing_auth_token:
self.bing_auth_token = self.get_bing_access_token()
@ -895,10 +974,11 @@ class LyricsPlugin(plugins.BeetsPlugin):
self.bing_auth_token = None
return self.append_translation(text, to_lang)
return text
lines_translated = ET.fromstring(r.text.encode('utf-8')).text
lines_translated = ElementTree.fromstring(
r.text.encode('utf-8')).text
# Use a translation mapping dict to build resulting lyrics
translations = dict(zip(text_lines, lines_translated.split('|')))
result = ''
for line in text.split('\n'):
result += '%s / %s\n' % (line, translations[line])
result += '{} / {}\n'.format(line, translations[line])
return result

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red>
#
@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
@ -34,11 +32,11 @@ def mb_call(func, *args, **kwargs):
try:
return func(*args, **kwargs)
except musicbrainzngs.AuthenticationError:
raise ui.UserError(u'authentication with MusicBrainz failed')
raise ui.UserError('authentication with MusicBrainz failed')
except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc:
raise ui.UserError(u'MusicBrainz API error: {0}'.format(exc))
raise ui.UserError(f'MusicBrainz API error: {exc}')
except musicbrainzngs.UsageError:
raise ui.UserError(u'MusicBrainz credentials missing')
raise ui.UserError('MusicBrainz credentials missing')
def submit_albums(collection_id, release_ids):
@ -55,7 +53,7 @@ def submit_albums(collection_id, release_ids):
class MusicBrainzCollectionPlugin(BeetsPlugin):
def __init__(self):
super(MusicBrainzCollectionPlugin, self).__init__()
super().__init__()
config['musicbrainz']['pass'].redact = True
musicbrainzngs.auth(
config['musicbrainz']['user'].as_str(),
@ -63,7 +61,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
)
self.config.add({
'auto': False,
'collection': u'',
'collection': '',
'remove': False,
})
if self.config['auto']:
@ -72,18 +70,18 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
def _get_collection(self):
collections = mb_call(musicbrainzngs.get_collections)
if not collections['collection-list']:
raise ui.UserError(u'no collections exist for user')
raise ui.UserError('no collections exist for user')
# Get all collection IDs, avoiding event collections
collection_ids = [x['id'] for x in collections['collection-list']]
if not collection_ids:
raise ui.UserError(u'No collection found.')
raise ui.UserError('No collection found.')
# Check that the collection exists so we can present a nice error
collection = self.config['collection'].as_str()
if collection:
if collection not in collection_ids:
raise ui.UserError(u'invalid collection ID: {}'
raise ui.UserError('invalid collection ID: {}'
.format(collection))
return collection
@ -110,7 +108,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
def commands(self):
mbupdate = Subcommand('mbupdate',
help=u'Update MusicBrainz collection')
help='Update MusicBrainz collection')
mbupdate.parser.add_option('-r', '--remove',
action='store_true',
default=None,
@ -120,7 +118,7 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
return [mbupdate]
def remove_missing(self, collection_id, lib_albums):
lib_ids = set([x.mb_albumid for x in lib_albums])
lib_ids = {x.mb_albumid for x in lib_albums}
albums_in_collection = self._get_albums_in_collection(collection_id)
remove_me = list(set(albums_in_collection) - lib_ids)
for i in range(0, len(remove_me), FETCH_CHUNK_SIZE):
@ -154,13 +152,13 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
if re.match(UUID_REGEX, aid):
album_ids.append(aid)
else:
self._log.info(u'skipping invalid MBID: {0}', aid)
self._log.info('skipping invalid MBID: {0}', aid)
# Submit to MusicBrainz.
self._log.info(
u'Updating MusicBrainz collection {0}...', collection_id
'Updating MusicBrainz collection {0}...', collection_id
)
submit_albums(collection_id, album_ids)
if remove_missing:
self.remove_missing(collection_id, lib.albums())
self._log.info(u'...MusicBrainz collection updated.')
self._log.info('...MusicBrainz collection updated.')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson and Diego Moreda.
#
@ -19,11 +18,9 @@ This plugin allows the user to print track information in a format that is
parseable by the MusicBrainz track parser [1]. Programmatic submitting is not
implemented by MusicBrainz yet.
[1] http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
"""
from __future__ import division, absolute_import, print_function
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
@ -33,10 +30,10 @@ from beetsplug.info import print_data
class MBSubmitPlugin(BeetsPlugin):
def __init__(self):
super(MBSubmitPlugin, self).__init__()
super().__init__()
self.config.add({
'format': u'$track. $title - $artist ($length)',
'format': '$track. $title - $artist ($length)',
'threshold': 'medium',
})
@ -53,7 +50,7 @@ class MBSubmitPlugin(BeetsPlugin):
def before_choose_candidate_event(self, session, task):
if task.rec <= self.threshold:
return [PromptChoice(u'p', u'Print tracks', self.print_tracks)]
return [PromptChoice('p', 'Print tracks', self.print_tracks)]
def print_tracks(self, session, task):
for i in sorted(task.items, key=lambda i: i.track):

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Jakob Schnitzer.
#
@ -15,47 +14,37 @@
"""Update library's tags using MusicBrainz.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from beets.autotag import hooks
from collections import defaultdict
import re
def apply_item_changes(lib, item, move, pretend, write):
"""Store, move and write the item according to the arguments.
"""
if not pretend:
# Move the item if it's in the library.
if move and lib.directory in util.ancestry(item.path):
item.move(with_album=False)
if write:
item.try_write()
item.store()
MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}"
class MBSyncPlugin(BeetsPlugin):
def __init__(self):
super(MBSyncPlugin, self).__init__()
super().__init__()
def commands(self):
cmd = ui.Subcommand('mbsync',
help=u'update metadata from musicbrainz')
help='update metadata from musicbrainz')
cmd.parser.add_option(
u'-p', u'--pretend', action='store_true',
help=u'show all changes but do nothing')
'-p', '--pretend', action='store_true',
help='show all changes but do nothing')
cmd.parser.add_option(
u'-m', u'--move', action='store_true', dest='move',
help=u"move files in the library directory")
'-m', '--move', action='store_true', dest='move',
help="move files in the library directory")
cmd.parser.add_option(
u'-M', u'--nomove', action='store_false', dest='move',
help=u"don't move files in library")
'-M', '--nomove', action='store_false', dest='move',
help="don't move files in library")
cmd.parser.add_option(
u'-W', u'--nowrite', action='store_false',
'-W', '--nowrite', action='store_false',
default=None, dest='write',
help=u"don't write updated metadata to files")
help="don't write updated metadata to files")
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
@ -75,17 +64,23 @@ class MBSyncPlugin(BeetsPlugin):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + [u'singleton:true']):
for item in lib.items(query + ['singleton:true']):
item_formatted = format(item)
if not item.mb_trackid:
self._log.info(u'Skipping singleton with no mb_trackid: {0}',
self._log.info('Skipping singleton with no mb_trackid: {0}',
item_formatted)
continue
# Do we have a valid MusicBrainz track ID?
if not re.match(MBID_REGEX, item.mb_trackid):
self._log.info('Skipping singleton with invalid mb_trackid:' +
' {0}', item_formatted)
continue
# Get the MusicBrainz recording info.
track_info = hooks.track_for_mbid(item.mb_trackid)
if not track_info:
self._log.info(u'Recording ID not found: {0} for track {0}',
self._log.info('Recording ID not found: {0} for track {0}',
item.mb_trackid,
item_formatted)
continue
@ -103,16 +98,22 @@ class MBSyncPlugin(BeetsPlugin):
for a in lib.albums(query):
album_formatted = format(a)
if not a.mb_albumid:
self._log.info(u'Skipping album with no mb_albumid: {0}',
self._log.info('Skipping album with no mb_albumid: {0}',
album_formatted)
continue
items = list(a.items())
# Do we have a valid MusicBrainz album ID?
if not re.match(MBID_REGEX, a.mb_albumid):
self._log.info('Skipping album with invalid mb_albumid: {0}',
album_formatted)
continue
# Get the MusicBrainz album information.
album_info = hooks.album_for_mbid(a.mb_albumid)
if not album_info:
self._log.info(u'Release ID {0} not found for album {1}',
self._log.info('Release ID {0} not found for album {1}',
a.mb_albumid,
album_formatted)
continue
@ -120,7 +121,7 @@ class MBSyncPlugin(BeetsPlugin):
# Map release track and recording MBIDs to their information.
# Recordings can appear multiple times on a release, so each MBID
# maps to a list of TrackInfo objects.
releasetrack_index = dict()
releasetrack_index = {}
track_index = defaultdict(list)
for track_info in album_info.tracks:
releasetrack_index[track_info.release_track_id] = track_info
@ -148,7 +149,7 @@ class MBSyncPlugin(BeetsPlugin):
break
# Apply.
self._log.debug(u'applying changes to {}', album_formatted)
self._log.debug('applying changes to {}', album_formatted)
with lib.transaction():
autotag.apply_metadata(album_info, mapping)
changed = False
@ -173,5 +174,5 @@ class MBSyncPlugin(BeetsPlugin):
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
self._log.debug(u'moving album {0}', album_formatted)
self._log.debug('moving album {0}', album_formatted)
a.move()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Heinz Wiesinger.
#
@ -16,15 +15,13 @@
"""Synchronize information from music player libraries
"""
from __future__ import division, absolute_import, print_function
from abc import abstractmethod, ABCMeta
from importlib import import_module
from beets.util.confit import ConfigValueError
from confuse import ConfigValueError
from beets import ui
from beets.plugins import BeetsPlugin
import six
METASYNC_MODULE = 'beetsplug.metasync'
@ -36,7 +33,7 @@ SOURCES = {
}
class MetaSource(six.with_metaclass(ABCMeta, object)):
class MetaSource(metaclass=ABCMeta):
def __init__(self, config, log):
self.item_types = {}
self.config = config
@ -77,7 +74,7 @@ class MetaSyncPlugin(BeetsPlugin):
item_types = load_item_types()
def __init__(self):
super(MetaSyncPlugin, self).__init__()
super().__init__()
def commands(self):
cmd = ui.Subcommand('metasync',
@ -108,7 +105,7 @@ class MetaSyncPlugin(BeetsPlugin):
# Avoid needlessly instantiating meta sources (can be expensive)
if not items:
self._log.info(u'No items found matching query')
self._log.info('No items found matching query')
return
# Instantiate the meta sources
@ -116,18 +113,18 @@ class MetaSyncPlugin(BeetsPlugin):
try:
cls = META_SOURCES[player]
except KeyError:
self._log.error(u'Unknown metadata source \'{0}\''.format(
self._log.error('Unknown metadata source \'{}\''.format(
player))
try:
meta_source_instances[player] = cls(self.config, self._log)
except (ImportError, ConfigValueError) as e:
self._log.error(u'Failed to instantiate metadata source '
u'\'{0}\': {1}'.format(player, e))
self._log.error('Failed to instantiate metadata source '
'\'{}\': {}'.format(player, e))
# Avoid needlessly iterating over items
if not meta_source_instances:
self._log.error(u'No valid metadata sources found')
self._log.error('No valid metadata sources found')
return
# Sync the items with all of the meta sources

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Heinz Wiesinger.
#
@ -16,7 +15,6 @@
"""Synchronize information from amarok's library via dbus
"""
from __future__ import division, absolute_import, print_function
from os.path import basename
from datetime import datetime
@ -49,14 +47,14 @@ class Amarok(MetaSource):
'amarok_lastplayed': DateType(),
}
queryXML = u'<query version="1.0"> \
query_xml = '<query version="1.0"> \
<filters> \
<and><include field="filename" value=%s /></and> \
</filters> \
</query>'
def __init__(self, config, log):
super(Amarok, self).__init__(config, log)
super().__init__(config, log)
if not dbus:
raise ImportError('failed to import dbus')
@ -72,7 +70,7 @@ class Amarok(MetaSource):
# of the result set. So query for the filename and then try to match
# the correct item from the results we get back
results = self.collection.Query(
self.queryXML % quoteattr(basename(path))
self.query_xml % quoteattr(basename(path))
)
for result in results:
if result['xesam:url'] != path:

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Tom Jaspers.
#
@ -16,7 +15,6 @@
"""Synchronize information from iTunes's library
"""
from __future__ import division, absolute_import, print_function
from contextlib import contextmanager
import os
@ -24,13 +22,13 @@ import shutil
import tempfile
import plistlib
from six.moves.urllib.parse import urlparse, unquote
from urllib.parse import urlparse, unquote
from time import mktime
from beets import util
from beets.dbcore import types
from beets.library import DateType
from beets.util.confit import ConfigValueError
from confuse import ConfigValueError
from beetsplug.metasync import MetaSource
@ -63,15 +61,16 @@ def _norm_itunes_path(path):
class Itunes(MetaSource):
item_types = {
'itunes_rating': types.INTEGER, # 0..100 scale
'itunes_playcount': types.INTEGER,
'itunes_skipcount': types.INTEGER,
'itunes_lastplayed': DateType(),
'itunes_rating': types.INTEGER, # 0..100 scale
'itunes_playcount': types.INTEGER,
'itunes_skipcount': types.INTEGER,
'itunes_lastplayed': DateType(),
'itunes_lastskipped': DateType(),
'itunes_dateadded': DateType(),
}
def __init__(self, config, log):
super(Itunes, self).__init__(config, log)
super().__init__(config, log)
config.add({'itunes': {
'library': '~/Music/iTunes/iTunes Library.xml'
@ -82,19 +81,20 @@ class Itunes(MetaSource):
try:
self._log.debug(
u'loading iTunes library from {0}'.format(library_path))
f'loading iTunes library from {library_path}')
with create_temporary_copy(library_path) as library_copy:
raw_library = plistlib.readPlist(library_copy)
except IOError as e:
raise ConfigValueError(u'invalid iTunes library: ' + e.strerror)
with open(library_copy, 'rb') as library_copy_f:
raw_library = plistlib.load(library_copy_f)
except OSError as e:
raise ConfigValueError('invalid iTunes library: ' + e.strerror)
except Exception:
# It's likely the user configured their '.itl' library (<> xml)
if os.path.splitext(library_path)[1].lower() != '.xml':
hint = u': please ensure that the configured path' \
u' points to the .XML library'
hint = ': please ensure that the configured path' \
' points to the .XML library'
else:
hint = ''
raise ConfigValueError(u'invalid iTunes library' + hint)
raise ConfigValueError('invalid iTunes library' + hint)
# Make the iTunes library queryable using the path
self.collection = {_norm_itunes_path(track['Location']): track
@ -105,7 +105,7 @@ class Itunes(MetaSource):
result = self.collection.get(util.bytestring_path(item.path).lower())
if not result:
self._log.warning(u'no iTunes match found for {0}'.format(item))
self._log.warning(f'no iTunes match found for {item}')
return
item.itunes_rating = result.get('Rating')
@ -119,3 +119,7 @@ class Itunes(MetaSource):
if result.get('Skip Date'):
item.itunes_lastskipped = mktime(
result.get('Skip Date').timetuple())
if result.get('Date Added'):
item.itunes_dateadded = mktime(
result.get('Date Added').timetuple())

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Pedro Silva.
# Copyright 2017, Quentin Young.
@ -16,7 +15,6 @@
"""List missing tracks.
"""
from __future__ import division, absolute_import, print_function
import musicbrainzngs
@ -93,7 +91,7 @@ class MissingPlugin(BeetsPlugin):
}
def __init__(self):
super(MissingPlugin, self).__init__()
super().__init__()
self.config.add({
'count': False,
@ -107,14 +105,14 @@ class MissingPlugin(BeetsPlugin):
help=__doc__,
aliases=['miss'])
self._command.parser.add_option(
u'-c', u'--count', dest='count', action='store_true',
help=u'count missing tracks per album')
'-c', '--count', dest='count', action='store_true',
help='count missing tracks per album')
self._command.parser.add_option(
u'-t', u'--total', dest='total', action='store_true',
help=u'count total of missing tracks')
'-t', '--total', dest='total', action='store_true',
help='count total of missing tracks')
self._command.parser.add_option(
u'-a', u'--album', dest='album', action='store_true',
help=u'show missing albums for artist instead of tracks')
'-a', '--album', dest='album', action='store_true',
help='show missing albums for artist instead of tracks')
self._command.parser.add_format_option()
def commands(self):
@ -173,10 +171,10 @@ class MissingPlugin(BeetsPlugin):
# build dict mapping artist to list of all albums
for artist, albums in albums_by_artist.items():
if artist[1] is None or artist[1] == "":
albs_no_mbid = [u"'" + a['album'] + u"'" for a in albums]
albs_no_mbid = ["'" + a['album'] + "'" for a in albums]
self._log.info(
u"No musicbrainz ID for artist '{}' found in album(s) {}; "
"skipping", artist[0], u", ".join(albs_no_mbid)
"No musicbrainz ID for artist '{}' found in album(s) {}; "
"skipping", artist[0], ", ".join(albs_no_mbid)
)
continue
@ -185,7 +183,7 @@ class MissingPlugin(BeetsPlugin):
release_groups = resp['release-group-list']
except MusicBrainzError as err:
self._log.info(
u"Couldn't fetch info for artist '{}' ({}) - '{}'",
"Couldn't fetch info for artist '{}' ({}) - '{}'",
artist[0], artist[1], err
)
continue
@ -207,7 +205,7 @@ class MissingPlugin(BeetsPlugin):
missing_titles = {rg['title'] for rg in missing}
for release_title in missing_titles:
print_(u"{} - {}".format(artist[0], release_title))
print_("{} - {}".format(artist[0], release_title))
if total:
print(total_missing)
@ -216,13 +214,13 @@ class MissingPlugin(BeetsPlugin):
"""Query MusicBrainz to determine items missing from `album`.
"""
item_mbids = [x.mb_trackid for x in album.items()]
if len([i for i in album.items()]) < album.albumtotal:
if len(list(album.items())) < album.albumtotal:
# fetch missing items
# TODO: Implement caching that without breaking other stuff
album_info = hooks.album_for_mbid(album.mb_albumid)
for track_info in getattr(album_info, 'tracks', []):
if track_info.track_id not in item_mbids:
item = _item(track_info, album_info, album.id)
self._log.debug(u'track {0} in album {1}',
self._log.debug('track {0} in album {1}',
track_info.track_id, album_info.album_id)
yield item

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Peter Schnebel and Johann Klähn.
#
@ -13,11 +12,8 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
import mpd
import socket
import select
import time
import os
@ -45,14 +41,21 @@ def is_url(path):
return path.split('://', 1)[0] in ['http', 'https']
class MPDClientWrapper(object):
class MPDClientWrapper:
def __init__(self, log):
self._log = log
self.music_directory = (
mpd_config['music_directory'].as_str())
self.music_directory = mpd_config['music_directory'].as_str()
self.strip_path = mpd_config['strip_path'].as_str()
self.client = mpd.MPDClient(use_unicode=True)
# Ensure strip_path end with '/'
if not self.strip_path.endswith('/'):
self.strip_path += '/'
self._log.debug('music_directory: {0}', self.music_directory)
self._log.debug('strip_path: {0}', self.strip_path)
self.client = mpd.MPDClient()
def connect(self):
"""Connect to the MPD.
@ -63,11 +66,11 @@ class MPDClientWrapper(object):
if host[0] in ['/', '~']:
host = os.path.expanduser(host)
self._log.info(u'connecting to {0}:{1}', host, port)
self._log.info('connecting to {0}:{1}', host, port)
try:
self.client.connect(host, port)
except socket.error as e:
raise ui.UserError(u'could not connect to MPD: {0}'.format(e))
except OSError as e:
raise ui.UserError(f'could not connect to MPD: {e}')
password = mpd_config['password'].as_str()
if password:
@ -75,7 +78,7 @@ class MPDClientWrapper(object):
self.client.password(password)
except mpd.CommandError as e:
raise ui.UserError(
u'could not authenticate to MPD: {0}'.format(e)
f'could not authenticate to MPD: {e}'
)
def disconnect(self):
@ -90,12 +93,12 @@ class MPDClientWrapper(object):
"""
try:
return getattr(self.client, command)()
except (select.error, mpd.ConnectionError) as err:
self._log.error(u'{0}', err)
except (OSError, mpd.ConnectionError) as err:
self._log.error('{0}', err)
if retries <= 0:
# if we exited without breaking, we couldn't reconnect in time :(
raise ui.UserError(u'communication with MPD server failed')
raise ui.UserError('communication with MPD server failed')
time.sleep(RETRY_INTERVAL)
@ -107,18 +110,26 @@ class MPDClientWrapper(object):
self.connect()
return self.get(command, retries=retries - 1)
def playlist(self):
"""Return the currently active playlist. Prefixes paths with the
music_directory, to get the absolute path.
def currentsong(self):
"""Return the path to the currently playing song, along with its
songid. Prefixes paths with the music_directory, to get the absolute
path.
In some cases, we need to remove the local path from MPD server,
we replace 'strip_path' with ''.
`strip_path` defaults to ''.
"""
result = {}
for entry in self.get('playlistinfo'):
result = None
entry = self.get('currentsong')
if 'file' in entry:
if not is_url(entry['file']):
result[entry['id']] = os.path.join(
self.music_directory, entry['file'])
file = entry['file']
if file.startswith(self.strip_path):
file = file[len(self.strip_path):]
result = os.path.join(self.music_directory, file)
else:
result[entry['id']] = entry['file']
return result
result = entry['file']
self._log.debug('returning: {0}', result)
return result, entry.get('id')
def status(self):
"""Return the current status of the MPD.
@ -132,7 +143,7 @@ class MPDClientWrapper(object):
return self.get('idle')
class MPDStats(object):
class MPDStats:
def __init__(self, lib, log):
self.lib = lib
self._log = log
@ -164,7 +175,7 @@ class MPDStats(object):
if item:
return item
else:
self._log.info(u'item not found: {0}', displayable_path(path))
self._log.info('item not found: {0}', displayable_path(path))
def update_item(self, item, attribute, value=None, increment=None):
"""Update the beets item. Set attribute to value or increment the value
@ -182,7 +193,7 @@ class MPDStats(object):
item[attribute] = value
item.store()
self._log.debug(u'updated: {0} = {1} [{2}]',
self._log.debug('updated: {0} = {1} [{2}]',
attribute,
item[attribute],
displayable_path(item.path))
@ -229,29 +240,31 @@ class MPDStats(object):
"""Updates the play count of a song.
"""
self.update_item(song['beets_item'], 'play_count', increment=1)
self._log.info(u'played {0}', displayable_path(song['path']))
self._log.info('played {0}', displayable_path(song['path']))
def handle_skipped(self, song):
"""Updates the skip count of a song.
"""
self.update_item(song['beets_item'], 'skip_count', increment=1)
self._log.info(u'skipped {0}', displayable_path(song['path']))
self._log.info('skipped {0}', displayable_path(song['path']))
def on_stop(self, status):
self._log.info(u'stop')
self._log.info('stop')
if self.now_playing:
# if the current song stays the same it means that we stopped on the
# current track and should not record a skip.
if self.now_playing and self.now_playing['id'] != status.get('songid'):
self.handle_song_change(self.now_playing)
self.now_playing = None
def on_pause(self, status):
self._log.info(u'pause')
self._log.info('pause')
self.now_playing = None
def on_play(self, status):
playlist = self.mpd.playlist()
path = playlist.get(status['songid'])
path, songid = self.mpd.currentsong()
if not path:
return
@ -276,16 +289,17 @@ class MPDStats(object):
self.handle_song_change(self.now_playing)
if is_url(path):
self._log.info(u'playing stream {0}', displayable_path(path))
self._log.info('playing stream {0}', displayable_path(path))
self.now_playing = None
return
self._log.info(u'playing {0}', displayable_path(path))
self._log.info('playing {0}', displayable_path(path))
self.now_playing = {
'started': time.time(),
'remaining': remaining,
'path': path,
'started': time.time(),
'remaining': remaining,
'path': path,
'id': songid,
'beets_item': self.get_item(path),
}
@ -305,7 +319,7 @@ class MPDStats(object):
if handler:
handler(status)
else:
self._log.debug(u'unhandled status "{0}"', status)
self._log.debug('unhandled status "{0}"', status)
events = self.mpd.events()
@ -313,37 +327,38 @@ class MPDStats(object):
class MPDStatsPlugin(plugins.BeetsPlugin):
item_types = {
'play_count': types.INTEGER,
'skip_count': types.INTEGER,
'play_count': types.INTEGER,
'skip_count': types.INTEGER,
'last_played': library.DateType(),
'rating': types.FLOAT,
'rating': types.FLOAT,
}
def __init__(self):
super(MPDStatsPlugin, self).__init__()
super().__init__()
mpd_config.add({
'music_directory': config['directory'].as_filename(),
'rating': True,
'rating_mix': 0.75,
'host': os.environ.get('MPD_HOST', u'localhost'),
'port': 6600,
'password': u'',
'strip_path': '',
'rating': True,
'rating_mix': 0.75,
'host': os.environ.get('MPD_HOST', 'localhost'),
'port': int(os.environ.get('MPD_PORT', 6600)),
'password': '',
})
mpd_config['password'].redact = True
def commands(self):
cmd = ui.Subcommand(
'mpdstats',
help=u'run a MPD client to gather play statistics')
help='run a MPD client to gather play statistics')
cmd.parser.add_option(
u'--host', dest='host', type='string',
help=u'set the hostname of the server to connect to')
'--host', dest='host', type='string',
help='set the hostname of the server to connect to')
cmd.parser.add_option(
u'--port', dest='port', type='int',
help=u'set the port of the MPD server to connect to')
'--port', dest='port', type='int',
help='set the port of the MPD server to connect to')
cmd.parser.add_option(
u'--password', dest='password', type='string',
help=u'set the password of the MPD server to connect to')
'--password', dest='password', type='string',
help='set the password of the MPD server to connect to')
def func(lib, opts, args):
mpd_config.set_args(opts)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -21,19 +20,17 @@ Put something like the following in your config.yaml to configure:
port: 6600
password: seekrit
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
import os
import socket
from beets import config
import six
# No need to introduce a dependency on an MPD library for such a
# simple use case. Here's a simple socket abstraction to make things
# easier.
class BufferedSocket(object):
class BufferedSocket:
"""Socket abstraction that allows reading by line."""
def __init__(self, host, port, sep=b'\n'):
if host[0] in ['/', '~']:
@ -66,11 +63,11 @@ class BufferedSocket(object):
class MPDUpdatePlugin(BeetsPlugin):
def __init__(self):
super(MPDUpdatePlugin, self).__init__()
super().__init__()
config['mpd'].add({
'host': os.environ.get('MPD_HOST', u'localhost'),
'port': 6600,
'password': u'',
'host': os.environ.get('MPD_HOST', 'localhost'),
'port': int(os.environ.get('MPD_PORT', 6600)),
'password': '',
})
config['mpd']['password'].redact = True
@ -100,21 +97,21 @@ class MPDUpdatePlugin(BeetsPlugin):
try:
s = BufferedSocket(host, port)
except socket.error as e:
self._log.warning(u'MPD connection failed: {0}',
six.text_type(e.strerror))
except OSError as e:
self._log.warning('MPD connection failed: {0}',
str(e.strerror))
return
resp = s.readline()
if b'OK MPD' not in resp:
self._log.warning(u'MPD connection failed: {0!r}', resp)
self._log.warning('MPD connection failed: {0!r}', resp)
return
if password:
s.send(b'password "%s"\n' % password.encode('utf8'))
resp = s.readline()
if b'OK' not in resp:
self._log.warning(u'Authentication failed: {0!r}', resp)
self._log.warning('Authentication failed: {0!r}', resp)
s.send(b'close\n')
s.close()
return
@ -122,8 +119,8 @@ class MPDUpdatePlugin(BeetsPlugin):
s.send(b'update\n')
resp = s.readline()
if b'updating_db' not in resp:
self._log.warning(u'Update failed: {0!r}', resp)
self._log.warning('Update failed: {0!r}', resp)
s.send(b'close\n')
s.close()
self._log.info(u'Database updated.')
self._log.info('Database updated.')

View file

@ -0,0 +1,211 @@
# This file is part of beets.
# Copyright 2017, Dorian Soergel.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Gets parent work, its disambiguation and id, composer, composer sort name
and work composition date
"""
from beets import ui
from beets.plugins import BeetsPlugin
import musicbrainzngs
def direct_parent_id(mb_workid, work_date=None):
"""Given a Musicbrainz work id, find the id one of the works the work is
part of and the first composition date it encounters.
"""
work_info = musicbrainzngs.get_work_by_id(mb_workid,
includes=["work-rels",
"artist-rels"])
if 'artist-relation-list' in work_info['work'] and work_date is None:
for artist in work_info['work']['artist-relation-list']:
if artist['type'] == 'composer':
if 'end' in artist.keys():
work_date = artist['end']
if 'work-relation-list' in work_info['work']:
for direct_parent in work_info['work']['work-relation-list']:
if direct_parent['type'] == 'parts' \
and direct_parent.get('direction') == 'backward':
direct_id = direct_parent['work']['id']
return direct_id, work_date
return None, work_date
def work_parent_id(mb_workid):
"""Find the parent work id and composition date of a work given its id.
"""
work_date = None
while True:
new_mb_workid, work_date = direct_parent_id(mb_workid, work_date)
if not new_mb_workid:
return mb_workid, work_date
mb_workid = new_mb_workid
return mb_workid, work_date
def find_parentwork_info(mb_workid):
"""Get the MusicBrainz information dict about a parent work, including
the artist relations, and the composition date for a work's parent work.
"""
parent_id, work_date = work_parent_id(mb_workid)
work_info = musicbrainzngs.get_work_by_id(parent_id,
includes=["artist-rels"])
return work_info, work_date
class ParentWorkPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'auto': False,
'force': False,
})
if self.config['auto']:
self.import_stages = [self.imported]
def commands(self):
def func(lib, opts, args):
self.config.set_args(opts)
force_parent = self.config['force'].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
changed = self.find_work(item, force_parent)
if changed:
item.store()
if write:
item.try_write()
command = ui.Subcommand(
'parentwork',
help='fetch parent works, composers and dates')
command.parser.add_option(
'-f', '--force', dest='force',
action='store_true', default=None,
help='re-fetch when parent work is already present')
command.func = func
return [command]
def imported(self, session, task):
"""Import hook for fetching parent works automatically.
"""
force_parent = self.config['force'].get(bool)
for item in task.imported_items():
self.find_work(item, force_parent)
item.store()
def get_info(self, item, work_info):
"""Given the parent work info dict, fetch parent_composer,
parent_composer_sort, parentwork, parentwork_disambig, mb_workid and
composer_ids.
"""
parent_composer = []
parent_composer_sort = []
parentwork_info = {}
composer_exists = False
if 'artist-relation-list' in work_info['work']:
for artist in work_info['work']['artist-relation-list']:
if artist['type'] == 'composer':
composer_exists = True
parent_composer.append(artist['artist']['name'])
parent_composer_sort.append(artist['artist']['sort-name'])
if 'end' in artist.keys():
parentwork_info["parentwork_date"] = artist['end']
parentwork_info['parent_composer'] = ', '.join(parent_composer)
parentwork_info['parent_composer_sort'] = ', '.join(
parent_composer_sort)
if not composer_exists:
self._log.debug(
'no composer for {}; add one at '
'https://musicbrainz.org/work/{}',
item, work_info['work']['id'],
)
parentwork_info['parentwork'] = work_info['work']['title']
parentwork_info['mb_parentworkid'] = work_info['work']['id']
if 'disambiguation' in work_info['work']:
parentwork_info['parentwork_disambig'] = work_info[
'work']['disambiguation']
else:
parentwork_info['parentwork_disambig'] = None
return parentwork_info
def find_work(self, item, force):
"""Finds the parent work of a recording and populates the tags
accordingly.
The parent work is found recursively, by finding the direct parent
repeatedly until there are no more links in the chain. We return the
final, topmost work in the chain.
Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
parent_composer, parent_composer_sort and work_date are populated.
"""
if not item.mb_workid:
self._log.info('No work for {}, \
add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
return
hasparent = hasattr(item, 'parentwork')
work_changed = True
if hasattr(item, 'parentwork_workid_current'):
work_changed = item.parentwork_workid_current != item.mb_workid
if force or not hasparent or work_changed:
try:
work_info, work_date = find_parentwork_info(item.mb_workid)
except musicbrainzngs.musicbrainz.WebServiceError as e:
self._log.debug("error fetching work: {}", e)
return
parent_info = self.get_info(item, work_info)
parent_info['parentwork_workid_current'] = item.mb_workid
if 'parent_composer' in parent_info:
self._log.debug("Work fetched: {} - {}",
parent_info['parentwork'],
parent_info['parent_composer'])
else:
self._log.debug("Work fetched: {} - no parent composer",
parent_info['parentwork'])
elif hasparent:
self._log.debug("{}: Work present, skipping", item)
return
# apply all non-null values to the item
for key, value in parent_info.items():
if value:
item[key] = value
if work_date:
item['work_date'] = work_date
return ui.show_model_changes(
item, fields=['parentwork', 'parentwork_disambig',
'mb_parentworkid', 'parent_composer',
'parent_composer_sort', 'work_date',
'parentwork_workid_current', 'parentwork_date'])

View file

@ -1,7 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function
"""Fixes file permissions after the file gets written on import. Put something
like the following in your config.yaml to configure:
@ -13,7 +9,6 @@ import os
from beets import config, util
from beets.plugins import BeetsPlugin
from beets.util import ancestry
import six
def convert_perm(perm):
@ -21,8 +16,8 @@ def convert_perm(perm):
Or, if `perm` is an integer, reinterpret it as an octal number that
has been "misinterpreted" as decimal.
"""
if isinstance(perm, six.integer_types):
perm = six.text_type(perm)
if isinstance(perm, int):
perm = str(perm)
return int(perm, 8)
@ -40,11 +35,11 @@ def assert_permissions(path, permission, log):
"""
if not check_permissions(util.syspath(path), permission):
log.warning(
u'could not set permissions on {}',
'could not set permissions on {}',
util.displayable_path(path),
)
log.debug(
u'set permissions to {}, but permissions are now {}',
'set permissions to {}, but permissions are now {}',
permission,
os.stat(util.syspath(path)).st_mode & 0o777,
)
@ -60,20 +55,39 @@ def dirs_in_library(library, item):
class Permissions(BeetsPlugin):
def __init__(self):
super(Permissions, self).__init__()
super().__init__()
# Adding defaults.
self.config.add({
u'file': '644',
u'dir': '755',
'file': '644',
'dir': '755',
})
self.register_listener('item_imported', self.fix)
self.register_listener('album_imported', self.fix)
self.register_listener('art_set', self.fix_art)
def fix(self, lib, item=None, album=None):
"""Fix the permissions for an imported Item or Album.
"""
files = []
dirs = set()
if item:
files.append(item.path)
dirs.update(dirs_in_library(lib.directory, item.path))
elif album:
for album_item in album.items():
files.append(album_item.path)
dirs.update(dirs_in_library(lib.directory, album_item.path))
self.set_permissions(files=files, dirs=dirs)
def fix_art(self, album):
"""Fix the permission for Album art file.
"""
if album.artpath:
self.set_permissions(files=[album.artpath])
def set_permissions(self, files=[], dirs=[]):
# Get the configured permissions. The user can specify this either a
# string (in YAML quotes) or, for convenience, as an integer so the
# quotes can be omitted. In the latter case, we need to reinterpret the
@ -83,21 +97,10 @@ class Permissions(BeetsPlugin):
file_perm = convert_perm(file_perm)
dir_perm = convert_perm(dir_perm)
# Create chmod_queue.
file_chmod_queue = []
if item:
file_chmod_queue.append(item.path)
elif album:
for album_item in album.items():
file_chmod_queue.append(album_item.path)
# A set of directories to change permissions for.
dir_chmod_queue = set()
for path in file_chmod_queue:
for path in files:
# Changing permissions on the destination file.
self._log.debug(
u'setting file permissions on {}',
'setting file permissions on {}',
util.displayable_path(path),
)
os.chmod(util.syspath(path), file_perm)
@ -105,16 +108,11 @@ class Permissions(BeetsPlugin):
# Checks if the destination path has the permissions configured.
assert_permissions(path, file_perm, self._log)
# Adding directories to the directory chmod queue.
dir_chmod_queue.update(
dirs_in_library(lib.directory,
path))
# Change permissions for the directories.
for path in dir_chmod_queue:
# Chaning permissions on the destination directory.
for path in dirs:
# Changing permissions on the destination directory.
self._log.debug(
u'setting directory permissions on {}',
'setting directory permissions on {}',
util.displayable_path(path),
)
os.chmod(util.syspath(path), dir_perm)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, David Hamp-Gonsalves
#
@ -15,7 +14,6 @@
"""Send the results of a query to the configured music player as a playlist.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
@ -26,6 +24,7 @@ from beets import util
from os.path import relpath
from tempfile import NamedTemporaryFile
import subprocess
import shlex
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
@ -39,25 +38,25 @@ def play(command_str, selection, paths, open_args, log, item_type='track',
"""
# Print number of tracks or albums to be played, log command to be run.
item_type += 's' if len(selection) > 1 else ''
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
log.debug(u'executing command: {} {!r}', command_str, open_args)
ui.print_('Playing {} {}.'.format(len(selection), item_type))
log.debug('executing command: {} {!r}', command_str, open_args)
try:
if keep_open:
command = util.shlex_split(command_str)
command = shlex.split(command_str)
command = command + open_args
subprocess.call(command)
else:
util.interactive_open(open_args, command_str)
except OSError as exc:
raise ui.UserError(
"Could not play the query: {0}".format(exc))
f"Could not play the query: {exc}")
class PlayPlugin(BeetsPlugin):
def __init__(self):
super(PlayPlugin, self).__init__()
super().__init__()
config['play'].add({
'command': None,
@ -65,6 +64,7 @@ class PlayPlugin(BeetsPlugin):
'relative_to': None,
'raw': False,
'warning_threshold': 100,
'bom': False,
})
self.register_listener('before_choose_candidate',
@ -73,18 +73,18 @@ class PlayPlugin(BeetsPlugin):
def commands(self):
play_command = Subcommand(
'play',
help=u'send music to a player as a playlist'
help='send music to a player as a playlist'
)
play_command.parser.add_album_option()
play_command.parser.add_option(
u'-A', u'--args',
'-A', '--args',
action='store',
help=u'add additional arguments to the command',
help='add additional arguments to the command',
)
play_command.parser.add_option(
u'-y', u'--yes',
'-y', '--yes',
action="store_true",
help=u'skip the warning threshold',
help='skip the warning threshold',
)
play_command.func = self._play_command
return [play_command]
@ -123,7 +123,7 @@ class PlayPlugin(BeetsPlugin):
if not selection:
ui.print_(ui.colorize('text_warning',
u'No {0} to play.'.format(item_type)))
f'No {item_type} to play.'))
return
open_args = self._playlist_or_paths(paths)
@ -147,7 +147,7 @@ class PlayPlugin(BeetsPlugin):
if ARGS_MARKER in command_str:
return command_str.replace(ARGS_MARKER, args)
else:
return u"{} {}".format(command_str, args)
return f"{command_str} {args}"
else:
# Don't include the marker in the command.
return command_str.replace(" " + ARGS_MARKER, "")
@ -174,10 +174,10 @@ class PlayPlugin(BeetsPlugin):
ui.print_(ui.colorize(
'text_warning',
u'You are about to queue {0} {1}.'.format(
'You are about to queue {} {}.'.format(
len(selection), item_type)))
if ui.input_options((u'Continue', u'Abort')) == 'a':
if ui.input_options(('Continue', 'Abort')) == 'a':
return True
return False
@ -185,7 +185,12 @@ class PlayPlugin(BeetsPlugin):
def _create_tmp_playlist(self, paths_list):
"""Create a temporary .m3u file. Return the filename.
"""
utf8_bom = config['play']['bom'].get(bool)
m3u = NamedTemporaryFile('wb', suffix='.m3u', delete=False)
if utf8_bom:
m3u.write(b'\xEF\xBB\xBF')
for item in paths_list:
m3u.write(item + b'\n')
m3u.close()

View file

@ -0,0 +1,185 @@
# This file is part of beets.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
import os
import fnmatch
import tempfile
import beets
from beets.util import path_as_posix
class PlaylistQuery(beets.dbcore.Query):
"""Matches files listed by a playlist file.
"""
def __init__(self, pattern):
self.pattern = pattern
config = beets.config['playlist']
# Get the full path to the playlist
playlist_paths = (
pattern,
os.path.abspath(os.path.join(
config['playlist_dir'].as_filename(),
f'{pattern}.m3u',
)),
)
self.paths = []
for playlist_path in playlist_paths:
if not fnmatch.fnmatch(playlist_path, '*.[mM]3[uU]'):
# This is not am M3U playlist, skip this candidate
continue
try:
f = open(beets.util.syspath(playlist_path), mode='rb')
except OSError:
continue
if config['relative_to'].get() == 'library':
relative_to = beets.config['directory'].as_filename()
elif config['relative_to'].get() == 'playlist':
relative_to = os.path.dirname(playlist_path)
else:
relative_to = config['relative_to'].as_filename()
relative_to = beets.util.bytestring_path(relative_to)
for line in f:
if line[0] == '#':
# ignore comments, and extm3u extension
continue
self.paths.append(beets.util.normpath(
os.path.join(relative_to, line.rstrip())
))
f.close()
break
def col_clause(self):
if not self.paths:
# Playlist is empty
return '0', ()
clause = 'path IN ({})'.format(', '.join('?' for path in self.paths))
return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
def match(self, item):
return item.path in self.paths
class PlaylistPlugin(beets.plugins.BeetsPlugin):
item_queries = {'playlist': PlaylistQuery}
def __init__(self):
super().__init__()
self.config.add({
'auto': False,
'playlist_dir': '.',
'relative_to': 'library',
'forward_slash': False,
})
self.playlist_dir = self.config['playlist_dir'].as_filename()
self.changes = {}
if self.config['relative_to'].get() == 'library':
self.relative_to = beets.util.bytestring_path(
beets.config['directory'].as_filename())
elif self.config['relative_to'].get() != 'playlist':
self.relative_to = beets.util.bytestring_path(
self.config['relative_to'].as_filename())
else:
self.relative_to = None
if self.config['auto']:
self.register_listener('item_moved', self.item_moved)
self.register_listener('item_removed', self.item_removed)
self.register_listener('cli_exit', self.cli_exit)
def item_moved(self, item, source, destination):
self.changes[source] = destination
def item_removed(self, item):
if not os.path.exists(beets.util.syspath(item.path)):
self.changes[item.path] = None
def cli_exit(self, lib):
for playlist in self.find_playlists():
self._log.info(f'Updating playlist: {playlist}')
base_dir = beets.util.bytestring_path(
self.relative_to if self.relative_to
else os.path.dirname(playlist)
)
try:
self.update_playlist(playlist, base_dir)
except beets.util.FilesystemError:
self._log.error('Failed to update playlist: {}'.format(
beets.util.displayable_path(playlist)))
def find_playlists(self):
"""Find M3U playlists in the playlist directory."""
try:
dir_contents = os.listdir(beets.util.syspath(self.playlist_dir))
except OSError:
self._log.warning('Unable to open playlist directory {}'.format(
beets.util.displayable_path(self.playlist_dir)))
return
for filename in dir_contents:
if fnmatch.fnmatch(filename, '*.[mM]3[uU]'):
yield os.path.join(self.playlist_dir, filename)
def update_playlist(self, filename, base_dir):
"""Find M3U playlists in the specified directory."""
changes = 0
deletions = 0
with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as tempfp:
new_playlist = tempfp.name
with open(filename, mode='rb') as fp:
for line in fp:
original_path = line.rstrip(b'\r\n')
# Ensure that path from playlist is absolute
is_relative = not os.path.isabs(line)
if is_relative:
lookup = os.path.join(base_dir, original_path)
else:
lookup = original_path
try:
new_path = self.changes[beets.util.normpath(lookup)]
except KeyError:
if self.config['forward_slash']:
line = path_as_posix(line)
tempfp.write(line)
else:
if new_path is None:
# Item has been deleted
deletions += 1
continue
changes += 1
if is_relative:
new_path = os.path.relpath(new_path, base_dir)
line = line.replace(original_path, new_path)
if self.config['forward_slash']:
line = path_as_posix(line)
tempfp.write(line)
if changes or deletions:
self._log.info(
'Updated playlist {} ({} changes, {} deletions)'.format(
filename, changes, deletions))
beets.util.copy(new_playlist, filename, replace=True)
beets.util.remove(new_playlist)

View file

@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
"""Updates an Plex library whenever the beets library is changed.
Plex Home users enter the Plex Token to enable updating.
@ -9,42 +7,51 @@ Put something like the following in your config.yaml to configure:
port: 32400
token: token
"""
from __future__ import division, absolute_import, print_function
import requests
import xml.etree.ElementTree as ET
from six.moves.urllib.parse import urljoin, urlencode
from xml.etree import ElementTree
from urllib.parse import urljoin, urlencode
from beets import config
from beets.plugins import BeetsPlugin
def get_music_section(host, port, token, library_name):
def get_music_section(host, port, token, library_name, secure,
ignore_cert_errors):
"""Getting the section key for the music library in Plex.
"""
api_endpoint = append_token('library/sections', token)
url = urljoin('http://{0}:{1}'.format(host, port), api_endpoint)
url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
port), api_endpoint)
# Sends request.
r = requests.get(url)
r = requests.get(url, verify=not ignore_cert_errors)
# Parse xml tree and extract music section key.
tree = ET.fromstring(r.content)
tree = ElementTree.fromstring(r.content)
for child in tree.findall('Directory'):
if child.get('title') == library_name:
return child.get('key')
def update_plex(host, port, token, library_name):
def update_plex(host, port, token, library_name, secure,
ignore_cert_errors):
"""Ignore certificate errors if configured to.
"""
if ignore_cert_errors:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
"""Sends request to the Plex api to start a library refresh.
"""
# Getting section key and build url.
section_key = get_music_section(host, port, token, library_name)
api_endpoint = 'library/sections/{0}/refresh'.format(section_key)
section_key = get_music_section(host, port, token, library_name,
secure, ignore_cert_errors)
api_endpoint = f'library/sections/{section_key}/refresh'
api_endpoint = append_token(api_endpoint, token)
url = urljoin('http://{0}:{1}'.format(host, port), api_endpoint)
url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
port), api_endpoint)
# Sends request and returns requests object.
r = requests.get(url)
r = requests.get(url, verify=not ignore_cert_errors)
return r
@ -56,16 +63,25 @@ def append_token(url, token):
return url
def get_protocol(secure):
if secure:
return 'https'
else:
return 'http'
class PlexUpdate(BeetsPlugin):
def __init__(self):
super(PlexUpdate, self).__init__()
super().__init__()
# Adding defaults.
config['plex'].add({
u'host': u'localhost',
u'port': 32400,
u'token': u'',
u'library_name': u'Music'})
'host': 'localhost',
'port': 32400,
'token': '',
'library_name': 'Music',
'secure': False,
'ignore_cert_errors': False})
config['plex']['token'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
@ -77,7 +93,7 @@ class PlexUpdate(BeetsPlugin):
def update(self, lib):
"""When the client exists try to send refresh request to Plex server.
"""
self._log.info(u'Updating Plex library...')
self._log.info('Updating Plex library...')
# Try to send update request.
try:
@ -85,8 +101,10 @@ class PlexUpdate(BeetsPlugin):
config['plex']['host'].get(),
config['plex']['port'].get(),
config['plex']['token'].get(),
config['plex']['library_name'].get())
self._log.info(u'... started.')
config['plex']['library_name'].get(),
config['plex']['secure'].get(bool),
config['plex']['ignore_cert_errors'].get(bool))
self._log.info('... started.')
except requests.exceptions.RequestException:
self._log.warning(u'Update failed.')
self._log.warning('Update failed.')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Philippe Mongeau.
#
@ -15,101 +14,10 @@
"""Get a random song or album from the library.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, decargs, print_
import random
from operator import attrgetter
from itertools import groupby
def _length(obj, album):
"""Get the duration of an item or album.
"""
if album:
return sum(i.length for i in obj.items())
else:
return obj.length
def _equal_chance_permutation(objs, field='albumartist'):
"""Generate (lazily) a permutation of the objects where every group
with equal values for `field` have an equal chance of appearing in
any given position.
"""
# Group the objects by artist so we can sample from them.
key = attrgetter(field)
objs.sort(key=key)
objs_by_artists = {}
for artist, v in groupby(objs, key):
objs_by_artists[artist] = list(v)
# While we still have artists with music to choose from, pick one
# randomly and pick a track from that artist.
while objs_by_artists:
# Choose an artist and an object for that artist, removing
# this choice from the pool.
artist = random.choice(list(objs_by_artists.keys()))
objs_from_artist = objs_by_artists[artist]
i = random.randint(0, len(objs_from_artist) - 1)
yield objs_from_artist.pop(i)
# Remove the artist if we've used up all of its objects.
if not objs_from_artist:
del objs_by_artists[artist]
def _take(iter, num):
"""Return a list containing the first `num` values in `iter` (or
fewer, if the iterable ends early).
"""
out = []
for val in iter:
out.append(val)
num -= 1
if num <= 0:
break
return out
def _take_time(iter, secs, album):
"""Return a list containing the first values in `iter`, which should
be Item or Album objects, that add up to the given amount of time in
seconds.
"""
out = []
total_time = 0.0
for obj in iter:
length = _length(obj, album)
if total_time + length <= secs:
out.append(obj)
total_time += length
return out
def random_objs(objs, album, number=1, time=None, equal_chance=False):
"""Get a random subset of the provided `objs`.
If `number` is provided, produce that many matches. Otherwise, if
`time` is provided, instead select a list whose total time is close
to that number of minutes. If `equal_chance` is true, give each
artist an equal chance of being included so that artists with more
songs are not represented disproportionately.
"""
# Permute the objects either in a straightforward way or an
# artist-balanced way.
if equal_chance:
perm = _equal_chance_permutation(objs)
else:
perm = objs
random.shuffle(perm) # N.B. This shuffles the original list.
# Select objects by time our count.
if time:
return _take_time(perm, time * 60, album)
else:
return _take(perm, number)
from beets.random import random_objs
def random_func(lib, opts, args):
@ -130,16 +38,16 @@ def random_func(lib, opts, args):
random_cmd = Subcommand('random',
help=u'choose a random track or album')
help='choose a random track or album')
random_cmd.parser.add_option(
u'-n', u'--number', action='store', type="int",
help=u'number of objects to choose', default=1)
'-n', '--number', action='store', type="int",
help='number of objects to choose', default=1)
random_cmd.parser.add_option(
u'-e', u'--equal-chance', action='store_true',
help=u'each artist has the same chance')
'-e', '--equal-chance', action='store_true',
help='each artist has the same chance')
random_cmd.parser.add_option(
u'-t', u'--time', action='store', type="float",
help=u'total length in minutes of objects to choose')
'-t', '--time', action='store', type="float",
help='total length in minutes of objects to choose')
random_cmd.parser.add_all_common_options()
random_cmd.func = random_func

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -16,7 +15,6 @@
"""Uses user-specified rewriting rules to canonicalize names for path
formats.
"""
from __future__ import division, absolute_import, print_function
import re
from collections import defaultdict
@ -44,7 +42,7 @@ def rewriter(field, rules):
class RewritePlugin(BeetsPlugin):
def __init__(self):
super(RewritePlugin, self).__init__()
super().__init__()
self.config.add({})
@ -55,11 +53,11 @@ class RewritePlugin(BeetsPlugin):
try:
fieldname, pattern = key.split(None, 1)
except ValueError:
raise ui.UserError(u"invalid rewrite specification")
raise ui.UserError("invalid rewrite specification")
if fieldname not in library.Item._fields:
raise ui.UserError(u"invalid field name (%s) in rewriter" %
raise ui.UserError("invalid field name (%s) in rewriter" %
fieldname)
self._log.debug(u'adding template field {0}', key)
self._log.debug('adding template field {0}', key)
pattern = re.compile(pattern.lower())
rules[fieldname].append((pattern, value))
if fieldname == 'artist':

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -17,13 +16,12 @@
automatically whenever tags are written.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui
from beets import util
from beets import config
from beets import mediafile
import mediafile
import mutagen
_MUTAGEN_FORMATS = {
@ -48,7 +46,7 @@ _MUTAGEN_FORMATS = {
class ScrubPlugin(BeetsPlugin):
"""Removes extraneous metadata from files' tags."""
def __init__(self):
super(ScrubPlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
})
@ -60,15 +58,15 @@ class ScrubPlugin(BeetsPlugin):
def scrub_func(lib, opts, args):
# Walk through matching files and remove tags.
for item in lib.items(ui.decargs(args)):
self._log.info(u'scrubbing: {0}',
self._log.info('scrubbing: {0}',
util.displayable_path(item.path))
self._scrub_item(item, opts.write)
scrub_cmd = ui.Subcommand('scrub', help=u'clean audio tags')
scrub_cmd = ui.Subcommand('scrub', help='clean audio tags')
scrub_cmd.parser.add_option(
u'-W', u'--nowrite', dest='write',
'-W', '--nowrite', dest='write',
action='store_false', default=True,
help=u'leave tags empty')
help='leave tags empty')
scrub_cmd.func = scrub_func
return [scrub_cmd]
@ -79,7 +77,7 @@ class ScrubPlugin(BeetsPlugin):
"""
classes = []
for modname, clsname in _MUTAGEN_FORMATS.items():
mod = __import__('mutagen.{0}'.format(modname),
mod = __import__(f'mutagen.{modname}',
fromlist=[clsname])
classes.append(getattr(mod, clsname))
return classes
@ -107,8 +105,8 @@ class ScrubPlugin(BeetsPlugin):
for tag in f.keys():
del f[tag]
f.save()
except (IOError, mutagen.MutagenError) as exc:
self._log.error(u'could not scrub {0}: {1}',
except (OSError, mutagen.MutagenError) as exc:
self._log.error('could not scrub {0}: {1}',
util.displayable_path(path), exc)
def _scrub_item(self, item, restore=True):
@ -121,7 +119,7 @@ class ScrubPlugin(BeetsPlugin):
mf = mediafile.MediaFile(util.syspath(item.path),
config['id3v23'].get(bool))
except mediafile.UnreadableFileError as exc:
self._log.error(u'could not open file to scrub: {0}',
self._log.error('could not open file to scrub: {0}',
exc)
return
images = mf.images
@ -131,21 +129,21 @@ class ScrubPlugin(BeetsPlugin):
# Restore tags, if enabled.
if restore:
self._log.debug(u'writing new tags after scrub')
self._log.debug('writing new tags after scrub')
item.try_write()
if images:
self._log.debug(u'restoring art')
self._log.debug('restoring art')
try:
mf = mediafile.MediaFile(util.syspath(item.path),
config['id3v23'].get(bool))
mf.images = images
mf.save()
except mediafile.UnreadableFileError as exc:
self._log.error(u'could not write tags: {0}', exc)
self._log.error('could not write tags: {0}', exc)
def import_task_files(self, session, task):
"""Automatically scrub imported files."""
for item in task.imported_items():
self._log.debug(u'auto-scrubbing {0}',
self._log.debug('auto-scrubbing {0}',
util.displayable_path(item.path))
self._scrub_item(item)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Dang Mai <contact@dangmai.net>.
#
@ -16,30 +15,38 @@
"""Generates smart playlists based on beets queries.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui
from beets.util import (mkdirall, normpath, sanitize_path, syspath,
bytestring_path)
bytestring_path, path_as_posix)
from beets.library import Item, Album, parse_query_string
from beets.dbcore import OrQuery
from beets.dbcore.query import MultipleSort, ParsingError
import os
import six
try:
from urllib.request import pathname2url
except ImportError:
# python2 is a bit different
from urllib import pathname2url
class SmartPlaylistPlugin(BeetsPlugin):
def __init__(self):
super(SmartPlaylistPlugin, self).__init__()
super().__init__()
self.config.add({
'relative_to': None,
'playlist_dir': u'.',
'playlist_dir': '.',
'auto': True,
'playlists': []
'playlists': [],
'forward_slash': False,
'prefix': '',
'urlencode': False,
})
self.config['prefix'].redact = True # May contain username/password.
self._matched_playlists = None
self._unmatched_playlists = None
@ -49,8 +56,8 @@ class SmartPlaylistPlugin(BeetsPlugin):
def commands(self):
spl_update = ui.Subcommand(
'splupdate',
help=u'update the smart playlists. Playlist names may be '
u'passed as arguments.'
help='update the smart playlists. Playlist names may be '
'passed as arguments.'
)
spl_update.func = self.update_cmd
return [spl_update]
@ -61,14 +68,14 @@ class SmartPlaylistPlugin(BeetsPlugin):
args = set(ui.decargs(args))
for a in list(args):
if not a.endswith(".m3u"):
args.add("{0}.m3u".format(a))
args.add(f"{a}.m3u")
playlists = set((name, q, a_q)
for name, q, a_q in self._unmatched_playlists
if name in args)
playlists = {(name, q, a_q)
for name, q, a_q in self._unmatched_playlists
if name in args}
if not playlists:
raise ui.UserError(
u'No playlist matching any of {0} found'.format(
'No playlist matching any of {} found'.format(
[name for name, _, _ in self._unmatched_playlists])
)
@ -81,7 +88,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
def build_queries(self):
"""
Instanciate queries for the playlists.
Instantiate queries for the playlists.
Each playlist has 2 queries: one or items one for albums, each with a
sort. We must also remember its name. _unmatched_playlists is a set of
@ -99,22 +106,23 @@ class SmartPlaylistPlugin(BeetsPlugin):
for playlist in self.config['playlists'].get(list):
if 'name' not in playlist:
self._log.warning(u"playlist configuration is missing name")
self._log.warning("playlist configuration is missing name")
continue
playlist_data = (playlist['name'],)
try:
for key, Model in (('query', Item), ('album_query', Album)):
for key, model_cls in (('query', Item),
('album_query', Album)):
qs = playlist.get(key)
if qs is None:
query_and_sort = None, None
elif isinstance(qs, six.string_types):
query_and_sort = parse_query_string(qs, Model)
elif isinstance(qs, str):
query_and_sort = parse_query_string(qs, model_cls)
elif len(qs) == 1:
query_and_sort = parse_query_string(qs[0], Model)
query_and_sort = parse_query_string(qs[0], model_cls)
else:
# multiple queries and sorts
queries, sorts = zip(*(parse_query_string(q, Model)
queries, sorts = zip(*(parse_query_string(q, model_cls)
for q in qs))
query = OrQuery(queries)
final_sorts = []
@ -135,7 +143,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
playlist_data += (query_and_sort,)
except ParsingError as exc:
self._log.warning(u"invalid query in playlist {}: {}",
self._log.warning("invalid query in playlist {}: {}",
playlist['name'], exc)
continue
@ -156,14 +164,14 @@ class SmartPlaylistPlugin(BeetsPlugin):
n, (q, _), (a_q, _) = playlist
if self.matches(model, q, a_q):
self._log.debug(
u"{0} will be updated because of {1}", n, model)
"{0} will be updated because of {1}", n, model)
self._matched_playlists.add(playlist)
self.register_listener('cli_exit', self.update_playlists)
self._unmatched_playlists -= self._matched_playlists
def update_playlists(self, lib):
self._log.info(u"Updating {0} smart playlists...",
self._log.info("Updating {0} smart playlists...",
len(self._matched_playlists))
playlist_dir = self.config['playlist_dir'].as_filename()
@ -177,7 +185,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
for playlist in self._matched_playlists:
name, (query, q_sort), (album_query, a_q_sort) = playlist
self._log.debug(u"Creating playlist {0}", name)
self._log.debug("Creating playlist {0}", name)
items = []
if query:
@ -199,6 +207,7 @@ class SmartPlaylistPlugin(BeetsPlugin):
if item_path not in m3us[m3u_name]:
m3us[m3u_name].append(item_path)
prefix = bytestring_path(self.config['prefix'].as_str())
# Write all of the accumulated track lists to files.
for m3u in m3us:
m3u_path = normpath(os.path.join(playlist_dir,
@ -206,6 +215,10 @@ class SmartPlaylistPlugin(BeetsPlugin):
mkdirall(m3u_path)
with open(syspath(m3u_path), 'wb') as f:
for path in m3us[m3u]:
f.write(path + b'\n')
if self.config['forward_slash'].get():
path = path_as_posix(path)
if self.config['urlencode']:
path = bytestring_path(pathname2url(path))
f.write(prefix + path + b'\n')
self._log.info(u"{0} playlists updated", len(self._matched_playlists))
self._log.info("{0} playlists updated", len(self._matched_playlists))

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2018, Tobias Sauerwein.
#
@ -16,7 +15,6 @@
"""Updates a Sonos library whenever the beets library is changed.
This is based on the Kodi Update plugin.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
import soco
@ -24,7 +22,7 @@ import soco
class SonosUpdate(BeetsPlugin):
def __init__(self):
super(SonosUpdate, self).__init__()
super().__init__()
self.register_listener('database_change', self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
@ -35,14 +33,14 @@ class SonosUpdate(BeetsPlugin):
"""When the client exists try to send refresh request to a Sonos
controler.
"""
self._log.info(u'Requesting a Sonos library update...')
self._log.info('Requesting a Sonos library update...')
device = soco.discovery.any_soco()
if device:
device.music_library.start_library_update()
else:
self._log.warning(u'Could not find a Sonos device.')
self._log.warning('Could not find a Sonos device.')
return
self._log.info(u'Sonos update triggered')
self._log.info('Sonos update triggered')

View file

@ -1,61 +1,379 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2019, Rahul Ahuja.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
"""Adds Spotify release and track search support to the autotagger, along with
Spotify playlist construction.
"""
import re
import json
import base64
import webbrowser
import collections
import unidecode
import requests
from beets.plugins import BeetsPlugin
from beets.ui import decargs
import confuse
from beets import ui
from requests.exceptions import HTTPError
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import MetadataSourcePlugin, BeetsPlugin
class SpotifyPlugin(BeetsPlugin):
class SpotifyPlugin(MetadataSourcePlugin, BeetsPlugin):
data_source = 'Spotify'
# URL for the Web API of Spotify
# Documentation here: https://developer.spotify.com/web-api/search-item/
base_url = "https://api.spotify.com/v1/search"
open_url = "http://open.spotify.com/track/"
playlist_partial = "spotify:trackset:Playlist:"
# Base URLs for the Spotify API
# Documentation: https://developer.spotify.com/web-api
oauth_token_url = 'https://accounts.spotify.com/api/token'
open_track_url = 'https://open.spotify.com/track/'
search_url = 'https://api.spotify.com/v1/search'
album_url = 'https://api.spotify.com/v1/albums/'
track_url = 'https://api.spotify.com/v1/tracks/'
# Spotify IDs consist of 22 alphanumeric characters
# (zero-left-padded base62 representation of randomly generated UUID4)
id_regex = {
'pattern': r'(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})',
'match_group': 2,
}
def __init__(self):
super(SpotifyPlugin, self).__init__()
self.config.add({
'mode': 'list',
'tiebreak': 'popularity',
'show_failures': False,
'artist_field': 'albumartist',
'album_field': 'album',
'track_field': 'title',
'region_filter': None,
'regex': []
})
super().__init__()
self.config.add(
{
'mode': 'list',
'tiebreak': 'popularity',
'show_failures': False,
'artist_field': 'albumartist',
'album_field': 'album',
'track_field': 'title',
'region_filter': None,
'regex': [],
'client_id': '4e414367a1d14c75a5c5129a627fcab8',
'client_secret': 'f82bdc09b2254f1a8286815d02fd46dc',
'tokenfile': 'spotify_token.json',
}
)
self.config['client_secret'].redact = True
self.tokenfile = self.config['tokenfile'].get(
confuse.Filename(in_app_dir=True)
) # Path to the JSON file for storing the OAuth access token.
self.setup()
def setup(self):
"""Retrieve previously saved OAuth token or generate a new one."""
try:
with open(self.tokenfile) as f:
token_data = json.load(f)
except OSError:
self._authenticate()
else:
self.access_token = token_data['access_token']
def _authenticate(self):
"""Request an access token via the Client Credentials Flow:
https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
"""
headers = {
'Authorization': 'Basic {}'.format(
base64.b64encode(
':'.join(
self.config[k].as_str()
for k in ('client_id', 'client_secret')
).encode()
).decode()
)
}
response = requests.post(
self.oauth_token_url,
data={'grant_type': 'client_credentials'},
headers=headers,
)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
raise ui.UserError(
'Spotify authorization failed: {}\n{}'.format(
e, response.text
)
)
self.access_token = response.json()['access_token']
# Save the token for later use.
self._log.debug(
'{} access token: {}', self.data_source, self.access_token
)
with open(self.tokenfile, 'w') as f:
json.dump({'access_token': self.access_token}, f)
def _handle_response(self, request_type, url, params=None):
"""Send a request, reauthenticating if necessary.
:param request_type: Type of :class:`Request` constructor,
e.g. ``requests.get``, ``requests.post``, etc.
:type request_type: function
:param url: URL for the new :class:`Request` object.
:type url: str
:param params: (optional) list of tuples or bytes to send
in the query string for the :class:`Request`.
:type params: dict
:return: JSON data for the class:`Response <Response>` object.
:rtype: dict
"""
response = request_type(
url,
headers={'Authorization': f'Bearer {self.access_token}'},
params=params,
)
if response.status_code != 200:
if 'token expired' in response.text:
self._log.debug(
'{} access token has expired. Reauthenticating.',
self.data_source,
)
self._authenticate()
return self._handle_response(request_type, url, params=params)
else:
raise ui.UserError(
'{} API error:\n{}\nURL:\n{}\nparams:\n{}'.format(
self.data_source, response.text, url, params
)
)
return response.json()
def album_for_id(self, album_id):
"""Fetch an album by its Spotify ID or URL and return an
AlbumInfo object or None if the album is not found.
:param album_id: Spotify ID or URL for the album
:type album_id: str
:return: AlbumInfo object for album
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
spotify_id = self._get_id('album', album_id)
if spotify_id is None:
return None
album_data = self._handle_response(
requests.get, self.album_url + spotify_id
)
artist, artist_id = self.get_artist(album_data['artists'])
date_parts = [
int(part) for part in album_data['release_date'].split('-')
]
release_date_precision = album_data['release_date_precision']
if release_date_precision == 'day':
year, month, day = date_parts
elif release_date_precision == 'month':
year, month = date_parts
day = None
elif release_date_precision == 'year':
year = date_parts[0]
month = None
day = None
else:
raise ui.UserError(
"Invalid `release_date_precision` returned "
"by {} API: '{}'".format(
self.data_source, release_date_precision
)
)
tracks = []
medium_totals = collections.defaultdict(int)
for i, track_data in enumerate(album_data['tracks']['items'], start=1):
track = self._get_track(track_data)
track.index = i
medium_totals[track.medium] += 1
tracks.append(track)
for track in tracks:
track.medium_total = medium_totals[track.medium]
return AlbumInfo(
album=album_data['name'],
album_id=spotify_id,
artist=artist,
artist_id=artist_id,
tracks=tracks,
albumtype=album_data['album_type'],
va=len(album_data['artists']) == 1
and artist.lower() == 'various artists',
year=year,
month=month,
day=day,
label=album_data['label'],
mediums=max(medium_totals.keys()),
data_source=self.data_source,
data_url=album_data['external_urls']['spotify'],
)
def _get_track(self, track_data):
"""Convert a Spotify track object dict to a TrackInfo object.
:param track_data: Simplified track object
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo
"""
artist, artist_id = self.get_artist(track_data['artists'])
return TrackInfo(
title=track_data['name'],
track_id=track_data['id'],
artist=artist,
artist_id=artist_id,
length=track_data['duration_ms'] / 1000,
index=track_data['track_number'],
medium=track_data['disc_number'],
medium_index=track_data['track_number'],
data_source=self.data_source,
data_url=track_data['external_urls']['spotify'],
)
def track_for_id(self, track_id=None, track_data=None):
"""Fetch a track by its Spotify ID or URL and return a
TrackInfo object or None if the track is not found.
:param track_id: (Optional) Spotify ID or URL for the track. Either
``track_id`` or ``track_data`` must be provided.
:type track_id: str
:param track_data: (Optional) Simplified track object dict. May be
provided instead of ``track_id`` to avoid unnecessary API calls.
:type track_data: dict
:return: TrackInfo object for track
:rtype: beets.autotag.hooks.TrackInfo or None
"""
if track_data is None:
spotify_id = self._get_id('track', track_id)
if spotify_id is None:
return None
track_data = self._handle_response(
requests.get, self.track_url + spotify_id
)
track = self._get_track(track_data)
# Get album's tracks to set `track.index` (position on the entire
# release) and `track.medium_total` (total number of tracks on
# the track's disc).
album_data = self._handle_response(
requests.get, self.album_url + track_data['album']['id']
)
medium_total = 0
for i, track_data in enumerate(album_data['tracks']['items'], start=1):
if track_data['disc_number'] == track.medium:
medium_total += 1
if track_data['id'] == track.track_id:
track.index = i
track.medium_total = medium_total
return track
@staticmethod
def _construct_search_query(filters=None, keywords=''):
"""Construct a query string with the specified filters and keywords to
be provided to the Spotify Search API
(https://developer.spotify.com/documentation/web-api/reference/search/search/#writing-a-query---guidelines).
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:return: Query string to be provided to the Search API.
:rtype: str
"""
query_components = [
keywords,
' '.join(':'.join((k, v)) for k, v in filters.items()),
]
query = ' '.join([q for q in query_components if q])
if not isinstance(query, str):
query = query.decode('utf8')
return unidecode.unidecode(query)
def _search_api(self, query_type, filters=None, keywords=''):
"""Query the Spotify Search API for the specified ``keywords``, applying
the provided ``filters``.
:param query_type: Item type to search across. Valid types are:
'album', 'artist', 'playlist', and 'track'.
:type query_type: str
:param filters: (Optional) Field filters to apply.
:type filters: dict
:param keywords: (Optional) Query keywords to use.
:type keywords: str
:return: JSON data for the class:`Response <Response>` object or None
if no search results are returned.
:rtype: dict or None
"""
query = self._construct_search_query(
keywords=keywords, filters=filters
)
if not query:
return None
self._log.debug(
f"Searching {self.data_source} for '{query}'"
)
response_data = (
self._handle_response(
requests.get,
self.search_url,
params={'q': query, 'type': query_type},
)
.get(query_type + 's', {})
.get('items', [])
)
self._log.debug(
"Found {} result(s) from {} for '{}'",
len(response_data),
self.data_source,
query,
)
return response_data
def commands(self):
def queries(lib, opts, args):
success = self.parse_opts(opts)
success = self._parse_opts(opts)
if success:
results = self.query_spotify(lib, decargs(args))
self.output_results(results)
results = self._match_library_tracks(lib, ui.decargs(args))
self._output_match_results(results)
spotify_cmd = ui.Subcommand(
'spotify',
help=u'build a Spotify playlist'
'spotify', help=f'build a {self.data_source} playlist'
)
spotify_cmd.parser.add_option(
u'-m', u'--mode', action='store',
help=u'"open" to open Spotify with playlist, '
u'"list" to print (default)'
'-m',
'--mode',
action='store',
help='"open" to open {} with playlist, '
'"list" to print (default)'.format(self.data_source),
)
spotify_cmd.parser.add_option(
u'-f', u'--show-failures',
action='store_true', dest='show_failures',
help=u'list tracks that did not match a Spotify ID'
'-f',
'--show-failures',
action='store_true',
dest='show_failures',
help='list tracks that did not match a {} ID'.format(
self.data_source
),
)
spotify_cmd.func = queries
return [spotify_cmd]
def parse_opts(self, opts):
def _parse_opts(self, opts):
if opts.mode:
self.config['mode'].set(opts.mode)
@ -63,35 +381,47 @@ class SpotifyPlugin(BeetsPlugin):
self.config['show_failures'].set(True)
if self.config['mode'].get() not in ['list', 'open']:
self._log.warning(u'{0} is not a valid mode',
self.config['mode'].get())
self._log.warning(
'{0} is not a valid mode', self.config['mode'].get()
)
return False
self.opts = opts
return True
def query_spotify(self, lib, query):
def _match_library_tracks(self, library, keywords):
"""Get a list of simplified track object dicts for library tracks
matching the specified ``keywords``.
:param library: beets library object to query.
:type library: beets.library.Library
:param keywords: Query to match library items against.
:type keywords: str
:return: List of simplified track object dicts for library items
matching the specified query.
:rtype: list[dict]
"""
results = []
failures = []
items = lib.items(query)
items = library.items(keywords)
if not items:
self._log.debug(u'Your beets query returned no items, '
u'skipping spotify')
self._log.debug(
'Your beets query returned no items, skipping {}.',
self.data_source,
)
return
self._log.info(u'Processing {0} tracks...', len(items))
self._log.info('Processing {} tracks...', len(items))
for item in items:
# Apply regex transformations if provided
for regex in self.config['regex'].get():
if (
not regex['field'] or
not regex['search'] or
not regex['replace']
not regex['field']
or not regex['search']
or not regex['replace']
):
continue
@ -103,73 +433,95 @@ class SpotifyPlugin(BeetsPlugin):
# Custom values can be passed in the config (just in case)
artist = item[self.config['artist_field'].get()]
album = item[self.config['album_field'].get()]
query = item[self.config['track_field'].get()]
search_url = query + " album:" + album + " artist:" + artist
keywords = item[self.config['track_field'].get()]
# Query the Web API for each track, look for the items' JSON data
r = requests.get(self.base_url, params={
"q": search_url, "type": "track"
})
self._log.debug('{}', r.url)
try:
r.raise_for_status()
except HTTPError as e:
self._log.debug(u'URL returned a {0} error',
e.response.status_code)
failures.append(search_url)
query_filters = {'artist': artist, 'album': album}
response_data_tracks = self._search_api(
query_type='track', keywords=keywords, filters=query_filters
)
if not response_data_tracks:
query = self._construct_search_query(
keywords=keywords, filters=query_filters
)
failures.append(query)
continue
r_data = r.json()['tracks']['items']
# Apply market filter if requested
region_filter = self.config['region_filter'].get()
if region_filter:
r_data = [x for x in r_data if region_filter
in x['available_markets']]
response_data_tracks = [
track_data
for track_data in response_data_tracks
if region_filter in track_data['available_markets']
]
# Simplest, take the first result
chosen_result = None
if len(r_data) == 1 or self.config['tiebreak'].get() == "first":
self._log.debug(u'Spotify track(s) found, count: {0}',
len(r_data))
chosen_result = r_data[0]
elif len(r_data) > 1:
# Use the popularity filter
self._log.debug(u'Most popular track chosen, count: {0}',
len(r_data))
chosen_result = max(r_data, key=lambda x: x['popularity'])
if chosen_result:
results.append(chosen_result)
if (
len(response_data_tracks) == 1
or self.config['tiebreak'].get() == 'first'
):
self._log.debug(
'{} track(s) found, count: {}',
self.data_source,
len(response_data_tracks),
)
chosen_result = response_data_tracks[0]
else:
self._log.debug(u'No spotify track found: {0}', search_url)
failures.append(search_url)
# Use the popularity filter
self._log.debug(
'Most popular track chosen, count: {}',
len(response_data_tracks),
)
chosen_result = max(
response_data_tracks, key=lambda x: x['popularity']
)
results.append(chosen_result)
failure_count = len(failures)
if failure_count > 0:
if self.config['show_failures'].get():
self._log.info(u'{0} track(s) did not match a Spotify ID:',
failure_count)
self._log.info(
'{} track(s) did not match a {} ID:',
failure_count,
self.data_source,
)
for track in failures:
self._log.info(u'track: {0}', track)
self._log.info(u'')
self._log.info('track: {}', track)
self._log.info('')
else:
self._log.warning(u'{0} track(s) did not match a Spotify ID;\n'
u'use --show-failures to display',
failure_count)
self._log.warning(
'{} track(s) did not match a {} ID:\n'
'use --show-failures to display',
failure_count,
self.data_source,
)
return results
def output_results(self, results):
if results:
ids = [x['id'] for x in results]
if self.config['mode'].get() == "open":
self._log.info(u'Attempting to open Spotify with playlist')
spotify_url = self.playlist_partial + ",".join(ids)
webbrowser.open(spotify_url)
def _output_match_results(self, results):
"""Open a playlist or print Spotify URLs for the provided track
object dicts.
:param results: List of simplified track object dicts
(https://developer.spotify.com/documentation/web-api/reference/object-model/#track-object-simplified)
:type results: list[dict]
"""
if results:
spotify_ids = [track_data['id'] for track_data in results]
if self.config['mode'].get() == 'open':
self._log.info(
'Attempting to open {} with playlist'.format(
self.data_source
)
)
spotify_url = 'spotify:trackset:Playlist:' + ','.join(
spotify_ids
)
webbrowser.open(spotify_url)
else:
for item in ids:
print(self.open_url + item)
for spotify_id in spotify_ids:
print(self.open_track_url + spotify_id)
else:
self._log.warning(u'No Spotify tracks found from beets query')
self._log.warning(
f'No {self.data_source} tracks found from beets query'
)

View file

@ -0,0 +1,171 @@
# This file is part of beets.
# Copyright 2019, Joris Jensen
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
import random
import string
from xml.etree import ElementTree
from hashlib import md5
from urllib.parse import urlencode
import requests
from beets.dbcore import AndQuery
from beets.dbcore.query import MatchQuery
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
__author__ = 'https://github.com/MrNuggelz'
def filter_to_be_removed(items, keys):
if len(items) > len(keys):
dont_remove = []
for artist, album, title in keys:
for item in items:
if artist == item['artist'] and \
album == item['album'] and \
title == item['title']:
dont_remove.append(item)
return [item for item in items if item not in dont_remove]
else:
def to_be_removed(item):
for artist, album, title in keys:
if artist == item['artist'] and\
album == item['album'] and\
title == item['title']:
return False
return True
return [item for item in items if to_be_removed(item)]
class SubsonicPlaylistPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add(
{
'delete': False,
'playlist_ids': [],
'playlist_names': [],
'username': '',
'password': ''
}
)
self.config['password'].redact = True
def update_tags(self, playlist_dict, lib):
with lib.transaction():
for query, playlist_tag in playlist_dict.items():
query = AndQuery([MatchQuery("artist", query[0]),
MatchQuery("album", query[1]),
MatchQuery("title", query[2])])
items = lib.items(query)
if not items:
self._log.warn("{} | track not found ({})", playlist_tag,
query)
continue
for item in items:
item.subsonic_playlist = playlist_tag
item.try_sync(write=True, move=False)
def get_playlist(self, playlist_id):
xml = self.send('getPlaylist', {'id': playlist_id}).text
playlist = ElementTree.fromstring(xml)[0]
if playlist.attrib.get('code', '200') != '200':
alt_error = 'error getting playlist, but no error message found'
self._log.warn(playlist.attrib.get('message', alt_error))
return
name = playlist.attrib.get('name', 'undefined')
tracks = [(t.attrib['artist'], t.attrib['album'], t.attrib['title'])
for t in playlist]
return name, tracks
def commands(self):
def build_playlist(lib, opts, args):
self.config.set_args(opts)
ids = self.config['playlist_ids'].as_str_seq()
if self.config['playlist_names'].as_str_seq():
playlists = ElementTree.fromstring(
self.send('getPlaylists').text)[0]
if playlists.attrib.get('code', '200') != '200':
alt_error = 'error getting playlists,' \
' but no error message found'
self._log.warn(
playlists.attrib.get('message', alt_error))
return
for name in self.config['playlist_names'].as_str_seq():
for playlist in playlists:
if name == playlist.attrib['name']:
ids.append(playlist.attrib['id'])
playlist_dict = self.get_playlists(ids)
# delete old tags
if self.config['delete']:
existing = list(lib.items('subsonic_playlist:";"'))
to_be_removed = filter_to_be_removed(
existing,
playlist_dict.keys())
for item in to_be_removed:
item['subsonic_playlist'] = ''
with lib.transaction():
item.try_sync(write=True, move=False)
self.update_tags(playlist_dict, lib)
subsonicplaylist_cmds = Subcommand(
'subsonicplaylist', help='import a subsonic playlist'
)
subsonicplaylist_cmds.parser.add_option(
'-d',
'--delete',
action='store_true',
help='delete tag from items not in any playlist anymore',
)
subsonicplaylist_cmds.func = build_playlist
return [subsonicplaylist_cmds]
def generate_token(self):
salt = ''.join(random.choices(string.ascii_lowercase + string.digits))
return md5(
(self.config['password'].get() + salt).encode()).hexdigest(), salt
def send(self, endpoint, params=None):
if params is None:
params = {}
a, b = self.generate_token()
params['u'] = self.config['username']
params['t'] = a
params['s'] = b
params['v'] = '1.12.0'
params['c'] = 'beets'
resp = requests.get('{}/rest/{}?{}'.format(
self.config['base_url'].get(),
endpoint,
urlencode(params))
)
return resp
def get_playlists(self, ids):
output = {}
for playlist_id in ids:
name, tracks = self.get_playlist(playlist_id)
for track in tracks:
if track not in output:
output[track] = ';'
output[track] += name + ';'
return output

View file

@ -0,0 +1,144 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Updates Subsonic library on Beets import
Your Beets configuration file should contain
a "subsonic" section like the following:
subsonic:
url: https://mydomain.com:443/subsonic
user: username
pass: password
auth: token
For older Subsonic versions, token authentication
is not supported, use password instead:
subsonic:
url: https://mydomain.com:443/subsonic
user: username
pass: password
auth: pass
"""
import hashlib
import random
import string
import requests
from binascii import hexlify
from beets import config
from beets.plugins import BeetsPlugin
__author__ = 'https://github.com/maffo999'
class SubsonicUpdate(BeetsPlugin):
def __init__(self):
super().__init__()
# Set default configuration values
config['subsonic'].add({
'user': 'admin',
'pass': 'admin',
'url': 'http://localhost:4040',
'auth': 'token',
})
config['subsonic']['pass'].redact = True
self.register_listener('import', self.start_scan)
@staticmethod
def __create_token():
"""Create salt and token from given password.
:return: The generated salt and hashed token
"""
password = config['subsonic']['pass'].as_str()
# Pick the random sequence and salt the password
r = string.ascii_letters + string.digits
salt = "".join([random.choice(r) for _ in range(6)])
salted_password = password + salt
token = hashlib.md5(salted_password.encode('utf-8')).hexdigest()
# Put together the payload of the request to the server and the URL
return salt, token
@staticmethod
def __format_url(endpoint):
"""Get the Subsonic URL to trigger the given endpoint.
Uses either the url config option or the deprecated host, port,
and context_path config options together.
:return: Endpoint for updating Subsonic
"""
url = config['subsonic']['url'].as_str()
if url and url.endswith('/'):
url = url[:-1]
# @deprecated("Use url config option instead")
if not url:
host = config['subsonic']['host'].as_str()
port = config['subsonic']['port'].get(int)
context_path = config['subsonic']['contextpath'].as_str()
if context_path == '/':
context_path = ''
url = f"http://{host}:{port}{context_path}"
return url + f'/rest/{endpoint}'
def start_scan(self):
user = config['subsonic']['user'].as_str()
auth = config['subsonic']['auth'].as_str()
url = self.__format_url("startScan")
self._log.debug('URL is {0}', url)
self._log.debug('auth type is {0}', config['subsonic']['auth'])
if auth == "token":
salt, token = self.__create_token()
payload = {
'u': user,
't': token,
's': salt,
'v': '1.13.0', # Subsonic 5.3 and newer
'c': 'beets',
'f': 'json'
}
elif auth == "password":
password = config['subsonic']['pass'].as_str()
encpass = hexlify(password.encode()).decode()
payload = {
'u': user,
'p': f'enc:{encpass}',
'v': '1.12.0',
'c': 'beets',
'f': 'json'
}
else:
return
try:
response = requests.get(url, params=payload)
json = response.json()
if response.status_code == 200 and \
json['subsonic-response']['status'] == "ok":
count = json['subsonic-response']['scanStatus']['count']
self._log.info(
f'Updating Subsonic; scanning {count} tracks')
elif response.status_code == 200 and \
json['subsonic-response']['status'] == "failed":
error_message = json['subsonic-response']['error']['message']
self._log.error(f'Error: {error_message}')
else:
self._log.error('Error: {0}', json)
except Exception as error:
self._log.error(f'Error: {error}')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
@ -15,7 +14,6 @@
"""Moves patterns in path formats (suitable for moving articles)."""
from __future__ import division, absolute_import, print_function
import re
from beets.plugins import BeetsPlugin
@ -23,9 +21,9 @@ from beets.plugins import BeetsPlugin
__author__ = 'baobab@heresiarch.info'
__version__ = '1.1'
PATTERN_THE = u'^[the]{3}\s'
PATTERN_A = u'^[a][n]?\s'
FORMAT = u'{0}, {1}'
PATTERN_THE = '^the\\s'
PATTERN_A = '^[a][n]?\\s'
FORMAT = '{0}, {1}'
class ThePlugin(BeetsPlugin):
@ -33,14 +31,14 @@ class ThePlugin(BeetsPlugin):
patterns = []
def __init__(self):
super(ThePlugin, self).__init__()
super().__init__()
self.template_funcs['the'] = self.the_template_func
self.config.add({
'the': True,
'a': True,
'format': u'{0}, {1}',
'format': '{0}, {1}',
'strip': False,
'patterns': [],
})
@ -51,17 +49,17 @@ class ThePlugin(BeetsPlugin):
try:
re.compile(p)
except re.error:
self._log.error(u'invalid pattern: {0}', p)
self._log.error('invalid pattern: {0}', p)
else:
if not (p.startswith('^') or p.endswith('$')):
self._log.warning(u'warning: \"{0}\" will not '
u'match string start/end', p)
self._log.warning('warning: \"{0}\" will not '
'match string start/end', p)
if self.config['a']:
self.patterns = [PATTERN_A] + self.patterns
if self.config['the']:
self.patterns = [PATTERN_THE] + self.patterns
if not self.patterns:
self._log.warning(u'no patterns defined!')
self._log.warning('no patterns defined!')
def unthe(self, text, pattern):
"""Moves pattern in the path format string or strips it
@ -84,7 +82,7 @@ class ThePlugin(BeetsPlugin):
fmt = self.config['format'].as_str()
return fmt.format(r, t.strip()).strip()
else:
return u''
return ''
def the_template_func(self, text):
if not self.patterns:
@ -93,8 +91,8 @@ class ThePlugin(BeetsPlugin):
for p in self.patterns:
r = self.unthe(text, p)
if r != text:
self._log.debug('\"{0}\" -> \"{1}\"', text, r)
break
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
return r
else:
return u''
return ''

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Bruno Cauet
#
@ -19,7 +18,6 @@ This plugin is POSIX-only.
Spec: standards.freedesktop.org/thumbnail-spec/latest/index.html
"""
from __future__ import division, absolute_import, print_function
from hashlib import md5
import os
@ -35,7 +33,6 @@ from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, decargs
from beets import util
from beets.util.artresizer import ArtResizer, get_im_version, get_pil_version
import six
BASE_DIR = os.path.join(BaseDirectory.xdg_cache_home, "thumbnails")
@ -45,7 +42,7 @@ LARGE_DIR = util.bytestring_path(os.path.join(BASE_DIR, "large"))
class ThumbnailsPlugin(BeetsPlugin):
def __init__(self):
super(ThumbnailsPlugin, self).__init__()
super().__init__()
self.config.add({
'auto': True,
'force': False,
@ -58,15 +55,15 @@ class ThumbnailsPlugin(BeetsPlugin):
def commands(self):
thumbnails_command = Subcommand("thumbnails",
help=u"Create album thumbnails")
help="Create album thumbnails")
thumbnails_command.parser.add_option(
u'-f', u'--force',
'-f', '--force',
dest='force', action='store_true', default=False,
help=u'force regeneration of thumbnails deemed fine (existing & '
u'recent enough)')
help='force regeneration of thumbnails deemed fine (existing & '
'recent enough)')
thumbnails_command.parser.add_option(
u'--dolphin', dest='dolphin', action='store_true', default=False,
help=u"create Dolphin-compatible thumbnail information (for KDE)")
'--dolphin', dest='dolphin', action='store_true', default=False,
help="create Dolphin-compatible thumbnail information (for KDE)")
thumbnails_command.func = self.process_query
return [thumbnails_command]
@ -85,8 +82,8 @@ class ThumbnailsPlugin(BeetsPlugin):
- detect whether we'll use GIO or Python to get URIs
"""
if not ArtResizer.shared.local:
self._log.warning(u"No local image resizing capabilities, "
u"cannot generate thumbnails")
self._log.warning("No local image resizing capabilities, "
"cannot generate thumbnails")
return False
for dir in (NORMAL_DIR, LARGE_DIR):
@ -100,12 +97,12 @@ class ThumbnailsPlugin(BeetsPlugin):
assert get_pil_version() # since we're local
self.write_metadata = write_metadata_pil
tool = "PIL"
self._log.debug(u"using {0} to write metadata", tool)
self._log.debug("using {0} to write metadata", tool)
uri_getter = GioURI()
if not uri_getter.available:
uri_getter = PathlibURI()
self._log.debug(u"using {0.name} to compute URIs", uri_getter)
self._log.debug("using {0.name} to compute URIs", uri_getter)
self.get_uri = uri_getter.uri
return True
@ -113,9 +110,9 @@ class ThumbnailsPlugin(BeetsPlugin):
def process_album(self, album):
"""Produce thumbnails for the album folder.
"""
self._log.debug(u'generating thumbnail for {0}', album)
self._log.debug('generating thumbnail for {0}', album)
if not album.artpath:
self._log.info(u'album {0} has no art', album)
self._log.info('album {0} has no art', album)
return
if self.config['dolphin']:
@ -123,7 +120,7 @@ class ThumbnailsPlugin(BeetsPlugin):
size = ArtResizer.shared.get_size(album.artpath)
if not size:
self._log.warning(u'problem getting the picture size for {0}',
self._log.warning('problem getting the picture size for {0}',
album.artpath)
return
@ -133,9 +130,9 @@ class ThumbnailsPlugin(BeetsPlugin):
wrote &= self.make_cover_thumbnail(album, 128, NORMAL_DIR)
if wrote:
self._log.info(u'wrote thumbnail for {0}', album)
self._log.info('wrote thumbnail for {0}', album)
else:
self._log.info(u'nothing to do for {0}', album)
self._log.info('nothing to do for {0}', album)
def make_cover_thumbnail(self, album, size, target_dir):
"""Make a thumbnail of given size for `album` and put it in
@ -146,11 +143,11 @@ class ThumbnailsPlugin(BeetsPlugin):
if os.path.exists(target) and \
os.stat(target).st_mtime > os.stat(album.artpath).st_mtime:
if self.config['force']:
self._log.debug(u"found a suitable {1}x{1} thumbnail for {0}, "
u"forcing regeneration", album, size)
self._log.debug("found a suitable {1}x{1} thumbnail for {0}, "
"forcing regeneration", album, size)
else:
self._log.debug(u"{1}x{1} thumbnail for {0} exists and is "
u"recent enough", album, size)
self._log.debug("{1}x{1} thumbnail for {0} exists and is "
"recent enough", album, size)
return False
resized = ArtResizer.shared.resize(size, album.artpath,
util.syspath(target))
@ -160,23 +157,23 @@ class ThumbnailsPlugin(BeetsPlugin):
def thumbnail_file_name(self, path):
"""Compute the thumbnail file name
See http://standards.freedesktop.org/thumbnail-spec/latest/x227.html
See https://standards.freedesktop.org/thumbnail-spec/latest/x227.html
"""
uri = self.get_uri(path)
hash = md5(uri.encode('utf-8')).hexdigest()
return util.bytestring_path("{0}.png".format(hash))
return util.bytestring_path(f"{hash}.png")
def add_tags(self, album, image_path):
"""Write required metadata to the thumbnail
See http://standards.freedesktop.org/thumbnail-spec/latest/x142.html
See https://standards.freedesktop.org/thumbnail-spec/latest/x142.html
"""
mtime = os.stat(album.artpath).st_mtime
metadata = {"Thumb::URI": self.get_uri(album.artpath),
"Thumb::MTime": six.text_type(mtime)}
"Thumb::MTime": str(mtime)}
try:
self.write_metadata(image_path, metadata)
except Exception:
self._log.exception(u"could not write metadata to {0}",
self._log.exception("could not write metadata to {0}",
util.displayable_path(image_path))
def make_dolphin_cover_thumbnail(self, album):
@ -186,9 +183,9 @@ class ThumbnailsPlugin(BeetsPlugin):
artfile = os.path.split(album.artpath)[1]
with open(outfilename, 'w') as f:
f.write('[Desktop Entry]\n')
f.write('Icon=./{0}'.format(artfile.decode('utf-8')))
f.write('Icon=./{}'.format(artfile.decode('utf-8')))
f.close()
self._log.debug(u"Wrote file {0}", util.displayable_path(outfilename))
self._log.debug("Wrote file {0}", util.displayable_path(outfilename))
def write_metadata_im(file, metadata):
@ -211,7 +208,7 @@ def write_metadata_pil(file, metadata):
return True
class URIGetter(object):
class URIGetter:
available = False
name = "Abstract base"
@ -224,7 +221,7 @@ class PathlibURI(URIGetter):
name = "Python Pathlib"
def uri(self, path):
return PurePosixPath(path).as_uri()
return PurePosixPath(util.py3_path(path)).as_uri()
def copy_c_string(c_string):
@ -269,7 +266,7 @@ class GioURI(URIGetter):
def uri(self, path):
g_file_ptr = self.libgio.g_file_new_for_path(path)
if not g_file_ptr:
raise RuntimeError(u"No gfile pointer received for {0}".format(
raise RuntimeError("No gfile pointer received for {}".format(
util.displayable_path(path)))
try:
@ -278,8 +275,8 @@ class GioURI(URIGetter):
self.libgio.g_object_unref(g_file_ptr)
if not uri_ptr:
self.libgio.g_free(uri_ptr)
raise RuntimeError(u"No URI received from the gfile pointer for "
u"{0}".format(util.displayable_path(path)))
raise RuntimeError("No URI received from the gfile pointer for "
"{}".format(util.displayable_path(path)))
try:
uri = copy_c_string(uri_ptr)
@ -290,5 +287,5 @@ class GioURI(URIGetter):
return uri.decode(util._fsencoding())
except UnicodeDecodeError:
raise RuntimeError(
"Could not decode filename from GIO: {!r}".format(uri)
f"Could not decode filename from GIO: {uri!r}"
)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Thomas Scholtes.
#
@ -13,11 +12,10 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.dbcore import types
from beets.util.confit import ConfigValueError
from confuse import ConfigValueError
from beets import library
@ -47,6 +45,6 @@ class TypesPlugin(BeetsPlugin):
mytypes[key] = library.DateType()
else:
raise ConfigValueError(
u"unknown type '{0}' for the '{1}' field"
"unknown type '{}' for the '{}' field"
.format(value, key))
return mytypes

View file

@ -0,0 +1,68 @@
# This file is part of beets.
# Copyright 2019, Joris Jensen
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""
List all files in the library folder which are not listed in the
beets library database, including art files
"""
import os
from beets import util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, print_
__author__ = 'https://github.com/MrNuggelz'
class Unimported(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add(
{
'ignore_extensions': []
}
)
def commands(self):
def print_unimported(lib, opts, args):
ignore_exts = [
('.' + x).encode()
for x in self.config["ignore_extensions"].as_str_seq()
]
ignore_dirs = [
os.path.join(lib.directory, x.encode())
for x in self.config["ignore_subdirectories"].as_str_seq()
]
in_folder = {
os.path.join(r, file)
for r, d, f in os.walk(lib.directory)
for file in f
if not any(
[file.endswith(ext) for ext in ignore_exts]
+ [r in ignore_dirs]
)
}
in_library = {x.path for x in lib.items()}
art_files = {x.artpath for x in lib.albums()}
for f in in_folder - in_library - art_files:
print_(util.displayable_path(f))
unimported = Subcommand(
'unimported',
help='list all files in the library folder which are not listed'
' in the beets library database')
unimported.func = print_unimported
return [unimported]

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@ -14,14 +13,13 @@
# included in all copies or substantial portions of the Software.
"""A Web interface to beets."""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets import ui
from beets import util
import beets.library
import flask
from flask import g
from flask import g, jsonify
from werkzeug.routing import BaseConverter, PathConverter
import os
from unidecode import unidecode
@ -59,7 +57,10 @@ def _rep(obj, expand=False):
return out
elif isinstance(obj, beets.library.Album):
del out['artpath']
if app.config.get('INCLUDE_PATHS', False):
out['artpath'] = util.displayable_path(out['artpath'])
else:
del out['artpath']
if expand:
out['items'] = [_rep(item) for item in obj.items()]
return out
@ -91,7 +92,20 @@ def is_expand():
return flask.request.args.get('expand') is not None
def resource(name):
def is_delete():
"""Returns whether the current delete request should remove the selected
files.
"""
return flask.request.args.get('delete') is not None
def get_method():
"""Returns the HTTP method of the current request."""
return flask.request.method
def resource(name, patchable=False):
"""Decorates a function to handle RESTful HTTP requests for a resource.
"""
def make_responder(retriever):
@ -99,34 +113,98 @@ def resource(name):
entities = [retriever(id) for id in ids]
entities = [entity for entity in entities if entity]
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
if get_method() == "DELETE":
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.update(flask.request.get_json())
entity.try_sync(True, False) # write, don't move
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
elif get_method() == "GET":
if len(entities) == 1:
return flask.jsonify(_rep(entities[0], expand=is_expand()))
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
else:
return flask.abort(404)
else:
return flask.abort(404)
responder.__name__ = 'get_{0}'.format(name)
return flask.abort(405)
responder.__name__ = f'get_{name}'
return responder
return make_responder
def resource_query(name):
def resource_query(name, patchable=False):
"""Decorates a function to handle RESTful HTTP queries for resources.
"""
def make_responder(query_func):
def responder(queries):
return app.response_class(
json_generator(
query_func(queries),
root='results', expand=is_expand()
),
mimetype='application/json'
)
responder.__name__ = 'query_{0}'.format(name)
entities = query_func(queries)
if get_method() == "DELETE":
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
return flask.abort(405)
for entity in entities:
entity.update(flask.request.get_json())
entity.try_sync(True, False) # write, don't move
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
)
elif get_method() == "GET":
return app.response_class(
json_generator(
entities,
root='results', expand=is_expand()
),
mimetype='application/json'
)
else:
return flask.abort(405)
responder.__name__ = f'query_{name}'
return responder
return make_responder
@ -140,7 +218,7 @@ def resource_list(name):
json_generator(list_all(), root=name, expand=is_expand()),
mimetype='application/json'
)
responder.__name__ = 'all_{0}'.format(name)
responder.__name__ = f'all_{name}'
return responder
return make_responder
@ -150,7 +228,7 @@ def _get_unique_table_field_values(model, field, sort_field):
if field not in model.all_keys() or sort_field not in model.all_keys():
raise KeyError
with g.lib.transaction() as tx:
rows = tx.query('SELECT DISTINCT "{0}" FROM "{1}" ORDER BY "{2}"'
rows = tx.query('SELECT DISTINCT "{}" FROM "{}" ORDER BY "{}"'
.format(field, model._table, sort_field))
return [row[0] for row in rows]
@ -169,7 +247,7 @@ class IdListConverter(BaseConverter):
return ids
def to_url(self, value):
return ','.join(value)
return ','.join(str(v) for v in value)
class QueryConverter(PathConverter):
@ -177,10 +255,13 @@ class QueryConverter(PathConverter):
"""
def to_python(self, value):
return value.split('/')
queries = value.split('/')
"""Do not do path substitution on regex value tests"""
return [query if '::' in query else query.replace('\\', os.sep)
for query in queries]
def to_url(self, value):
return ','.join(value)
return ','.join([v.replace(os.sep, '\\') for v in value])
class EverythingConverter(PathConverter):
@ -202,8 +283,8 @@ def before_request():
# Items.
@app.route('/item/<idlist:ids>')
@resource('items')
@app.route('/item/<idlist:ids>', methods=["GET", "DELETE", "PATCH"])
@resource('items', patchable=True)
def get_item(id):
return g.lib.get_item(id)
@ -249,8 +330,8 @@ def item_file(item_id):
return response
@app.route('/item/query/<query:queries>')
@resource_query('items')
@app.route('/item/query/<query:queries>', methods=["GET", "DELETE", "PATCH"])
@resource_query('items', patchable=True)
def item_query(queries):
return g.lib.items(queries)
@ -278,7 +359,7 @@ def item_unique_field_values(key):
# Albums.
@app.route('/album/<idlist:ids>')
@app.route('/album/<idlist:ids>', methods=["GET", "DELETE"])
@resource('albums')
def get_album(id):
return g.lib.get_album(id)
@ -291,7 +372,7 @@ def all_albums():
return g.lib.albums()
@app.route('/album/query/<query:queries>')
@app.route('/album/query/<query:queries>', methods=["GET", "DELETE"])
@resource_query('albums')
def album_query(queries):
return g.lib.albums(queries)
@ -351,20 +432,21 @@ def home():
class WebPlugin(BeetsPlugin):
def __init__(self):
super(WebPlugin, self).__init__()
super().__init__()
self.config.add({
'host': u'127.0.0.1',
'host': '127.0.0.1',
'port': 8337,
'cors': '',
'cors_supports_credentials': False,
'reverse_proxy': False,
'include_paths': False,
'readonly': True,
})
def commands(self):
cmd = ui.Subcommand('web', help=u'start a Web interface')
cmd.parser.add_option(u'-d', u'--debug', action='store_true',
default=False, help=u'debug mode')
cmd = ui.Subcommand('web', help='start a Web interface')
cmd.parser.add_option('-d', '--debug', action='store_true',
default=False, help='debug mode')
def func(lib, opts, args):
args = ui.decargs(args)
@ -378,12 +460,13 @@ class WebPlugin(BeetsPlugin):
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
app.config['INCLUDE_PATHS'] = self.config['include_paths']
app.config['READONLY'] = self.config['readonly']
# Enable CORS if required.
if self.config['cors']:
self._log.info(u'Enabling CORS with origin: {0}',
self._log.info('Enabling CORS with origin: {0}',
self.config['cors'])
from flask.ext.cors import CORS
from flask_cors import CORS
app.config['CORS_ALLOW_HEADERS'] = "Content-Type"
app.config['CORS_RESOURCES'] = {
r"/*": {"origins": self.config['cors'].get(str)}
@ -407,7 +490,7 @@ class WebPlugin(BeetsPlugin):
return [cmd]
class ReverseProxied(object):
class ReverseProxied:
'''Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind
this to a URL other than / and to an HTTP scheme that is

View file

@ -129,7 +129,7 @@ $.fn.player = function(debug) {
// Simple selection disable for jQuery.
// Cut-and-paste from:
// http://stackoverflow.com/questions/2700000
// https://stackoverflow.com/questions/2700000
$.fn.disableSelection = function() {
$(this).attr('unselectable', 'on')
.css('-moz-user-select', 'none')

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
#
@ -15,23 +14,21 @@
""" Clears tag fields in media files."""
from __future__ import division, absolute_import, print_function
import six
import re
from beets.plugins import BeetsPlugin
from beets.mediafile import MediaFile
from mediafile import MediaFile
from beets.importer import action
from beets.ui import Subcommand, decargs, input_yn
from beets.util import confit
import confuse
__author__ = 'baobab@heresiarch.info'
class ZeroPlugin(BeetsPlugin):
def __init__(self):
super(ZeroPlugin, self).__init__()
super().__init__()
self.register_listener('write', self.write_event)
self.register_listener('import_task_choice',
@ -56,7 +53,7 @@ class ZeroPlugin(BeetsPlugin):
"""
if self.config['fields'] and self.config['keep_fields']:
self._log.warning(
u'cannot blacklist and whitelist at the same time'
'cannot blacklist and whitelist at the same time'
)
# Blacklist mode.
elif self.config['fields']:
@ -75,7 +72,7 @@ class ZeroPlugin(BeetsPlugin):
def zero_fields(lib, opts, args):
if not decargs(args) and not input_yn(
u"Remove fields for all items? (Y/n)",
"Remove fields for all items? (Y/n)",
True):
return
for item in lib.items(decargs(args)):
@ -89,22 +86,22 @@ class ZeroPlugin(BeetsPlugin):
Do some sanity checks then compile the regexes.
"""
if field not in MediaFile.fields():
self._log.error(u'invalid field: {0}', field)
self._log.error('invalid field: {0}', field)
elif field in ('id', 'path', 'album_id'):
self._log.warning(u'field \'{0}\' ignored, zeroing '
u'it would be dangerous', field)
self._log.warning('field \'{0}\' ignored, zeroing '
'it would be dangerous', field)
else:
try:
for pattern in self.config[field].as_str_seq():
prog = re.compile(pattern, re.IGNORECASE)
self.fields_to_progs.setdefault(field, []).append(prog)
except confit.NotFoundError:
except confuse.NotFoundError:
# Matches everything
self.fields_to_progs[field] = []
def import_task_choice_event(self, session, task):
if task.choice_flag == action.ASIS and not self.warned:
self._log.warning(u'cannot zero in \"as-is\" mode')
self._log.warning('cannot zero in \"as-is\" mode')
self.warned = True
# TODO request write in as-is mode
@ -122,7 +119,7 @@ class ZeroPlugin(BeetsPlugin):
fields_set = False
if not self.fields_to_progs:
self._log.warning(u'no fields, nothing to do')
self._log.warning('no fields, nothing to do')
return False
for field, progs in self.fields_to_progs.items():
@ -135,7 +132,7 @@ class ZeroPlugin(BeetsPlugin):
if match:
fields_set = True
self._log.debug(u'{0}: {1} -> None', field, value)
self._log.debug('{0}: {1} -> None', field, value)
tags[field] = None
if self.config['update_database']:
item[field] = None
@ -158,6 +155,6 @@ def _match_progs(value, progs):
if not progs:
return True
for prog in progs:
if prog.search(six.text_type(value)):
if prog.search(str(value)):
return True
return False