Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ venv/
.venv/
ENV/

# uv
uv.lock

# Spyder project settings
.spyderproject

Expand Down
30 changes: 30 additions & 0 deletions beets/library/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from beets.util.deprecation import maybe_replace_legacy_field
from beets.util.functemplate import Template, template
from beets.util.urls import album_url, track_url

from .exceptions import FileOperationError, ReadError, WriteError
from .fields import TYPE_BY_FIELD
Expand Down Expand Up @@ -341,6 +342,7 @@ def _getters(cls):
getters = plugins.album_field_getters()
getters["path"] = Album.item_dir
getters["albumtotal"] = Album._albumtotal
getters["url"] = Album._url
return getters

def items(self):
Expand Down Expand Up @@ -489,6 +491,19 @@ def _albumtotal(self):

return total

def _url(self) -> str | None:
"""The URL of the album's page on its data source."""

# First up, check if there's a valid mb_albumid -> data_source combination.
if url := album_url(self.get("data_source"), self.get("mb_albumid")):
return url

# Fallback to discogs if set
if discogs_id := self.get("discogs_albumid"):
return f"https://www.discogs.com/release/{discogs_id}"

return None

def art_destination(self, image, item_dir=None):
"""Return a path to the destination for the album art image
for the album.
Expand Down Expand Up @@ -744,12 +759,27 @@ def _cached_album(self):
def _cached_album(self, album):
self.__album = album

def _url(self) -> str | None:
"""The URL of the track's page on its data source.

Falls back to the album URL when the source has no per-track pages.
"""
source = self.get("data_source")
if url := track_url(source, self.get("mb_trackid")):
return url

# Discogs is the only source that doesn't have per-track links as of yet.
if discogs_id := self.get("discogs_albumid"):
return f"https://www.discogs.com/release/{discogs_id}"
return None

@classmethod
def _getters(cls):
getters = plugins.item_field_getters()
getters["singleton"] = lambda i: i.album_id is None
getters["filesize"] = Item.try_filesize # In bytes.
getters["has_cover_art"] = Item.has_cover_art
getters["url"] = Item._url
return getters

def duplicates_query(self, fields: list[str]) -> dbcore.AndQuery:
Expand Down
14 changes: 14 additions & 0 deletions beets/util/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ def colorize(color_name: ColorName, text: str) -> str:
return text


def terminal_link(url: str, text: str | None = None) -> str:
"""Create a clickable terminal hyperlink using a OSC 8 escape sequence.
``text`` falls back to ``url`` if ``None``.
"""
display = text if text is not None else url

return (
f"{COLOR_ESCAPE}]8;;{url}{COLOR_ESCAPE}\\"
f"{display}"
f"{COLOR_ESCAPE}]8;;{COLOR_ESCAPE}\\"
)


def uncolorize(colored_text: str) -> str:
"""Remove colors from a string."""
# Define a regular expression to match ANSI codes.
Expand Down
115 changes: 115 additions & 0 deletions beets/util/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""This module contains helpers to build URLs for external sources.

There're currently explicit database fields for musicbrainz (``mb_*``) and deezer
(``deezer_*``), which hold IDs that point to sources.

Since not all data sources have their own fields though, a workaround was needed.
For those sources, the ``mb_*`` musicbrainz fields are overloaded and used for
other non-musicbrainz ids, such as beatport track ids or spotify album ids.

To distinquish, what kind of IDs we're actually looking at the ``data_source`` field
is used. If ``data_source`` is not set, we default to `musicbrainz`.

The ``discogs_*`` fields are **not** overloaded and always expected to point to discogs.
"""

from __future__ import annotations

# Since the ``mb_*`` fields were originally MusicBrainz IDs, they should also be used
# as such, when the no ``data_source`` is set.
DEFAULT_SOURCE = "musicbrainz"

# Album/release web pages for each known data source.
ALBUM_URL_BY_SOURCE: dict[str, str] = {
"musicbrainz": "https://musicbrainz.org/release/{}",
"spotify": "https://open.spotify.com/album/{}",
"deezer": "https://www.deezer.com/album/{}",
"beatport": "https://www.beatport.com/release/_/{}",
"tidal": "https://tidal.com/browse/album/{}",
"discogs": "https://www.discogs.com/release/{}",
}

# Track/recording web pages for each known data source.
#
# Discogs is intentionally absent, as it has no per-track URLs.
TRACK_URL_BY_SOURCE: dict[str, str] = {
"musicbrainz": "https://musicbrainz.org/recording/{}",
"spotify": "https://open.spotify.com/track/{}",
"deezer": "https://www.deezer.com/track/{}",
"beatport": "https://www.beatport.com/track/_/{}",
"tidal": "https://tidal.com/browse/track/{}",
}

# Artist web pages for each known data source.
ARTIST_URL_BY_SOURCE: dict[str, str] = {
"musicbrainz": "https://musicbrainz.org/artist/{}",
"spotify": "https://open.spotify.com/artist/{}",
"deezer": "https://www.deezer.com/artist/{}",
"tidal": "https://tidal.com/browse/artist/{}",
"discogs": "https://www.discogs.com/artist/{}",
}

# Fields that always point to MusicBrainz, regardless of ``data_source``.
_MB_ONLY_URLS: dict[str, str] = {
"mb_releasetrackid": "https://musicbrainz.org/track/{}",
"mb_releasegroupid": "https://musicbrainz.org/release-group/{}",
"mb_workid": "https://musicbrainz.org/work/{}",
}

# Fields that always point to Discogs, regardless of ``data_source``.
_DISCOGS_ONLY_URLS: dict[str, str] = {
"discogs_albumid": "https://www.discogs.com/release/{}",
"discogs_artistid": "https://www.discogs.com/artist/{}",
"discogs_labelid": "https://www.discogs.com/label/{}",
}


def _format(templates: dict[str, str], source: str | None, value) -> str | None:
"""Small helper function to get an url by source from one of the lookup maps."""
if not value:
return None

template = templates.get((source or DEFAULT_SOURCE).lower())
return template.format(value) if template else None


def album_url(source: str | None, album_id) -> str | None:
"""URL for an album/release on ``source`` (defaults to MusicBrainz)."""
return _format(ALBUM_URL_BY_SOURCE, source, album_id)


def track_url(source: str | None, track_id) -> str | None:
"""URL for a track/recording on ``source`` (defaults to MusicBrainz).

Returns ``None`` for sources without per-track URLs (Discogs).
"""
return _format(TRACK_URL_BY_SOURCE, source, track_id)


def artist_url(source: str | None, artist_id) -> str | None:
"""URL for an artist on ``source`` (defaults to MusicBrainz)."""
return _format(ARTIST_URL_BY_SOURCE, source, artist_id)


def field_url(field: str, value, source: str | None = None) -> str | None:
"""Resolve a beets ID field to a URL, taking ``data_source`` into account.

Returns ``None`` if the ``field`` is not a known external-ID field or
the source is unknown.
"""
if not value:
return None

if field == "mb_albumid":
return album_url(source, value)
if field == "mb_trackid":
return track_url(source, value)
if field in ("mb_artistid", "mb_albumartistid"):
return artist_url(source, value)

if template := _MB_ONLY_URLS.get(field):
return template.format(value)
if template := _DISCOGS_ONLY_URLS.get(field):
return template.format(value)

return None
23 changes: 19 additions & 4 deletions beetsplug/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from beets.library import Item
from beets.plugins import BeetsPlugin
from beets.util import displayable_path, normpath, syspath
from beets.util.urls import field_url


def tag_data(lib, args, album=False):
Expand Down Expand Up @@ -92,25 +93,34 @@ def update_summary(summary, tags):
return summary


def print_data(data, item=None, fmt=None):
def print_data(data, item=None, fmt=None, links=False):
"""Print, with optional formatting, the fields of a single element.

If no format string `fmt` is passed, the entries on `data` are printed one
in each line, with the format 'field: value'. If `fmt` is not `None`, the
`item` is printed according to `fmt`, using the `Item.__format__`
machinery.

When ``links == True``, external ID fields will be rendered as clickable
terminal hyperlinks using OSC 8 escape sequences.
"""
if fmt:
# use fmt specified by the user
ui.print_(format(item, fmt))
return

path = displayable_path(item.path) if item else None

# ``data_source`` is used to determine the correct website to point to when
# ``--links`` has been requested.
source = item.get("data_source") if (links and item) else None
formatted = {}
for key, value in data.items():
if isinstance(value, list):
formatted[key] = "; ".join(value)
if value is not None:
elif value is not None:
if links and (url := field_url(key, value, source)):
value = ui.terminal_link(url, str(value))
formatted[key] = value

if len(formatted) == 0:
Expand Down Expand Up @@ -181,6 +191,11 @@ def commands(self):
action="store_true",
help="show only the keys",
)
cmd.parser.add_option(
"--links",
action="store_true",
help="make ID fields (MusicBrainz, Discogs) clickable terminal hyperlinks",
)
cmd.parser.add_format_option(target="item")
return [cmd]

Expand Down Expand Up @@ -231,8 +246,8 @@ def run(self, lib, opts, args):
print_data_keys(data, item)
else:
fmt = [opts.format][0] if opts.format else None
print_data(data, item, fmt)
print_data(data, item, fmt, links=opts.links)
first = False

if opts.summarize:
print_data(summary)
print_data(summary, links=opts.links)
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ New features
also available as config options via ``force`` and ``keep_new``.
- :ref:`import-cmd`: The ``--nomove`` / ``-M`` CLI flag can now be used to
override the ``move: yes`` config option during import.
- :doc:`plugins/info`: Added ``--links`` command-line flag, which results in
external IDs (MusicBrainz, Discogs) being clickable links in the terminal.

Bug fixes
~~~~~~~~~
Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/info.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Additional command-line options include:
item. This uses the same template syntax as beets’ :doc:`path formats
</reference/pathformat>`.
- ``--keys-only`` or ``-k``: Show the name of the tags without the values.
- ``--links``: Make external IDs (Discogs/MusicBrainz) clickable links in the
terminal.

.. _id3v2: https://sourceforge.net/projects/id3v2/

Expand Down
16 changes: 8 additions & 8 deletions test/plugins/test_beatport.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,12 +494,12 @@ def mk_test_album(self):
items[4].length = timedelta(minutes=9, seconds=49).total_seconds()
items[5].length = timedelta(minutes=7, seconds=5).total_seconds()

items[0].url = "mirage-a-trois-original-mix"
items[1].url = "aeon-bahamut-original-mix"
items[2].url = "trancendental-medication-original-mix"
items[3].url = "a-list-of-instructions-for-when-im-human-original-mix"
items[4].url = "the-great-shenanigan-original-mix"
items[5].url = "charade-original-mix"
items[0].slug = "mirage-a-trois-original-mix"
items[1].slug = "aeon-bahamut-original-mix"
items[2].slug = "trancendental-medication-original-mix"
items[3].slug = "a-list-of-instructions-for-when-im-human-original-mix"
items[4].slug = "the-great-shenanigan-original-mix"
items[5].slug = "charade-original-mix"

counter = 0
for item in items:
Expand Down Expand Up @@ -569,8 +569,8 @@ def test_track_url_applied(self):
]
# Concatenate with 'id' to pass strict equality test.
for track, test_track, id in zip(self.tracks, self.test_tracks, ids):
assert (
track.url == f"https://beatport.com/track/{test_track.url}/{id}"
assert track.url == (
f"https://beatport.com/track/{test_track.slug}/{id}"
)

def test_bpm_applied(self):
Expand Down
57 changes: 57 additions & 0 deletions test/plugins/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

from mediafile import MediaFile

from beets import ui
from beets.test.helper import IOMixin, PluginTestCase
from beets.util import displayable_path
from beets.util.urls import field_url


class InfoTest(IOMixin, PluginTestCase):
Expand Down Expand Up @@ -116,3 +118,58 @@ def test_custom_format(self):
"$track. $title - $artist ($length)",
)
assert "02. tïtle 0 - the artist (0:01)\n" == out

def _assert_field_link(self, out, field, value, source=None):
"""Assert that ``field: <hyperlink>`` is present in ``out``."""
url = field_url(field, value, source)
assert url is not None
link = ui.terminal_link(url, str(value))
assert f"{field}: {link}" in out

def test_links(self):
"""``--links`` defaults to MusicBrainz when ``data_source`` is unset."""
(item,) = self.add_item_fixtures()
item.mb_albumid = "album-uuid"
item.mb_trackid = "track-uuid"
item.discogs_albumid = 99999
item.album = "MyAlbum"
item.store()

out = self.run_with_output(
"info",
"--library",
"--include-keys",
"mb_albumid,mb_trackid,discogs_albumid,album",
"--links",
)

self._assert_field_link(out, "mb_albumid", item.mb_albumid)
self._assert_field_link(out, "mb_trackid", item.mb_trackid)
self._assert_field_link(out, "discogs_albumid", item.discogs_albumid)

# Non-ID fields remain plain text.
assert "album: MyAlbum" in out

def test_links_with_data_source(self):
"""Non-MusicBrainz ``data_source`` values pick the right site.

When ``data_source`` is set, the ``mb_albumid``/``mb_trackid`` fields
actually hold IDs for that source's website, so the rendered links
must point there too.
"""
(item,) = self.add_item_fixtures()
item.data_source = "Deezer"
item.mb_albumid = "deezer-album-id"
item.mb_trackid = "deezer-track-id"
item.store()

out = self.run_with_output(
"info",
"--library",
"--include-keys",
"mb_albumid,mb_trackid",
"--links",
)

self._assert_field_link(out, "mb_albumid", item.mb_albumid, "Deezer")
self._assert_field_link(out, "mb_trackid", item.mb_trackid, "Deezer")
Loading