mirror of
https://github.com/beetbox/beets.git
synced 2026-02-08 00:14:31 +01:00
Merge remote-tracking branch 'upstream/master' into embedart_url
This commit is contained in:
commit
23b42e80e2
21 changed files with 458 additions and 12 deletions
|
|
@ -80,7 +80,7 @@ shockingly simple if you know a little Python.
|
|||
Install
|
||||
-------
|
||||
|
||||
You can install beets by typing ``pip install beets``.
|
||||
You can install beets by typing ``pip install beets`` or directly from Github (see details [here](https://beets.readthedocs.io/en/latest/faq.html#run-the-latest-source-version-of-beets)).
|
||||
Beets has also been packaged in the `software repositories`_ of several
|
||||
distributions. Check out the `Getting Started`_ guide for more information.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ from beets import util
|
|||
from beets import config
|
||||
from collections import Counter
|
||||
from urllib.parse import urljoin
|
||||
from beets.util.id_extractors import extract_discogs_id_regex, \
|
||||
spotify_id_regex, deezer_id_regex, beatport_id_regex
|
||||
from beets.plugins import MetadataSourcePlugin
|
||||
|
||||
VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
|
||||
|
||||
|
|
@ -70,7 +73,7 @@ log = logging.getLogger('beets')
|
|||
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
|
||||
'labels', 'artist-credits', 'aliases',
|
||||
'recording-level-rels', 'work-rels',
|
||||
'work-level-rels', 'artist-rels', 'isrcs']
|
||||
'work-level-rels', 'artist-rels', 'isrcs', 'url-rels']
|
||||
BROWSE_INCLUDES = ['artist-credits', 'work-rels',
|
||||
'artist-rels', 'recording-rels', 'release-rels']
|
||||
if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']:
|
||||
|
|
@ -511,6 +514,56 @@ def album_info(release: Dict) -> beets.autotag.hooks.AlbumInfo:
|
|||
in sorted(genres.items(), key=lambda g: -g[1])
|
||||
)
|
||||
|
||||
# We might find links to external sources (Discogs, Bandcamp, ...)
|
||||
if (any(config['musicbrainz']['external_ids'].get().values())
|
||||
and release.get('url-relation-list')):
|
||||
discogs_url, bandcamp_url, spotify_url = None, None, None
|
||||
deezer_url, beatport_url = None, None
|
||||
fetch_discogs, fetch_bandcamp, fetch_spotify = False, False, False
|
||||
fetch_deezer, fetch_beatport = False, False
|
||||
|
||||
if config['musicbrainz']['external_ids']['discogs'].get():
|
||||
fetch_discogs = True
|
||||
if config['musicbrainz']['external_ids']['bandcamp'].get():
|
||||
fetch_bandcamp = True
|
||||
if config['musicbrainz']['external_ids']['spotify'].get():
|
||||
fetch_spotify = True
|
||||
if config['musicbrainz']['external_ids']['deezer'].get():
|
||||
fetch_deezer = True
|
||||
if config['musicbrainz']['external_ids']['beatport'].get():
|
||||
fetch_beatport = True
|
||||
|
||||
for url in release['url-relation-list']:
|
||||
if fetch_discogs and url['type'] == 'discogs':
|
||||
log.debug('Found link to Discogs release via MusicBrainz')
|
||||
discogs_url = url['target']
|
||||
if fetch_bandcamp and 'bandcamp.com' in url['target']:
|
||||
log.debug('Found link to Bandcamp release via MusicBrainz')
|
||||
bandcamp_url = url['target']
|
||||
if fetch_spotify and 'spotify.com' in url['target']:
|
||||
log.debug('Found link to Spotify album via MusicBrainz')
|
||||
spotify_url = url['target']
|
||||
if fetch_deezer and 'deezer.com' in url['target']:
|
||||
log.debug('Found link to Deezer album via MusicBrainz')
|
||||
deezer_url = url['target']
|
||||
if fetch_beatport and 'beatport.com' in url['target']:
|
||||
log.debug('Found link to Beatport release via MusicBrainz')
|
||||
beatport_url = url['target']
|
||||
|
||||
if discogs_url:
|
||||
info.discogs_albumid = extract_discogs_id_regex(discogs_url)
|
||||
if bandcamp_url:
|
||||
info.bandcamp_album_id = bandcamp_url
|
||||
if spotify_url:
|
||||
info.spotify_album_id = MetadataSourcePlugin._get_id(
|
||||
'album', spotify_url, spotify_id_regex)
|
||||
if deezer_url:
|
||||
info.deezer_album_id = MetadataSourcePlugin._get_id(
|
||||
'album', deezer_url, deezer_id_regex)
|
||||
if beatport_url:
|
||||
info.beatport_album_id = MetadataSourcePlugin._get_id(
|
||||
'album', beatport_url, beatport_id_regex)
|
||||
|
||||
extra_albumdatas = plugins.send('mb_album_extract', data=release)
|
||||
for extra_albumdata in extra_albumdatas:
|
||||
info.update(extra_albumdata)
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ musicbrainz:
|
|||
searchlimit: 5
|
||||
extra_tags: []
|
||||
genres: no
|
||||
external_ids:
|
||||
discogs: no
|
||||
bandcamp: no
|
||||
spotify: no
|
||||
deezer: no
|
||||
beatport: no
|
||||
|
||||
match:
|
||||
strong_rec_thresh: 0.04
|
||||
|
|
|
|||
|
|
@ -839,6 +839,19 @@ class ImportTask(BaseImportTask):
|
|||
dup_item.id,
|
||||
displayable_path(item.path)
|
||||
)
|
||||
# We exclude certain flexible attributes from the preserving
|
||||
# process since they might have been fetched from MusicBrainz
|
||||
# and been set in beets.autotag.apply_metadata().
|
||||
# discogs_albumid might also have been set but is not a
|
||||
# flexible attribute, thus no exclude is required.
|
||||
if item.get('bandcamp_album_id'):
|
||||
dup_item.bandcamp_album_id = item.bandcamp_album_id
|
||||
if item.get('spotify_album_id'):
|
||||
dup_item.spotify_album_id = item.spotify_album_id
|
||||
if item.get('deezer_album_id'):
|
||||
dup_item.deezer_album_id = item.deezer_album_id
|
||||
if item.get('beatport_album_id'):
|
||||
dup_item.beatport_album_id = item.beatport_album_id
|
||||
item.update(dup_item._values_flex)
|
||||
log.debug(
|
||||
'Reimported item flexible attributes {0} '
|
||||
|
|
@ -1109,6 +1122,19 @@ class ArchiveImportTask(SentinelImportTask):
|
|||
archive = handler_class(util.py3_path(self.toppath), mode='r')
|
||||
try:
|
||||
archive.extractall(extract_to)
|
||||
|
||||
# Adjust the files' mtimes to match the information from the
|
||||
# archive. Inspired by: https://stackoverflow.com/q/9813243
|
||||
for f in archive.infolist():
|
||||
# The date_time will need to adjusted otherwise
|
||||
# the item will have the current date_time of extraction.
|
||||
# The (0, 0, -1) is added to date_time because the
|
||||
# function time.mktime expects a 9-element tuple.
|
||||
# The -1 indicates that the DST flag is unknown.
|
||||
date_time = time.mktime(f.date_time + (0, 0, -1))
|
||||
fullpath = os.path.join(extract_to, f.filename)
|
||||
os.utime(fullpath, (date_time, date_time))
|
||||
|
||||
finally:
|
||||
archive.close()
|
||||
self.extracted = True
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ def import_files(lib, paths, query):
|
|||
if config['import']['log'].get() is not None:
|
||||
logpath = syspath(config['import']['log'].as_filename())
|
||||
try:
|
||||
loghandler = logging.FileHandler(logpath)
|
||||
loghandler = logging.FileHandler(logpath, encoding='utf-8')
|
||||
except OSError:
|
||||
raise ui.UserError("could not open log file for writing: "
|
||||
"{}".format(displayable_path(logpath)))
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ beatport_id_regex = {
|
|||
|
||||
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
|
||||
# the URL can be used as the identifier. The Bandcamp metadata source plugin
|
||||
# works that way - https://github.com/unrblt/beets-bandcamp. Bandcamp album
|
||||
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
|
||||
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
|
||||
|
||||
|
||||
|
|
|
|||
93
beets/util/m3u.py
Normal file
93
beets/util/m3u.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2022, J0J0 Todos.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Provides utilities to read, write and manipulate m3u playlist files."""
|
||||
|
||||
import traceback
|
||||
|
||||
from beets.util import syspath, normpath, mkdirall, FilesystemError
|
||||
|
||||
|
||||
class EmptyPlaylistError(Exception):
|
||||
"""Raised when a playlist file without media files is saved or loaded."""
|
||||
pass
|
||||
|
||||
|
||||
class M3UFile():
|
||||
"""Reads and writes m3u or m3u8 playlist files."""
|
||||
def __init__(self, path):
|
||||
"""``path`` is the absolute path to the playlist file.
|
||||
|
||||
The playlist file type, m3u or m3u8 is determined by 1) the ending
|
||||
being m3u8 and 2) the file paths contained in the list being utf-8
|
||||
encoded. Since the list is passed from the outside, this is currently
|
||||
out of control of this class.
|
||||
"""
|
||||
self.path = path
|
||||
self.extm3u = False
|
||||
self.media_list = []
|
||||
|
||||
def load(self):
|
||||
"""Reads the m3u file from disk and sets the object's attributes."""
|
||||
pl_normpath = normpath(self.path)
|
||||
try:
|
||||
with open(syspath(pl_normpath), "rb") as pl_file:
|
||||
raw_contents = pl_file.readlines()
|
||||
except OSError as exc:
|
||||
raise FilesystemError(exc, 'read', (pl_normpath, ),
|
||||
traceback.format_exc())
|
||||
|
||||
self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False
|
||||
for line in raw_contents[1:]:
|
||||
if line.startswith(b"#"):
|
||||
# Support for specific EXTM3U comments could be added here.
|
||||
continue
|
||||
self.media_list.append(normpath(line.rstrip()))
|
||||
if not self.media_list:
|
||||
raise EmptyPlaylistError
|
||||
|
||||
def set_contents(self, media_list, extm3u=True):
|
||||
"""Sets self.media_list to a list of media file paths.
|
||||
|
||||
Also sets additional flags, changing the final m3u-file's format.
|
||||
|
||||
``media_list`` is a list of paths to media files that should be added
|
||||
to the playlist (relative or absolute paths, that's the responsibility
|
||||
of the caller). By default the ``extm3u`` flag is set, to ensure a
|
||||
save-operation writes an m3u-extended playlist (comment "#EXTM3U" at
|
||||
the top of the file).
|
||||
"""
|
||||
self.media_list = media_list
|
||||
self.extm3u = extm3u
|
||||
|
||||
def write(self):
|
||||
"""Writes the m3u file to disk.
|
||||
|
||||
Handles the creation of potential parent directories.
|
||||
"""
|
||||
header = [b"#EXTM3U"] if self.extm3u else []
|
||||
if not self.media_list:
|
||||
raise EmptyPlaylistError
|
||||
contents = header + self.media_list
|
||||
pl_normpath = normpath(self.path)
|
||||
mkdirall(pl_normpath)
|
||||
|
||||
try:
|
||||
with open(syspath(pl_normpath), "wb") as pl_file:
|
||||
for line in contents:
|
||||
pl_file.write(line + b'\n')
|
||||
pl_file.write(b'\n') # Final linefeed to prevent noeol file.
|
||||
except OSError as exc:
|
||||
raise FilesystemError(exc, 'create', (pl_normpath, ),
|
||||
traceback.format_exc())
|
||||
|
|
@ -432,6 +432,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
tracks = [self._get_track_info(x) for x in release.tracks]
|
||||
|
||||
return AlbumInfo(album=release.name, album_id=release.beatport_id,
|
||||
beatport_album_id=release.beatport_id,
|
||||
artist=artist, artist_id=artist_id, tracks=tracks,
|
||||
albumtype=release.category, va=va,
|
||||
year=release.release_date.year,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from beets import art
|
|||
from beets.util.artresizer import ArtResizer
|
||||
from beets.library import parse_query_string
|
||||
from beets.library import Item
|
||||
from beets.util.m3u import M3UFile
|
||||
|
||||
_fs_lock = threading.Lock()
|
||||
_temp_files = [] # Keep track of temporary transcoded files for deletion.
|
||||
|
|
@ -149,6 +150,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
'copy_album_art': False,
|
||||
'album_art_maxwidth': 0,
|
||||
'delete_originals': False,
|
||||
'playlist': None,
|
||||
})
|
||||
self.early_import_stages = [self.auto_convert, self.auto_convert_keep]
|
||||
|
||||
|
|
@ -177,6 +179,15 @@ class ConvertPlugin(BeetsPlugin):
|
|||
dest='hardlink',
|
||||
help='hardlink files that do not \
|
||||
need transcoding. Overrides --link.')
|
||||
cmd.parser.add_option('-m', '--playlist', action='store',
|
||||
help='''create an m3u8 playlist file containing
|
||||
the converted files. The playlist file will be
|
||||
saved below the destination directory, thus
|
||||
PLAYLIST could be a file name or a relative path.
|
||||
To ensure a working playlist when transferred to
|
||||
a different computer, or opened from an external
|
||||
drive, relative paths pointing to media files
|
||||
will be used.''')
|
||||
cmd.parser.add_album_option()
|
||||
cmd.func = self.convert_func
|
||||
return [cmd]
|
||||
|
|
@ -436,7 +447,7 @@ class ConvertPlugin(BeetsPlugin):
|
|||
|
||||
def convert_func(self, lib, opts, args):
|
||||
(dest, threads, path_formats, fmt,
|
||||
pretend, hardlink, link) = self._get_opts_and_config(opts)
|
||||
pretend, hardlink, link, playlist) = self._get_opts_and_config(opts)
|
||||
|
||||
if opts.album:
|
||||
albums = lib.albums(ui.decargs(args))
|
||||
|
|
@ -461,8 +472,26 @@ class ConvertPlugin(BeetsPlugin):
|
|||
self.copy_album_art(album, dest, path_formats, pretend,
|
||||
link, hardlink)
|
||||
|
||||
self._parallel_convert(dest, opts.keep_new, path_formats, fmt,
|
||||
pretend, link, hardlink, threads, items)
|
||||
self._parallel_convert(dest, opts.keep_new, path_formats, fmt, pretend,
|
||||
link, hardlink, threads, items)
|
||||
|
||||
if playlist:
|
||||
# Playlist paths are understood as relative to the dest directory.
|
||||
pl_normpath = util.normpath(playlist)
|
||||
pl_dir = os.path.dirname(pl_normpath)
|
||||
self._log.info("Creating playlist file {0}", pl_normpath)
|
||||
# Generates a list of paths to media files, ensures the paths are
|
||||
# relative to the playlist's location and translates the unicode
|
||||
# strings we get from item.destination to bytes.
|
||||
items_paths = [
|
||||
os.path.relpath(util.bytestring_path(item.destination(
|
||||
basedir=dest, path_formats=path_formats, fragment=False
|
||||
)), pl_dir) for item in items
|
||||
]
|
||||
if not pretend:
|
||||
m3ufile = M3UFile(playlist)
|
||||
m3ufile.set_contents(items_paths)
|
||||
m3ufile.write()
|
||||
|
||||
def convert_on_import(self, lib, item):
|
||||
"""Transcode a file automatically after it is imported into the
|
||||
|
|
@ -544,6 +573,10 @@ class ConvertPlugin(BeetsPlugin):
|
|||
|
||||
fmt = opts.format or self.config['format'].as_str().lower()
|
||||
|
||||
playlist = opts.playlist or self.config['playlist'].get()
|
||||
if playlist is not None:
|
||||
playlist = os.path.join(dest, util.bytestring_path(playlist))
|
||||
|
||||
if opts.pretend is not None:
|
||||
pretend = opts.pretend
|
||||
else:
|
||||
|
|
@ -559,7 +592,8 @@ class ConvertPlugin(BeetsPlugin):
|
|||
hardlink = self.config['hardlink'].get(bool)
|
||||
link = self.config['link'].get(bool)
|
||||
|
||||
return dest, threads, path_formats, fmt, pretend, hardlink, link
|
||||
return (dest, threads, path_formats, fmt, pretend, hardlink, link,
|
||||
playlist)
|
||||
|
||||
def _parallel_convert(self, dest, keep_new, path_formats, fmt,
|
||||
pretend, link, hardlink, threads, items):
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
|
|||
return AlbumInfo(
|
||||
album=album_data['title'],
|
||||
album_id=deezer_id,
|
||||
deezer_album_id=deezer_id,
|
||||
artist=artist,
|
||||
artist_credit=self.get_artist([album_data['artist']])[0],
|
||||
artist_id=artist_id,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ New features:
|
|||
|
||||
* Added option to specify a URL in the `embedart` plugin.
|
||||
:bug:`83`
|
||||
* --from-logfile now parses log files using a UTF-8 encoding in `beets/beets/ui/commands.py`.
|
||||
:bug:`4693`
|
||||
* Added additional error handling for `spotify` plugin.
|
||||
:bug:`4686`
|
||||
* We now import the remixer field from Musicbrainz into the library.
|
||||
|
|
@ -54,6 +56,8 @@ New features:
|
|||
* :ref:`import-options`: Add support for re-running the importer on paths in
|
||||
log files that were created with the ``-l`` (or ``--logfile``) argument.
|
||||
:bug:`4379` :bug:`4387`
|
||||
* Preserve mtimes from archives
|
||||
:bug:`4392`
|
||||
* Add :ref:`%sunique{} <sunique>` template to disambiguate between singletons.
|
||||
:bug:`4438`
|
||||
* Add a new ``import.ignored_alias_types`` config option to allow for
|
||||
|
|
@ -65,6 +69,14 @@ New features:
|
|||
* :doc:`/plugins/fromfilename`: Add debug log messages that inform when the
|
||||
plugin replaced bad (missing) artist, title or tracknumber metadata.
|
||||
:bug:`4561` :bug:`4600`
|
||||
* :ref:`musicbrainz-config`: MusicBrainz release pages often link to related
|
||||
metadata sources like Discogs, Bandcamp, Spotify, Deezer and Beatport. When
|
||||
enabled via the :ref:`musicbrainz.external_ids` options, release ID's will be
|
||||
extracted from those URL's and imported to the library.
|
||||
:bug:`4220`
|
||||
* :doc:`/plugins/convert`: Add support for generating m3u8 playlists together
|
||||
with converted media files.
|
||||
:bug:`4373`
|
||||
|
||||
Bug fixes:
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ it's helpful to run on the "bleeding edge". To run the latest source:
|
|||
``pip uninstall beets``.
|
||||
2. Install from source. Choose one of these methods:
|
||||
|
||||
- Directly from GitHub using
|
||||
``python -m pip install git+https://github.com/beetbox/beets.git`` command. Depending on your system, you may need to use ``pip3`` and ``python3`` instead of ``pip`` and ``python`` respectively.
|
||||
- Use ``pip`` to install the latest snapshot tarball. Type:
|
||||
``pip install https://github.com/beetbox/beets/tarball/master``
|
||||
- Grab the source using git. First, clone the repository:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ Convert Plugin
|
|||
The ``convert`` plugin lets you convert parts of your collection to a
|
||||
directory of your choice, transcoding audio and embedding album art along the
|
||||
way. It can transcode to and from any format using a configurable command
|
||||
line.
|
||||
line. Optionally an m3u playlist file containing all the converted files can be
|
||||
saved to the destination path.
|
||||
|
||||
|
||||
Installation
|
||||
|
|
@ -54,6 +55,18 @@ instead, passing ``-H`` (``--hardlink``) creates hard links.
|
|||
Note that album art embedding is disabled for files that are linked.
|
||||
Refer to the ``link`` and ``hardlink`` options below.
|
||||
|
||||
The ``-m`` (or ``--playlist``) option enables the plugin to create an m3u8
|
||||
playlist file in the destination folder given by the ``-d`` (``--dest``) option
|
||||
or the ``dest`` configuration. The path to the playlist file can either be
|
||||
absolute or relative to the ``dest`` directory. The contents will always be
|
||||
relative paths to media files, which tries to ensure compatibility when read
|
||||
from external drives or on computers other than the one used for the
|
||||
conversion. There is one caveat though: A list generated on Unix/macOS can't be
|
||||
read on Windows and vice versa.
|
||||
|
||||
Depending on the beets user's settings a generated playlist potentially could
|
||||
contain unicode characters. This is supported, playlists are written in [m3u8
|
||||
format](https://en.wikipedia.org/wiki/M3U#M3U8).
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
@ -94,9 +107,9 @@ file. The available options are:
|
|||
output. Note that this does not guarantee that all converted files will have
|
||||
a lower bitrate---that depends on the encoder and its configuration.
|
||||
Default: none.
|
||||
- **no_convert**: Does not transcode items matching provided query string
|
||||
(see :doc:`/reference/query`). (i.e. ``format:AAC, format:WMA`` or
|
||||
``path::\.(m4a|wma)$``)
|
||||
- **no_convert**: Does not transcode items matching the query string provided
|
||||
(see :doc:`/reference/query`). For example, to not convert AAC or WMA formats, you can use ``format:AAC, format:WMA`` or
|
||||
``path::\.(m4a|wma)$``. If you only want to transcode WMA format, you can use a negative query, e.g., ``^path::\.(wma)$``, to not convert any other format except WMA.
|
||||
- **never_convert_lossy_files**: Cross-conversions between lossy codecs---such
|
||||
as mp3, ogg vorbis, etc.---makes little sense as they will decrease quality
|
||||
even further. If set to ``yes``, lossy files are always copied.
|
||||
|
|
@ -124,6 +137,12 @@ file. The available options are:
|
|||
Default: ``false``.
|
||||
- **delete_originals**: Transcoded files will be copied or moved to their destination, depending on the import configuration. By default, the original files are not modified by the plugin. This option deletes the original files after the transcoding step has completed.
|
||||
Default: ``false``.
|
||||
- **playlist**: The name of a playlist file that should be written on each run
|
||||
of the plugin. A relative file path (e.g `playlists/mylist.m3u8`) is allowed
|
||||
as well. The final destination of the playlist file will always be relative
|
||||
to the destination path (``dest``, ``--dest``, ``-d``). This configuration is
|
||||
overridden by the ``-m`` (``--playlist``) command line option.
|
||||
Default: none.
|
||||
|
||||
You can also configure the format to use for transcoding (see the next
|
||||
section):
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ following to your configuration::
|
|||
web
|
||||
zero
|
||||
|
||||
.. _autotagger_extensions:
|
||||
|
||||
Autotagger Extensions
|
||||
---------------------
|
||||
|
||||
|
|
@ -458,6 +460,9 @@ Here are a few of the plugins written by the beets community:
|
|||
Lets you perform regex replacements on incoming
|
||||
metadata.
|
||||
|
||||
`beets-jiosaavn`_
|
||||
Adds JioSaavn.com as a tagger data source..
|
||||
|
||||
`beets-mosaic`_
|
||||
Generates a montage of a mosaic from cover art.
|
||||
|
||||
|
|
@ -522,6 +527,7 @@ Here are a few of the plugins written by the beets community:
|
|||
.. _beets-usertag: https://github.com/igordertigor/beets-usertag
|
||||
.. _beets-popularity: https://github.com/abba23/beets-popularity
|
||||
.. _beets-plexsync: https://github.com/arsaboo/beets-plexsync
|
||||
.. _beets-jiosaavn: https://github.com/arsaboo/beets-jiosaavn
|
||||
.. _beets-ydl: https://github.com/vmassuchetto/beets-ydl
|
||||
.. _beet-summarize: https://github.com/steven-murray/beet-summarize
|
||||
.. _beets-mosaic: https://github.com/SusannaMaria/beets-mosaic
|
||||
|
|
|
|||
|
|
@ -842,6 +842,32 @@ release and the release-group on MusicBrainz, separated by "; " and sorted by
|
|||
the total number of votes.
|
||||
Default: ``no``
|
||||
|
||||
.. _musicbrainz.external_ids:
|
||||
|
||||
external_ids
|
||||
~~~~~~~~~~~~
|
||||
|
||||
Set any of the ``external_ids`` options to ``yes`` to enable the MusicBrainz
|
||||
importer to look for links to related metadata sources. If such a link is
|
||||
available the release ID will be extracted from the URL provided and imported
|
||||
to the beets library.
|
||||
|
||||
musicbrainz:
|
||||
external_ids:
|
||||
discogs: yes
|
||||
spotify: yes
|
||||
bandcamp: yes
|
||||
beatport: yes
|
||||
deezer: yes
|
||||
|
||||
|
||||
The library fields of the corresponding :ref:`autotagger_extensions` are used
|
||||
to save the data (``discogs_albumid``, ``bandcamp_album_id``,
|
||||
``spotify_album_id``, ``beatport_album_id``, ``deezer_album_id``). On
|
||||
re-imports existing data will be overwritten.
|
||||
|
||||
The default of all options is ``no``.
|
||||
|
||||
.. _match-config:
|
||||
|
||||
Autotagger Matching Options
|
||||
|
|
|
|||
3
test/rsrc/playlist.m3u
Normal file
3
test/rsrc/playlist.m3u
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#EXTM3U
|
||||
/This/is/a/path/to_a_file.mp3
|
||||
/This/is/another/path/to_a_file.mp3
|
||||
3
test/rsrc/playlist.m3u8
Normal file
3
test/rsrc/playlist.m3u8
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#EXTM3U
|
||||
/This/is/å/path/to_a_file.mp3
|
||||
/This/is/another/path/tö_a_file.mp3
|
||||
2
test/rsrc/playlist_non_ext.m3u
Normal file
2
test/rsrc/playlist_non_ext.m3u
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/This/is/a/path/to_a_file.mp3
|
||||
/This/is/another/path/to_a_file.mp3
|
||||
3
test/rsrc/playlist_windows.m3u8
Normal file
3
test/rsrc/playlist_windows.m3u8
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#EXTM3U
|
||||
x:\This\is\å\path\to_a_file.mp3
|
||||
x:\This\is\another\path\tö_a_file.mp3
|
||||
|
|
@ -293,6 +293,17 @@ class ConvertCliTest(unittest.TestCase, TestHelper, ConvertCommand):
|
|||
converted = os.path.join(self.convert_dest, b'converted.ogg')
|
||||
self.assertNoFileTag(converted, 'ogg')
|
||||
|
||||
def test_playlist(self):
|
||||
with control_stdin('y'):
|
||||
self.run_convert('--playlist', 'playlist.m3u8')
|
||||
m3u_created = os.path.join(self.convert_dest, b'playlist.m3u8')
|
||||
self.assertTrue(os.path.exists(m3u_created))
|
||||
|
||||
def test_playlist_pretend(self):
|
||||
self.run_convert('--playlist', 'playlist.m3u8', '--pretend')
|
||||
m3u_created = os.path.join(self.convert_dest, b'playlist.m3u8')
|
||||
self.assertFalse(os.path.exists(m3u_created))
|
||||
|
||||
|
||||
@_common.slow_test()
|
||||
class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper,
|
||||
|
|
|
|||
145
test/test_m3ufile.py
Normal file
145
test/test_m3ufile.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2022, J0J0 Todos.
|
||||
#
|
||||
# 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.
|
||||
"""Testsuite for the M3UFile class."""
|
||||
|
||||
|
||||
from os import path
|
||||
from tempfile import mkdtemp
|
||||
from shutil import rmtree
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
from beets.util import bytestring_path
|
||||
from beets.util.m3u import M3UFile, EmptyPlaylistError
|
||||
from test._common import RSRC
|
||||
|
||||
|
||||
class M3UFileTest(unittest.TestCase):
|
||||
"""Tests the M3UFile class."""
|
||||
def test_playlist_write_empty(self):
|
||||
"""Test whether saving an empty playlist file raises an error."""
|
||||
tempdir = bytestring_path(mkdtemp())
|
||||
the_playlist_file = path.join(tempdir, b'playlist.m3u8')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
with self.assertRaises(EmptyPlaylistError):
|
||||
m3ufile.write()
|
||||
rmtree(tempdir)
|
||||
|
||||
def test_playlist_write(self):
|
||||
"""Test saving ascii paths to a playlist file."""
|
||||
tempdir = bytestring_path(mkdtemp())
|
||||
the_playlist_file = path.join(tempdir, b'playlist.m3u')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.set_contents([
|
||||
bytestring_path('/This/is/a/path/to_a_file.mp3'),
|
||||
bytestring_path('/This/is/another/path/to_a_file.mp3')
|
||||
])
|
||||
m3ufile.write()
|
||||
self.assertTrue(path.exists(the_playlist_file))
|
||||
rmtree(tempdir)
|
||||
|
||||
def test_playlist_write_unicode(self):
|
||||
"""Test saving unicode paths to a playlist file."""
|
||||
tempdir = bytestring_path(mkdtemp())
|
||||
the_playlist_file = path.join(tempdir, b'playlist.m3u8')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.set_contents([
|
||||
bytestring_path('/This/is/å/path/to_a_file.mp3'),
|
||||
bytestring_path('/This/is/another/path/tö_a_file.mp3')
|
||||
])
|
||||
m3ufile.write()
|
||||
self.assertTrue(path.exists(the_playlist_file))
|
||||
rmtree(tempdir)
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'win32')
|
||||
def test_playlist_write_and_read_unicode_windows(self):
|
||||
"""Test saving unicode paths to a playlist file on Windows."""
|
||||
tempdir = bytestring_path(mkdtemp())
|
||||
the_playlist_file = path.join(tempdir,
|
||||
b'playlist_write_and_read_windows.m3u8')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.set_contents([
|
||||
bytestring_path(r"x:\This\is\å\path\to_a_file.mp3"),
|
||||
bytestring_path(r"x:\This\is\another\path\tö_a_file.mp3")
|
||||
])
|
||||
m3ufile.write()
|
||||
self.assertTrue(path.exists(the_playlist_file))
|
||||
m3ufile_read = M3UFile(the_playlist_file)
|
||||
m3ufile_read.load()
|
||||
self.assertEquals(
|
||||
m3ufile.media_list[0],
|
||||
bytestring_path(
|
||||
path.join('x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3'))
|
||||
)
|
||||
self.assertEquals(
|
||||
m3ufile.media_list[1],
|
||||
bytestring_path(r"x:\This\is\another\path\tö_a_file.mp3"),
|
||||
bytestring_path(path.join(
|
||||
'x:\\', 'This', 'is', 'another', 'path', 'tö_a_file.mp3'))
|
||||
)
|
||||
rmtree(tempdir)
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32', 'win32')
|
||||
def test_playlist_load_ascii(self):
|
||||
"""Test loading ascii paths from a playlist file."""
|
||||
the_playlist_file = path.join(RSRC, b'playlist.m3u')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.load()
|
||||
self.assertEqual(m3ufile.media_list[0],
|
||||
bytestring_path('/This/is/a/path/to_a_file.mp3'))
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32', 'win32')
|
||||
def test_playlist_load_unicode(self):
|
||||
"""Test loading unicode paths from a playlist file."""
|
||||
the_playlist_file = path.join(RSRC, b'playlist.m3u8')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.load()
|
||||
self.assertEqual(m3ufile.media_list[0],
|
||||
bytestring_path('/This/is/å/path/to_a_file.mp3'))
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'win32')
|
||||
def test_playlist_load_unicode_windows(self):
|
||||
"""Test loading unicode paths from a playlist file."""
|
||||
the_playlist_file = path.join(RSRC, b'playlist_windows.m3u8')
|
||||
winpath = bytestring_path(path.join(
|
||||
'x:\\', 'This', 'is', 'å', 'path', 'to_a_file.mp3'))
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.load()
|
||||
self.assertEqual(
|
||||
m3ufile.media_list[0],
|
||||
winpath
|
||||
)
|
||||
|
||||
def test_playlist_load_extm3u(self):
|
||||
"""Test loading a playlist with an #EXTM3U header."""
|
||||
the_playlist_file = path.join(RSRC, b'playlist.m3u')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.load()
|
||||
self.assertTrue(m3ufile.extm3u)
|
||||
|
||||
def test_playlist_load_non_extm3u(self):
|
||||
"""Test loading a playlist without an #EXTM3U header."""
|
||||
the_playlist_file = path.join(RSRC, b'playlist_non_ext.m3u')
|
||||
m3ufile = M3UFile(the_playlist_file)
|
||||
m3ufile.load()
|
||||
self.assertFalse(m3ufile.extm3u)
|
||||
|
||||
|
||||
def suite():
|
||||
"""This testsuite's main function."""
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
Loading…
Reference in a new issue