diff --git a/.gitignore b/.gitignore index 138965b22..54a2c04c5 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,9 @@ venv/ .venv/ ENV/ +# uv +uv.lock + # Spyder project settings .spyderproject diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 627b07981..ce54956c2 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -37,7 +37,7 @@ from beets import config, library, logging, plugins, util from beets.dbcore import db from beets.dbcore import query as db_query from beets.util import as_string -from beets.util.color import colorize +from beets.util.color import colorize, terminal_link from beets.util.deprecation import deprecate_for_maintainers from beets.util.diff import get_model_changes from beets.util.functemplate import template diff --git a/beets/util/color.py b/beets/util/color.py index 8e83ba7cb..9017089b2 100644 --- a/beets/util/color.py +++ b/beets/util/color.py @@ -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. diff --git a/beetsplug/info.py b/beetsplug/info.py index cc78aaffe..218368f43 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -23,6 +23,20 @@ from beets.library import Item from beets.plugins import BeetsPlugin from beets.util import displayable_path, normpath, syspath +# Mapping from beets field names to URL templates. +FIELD_LINK_TEMPLATES: dict[str, str] = { + "mb_trackid": "https://musicbrainz.org/recording/{value}", + "mb_albumid": "https://musicbrainz.org/release/{value}", + "mb_artistid": "https://musicbrainz.org/artist/{value}", + "mb_albumartistid": "https://musicbrainz.org/artist/{value}", + "mb_releasetrackid": "https://musicbrainz.org/track/{value}", + "mb_releasegroupid": "https://musicbrainz.org/release-group/{value}", + "mb_workid": "https://musicbrainz.org/work/{value}", + "discogs_albumid": "https://www.discogs.com/release/{value}", + "discogs_artistid": "https://www.discogs.com/artist/{value}", + "discogs_labelid": "https://www.discogs.com/label/{value}", +} + def tag_data(lib, args, album=False): query = [] @@ -92,13 +106,16 @@ 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 @@ -110,7 +127,11 @@ def print_data(data, item=None, fmt=None): 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 key in FIELD_LINK_TEMPLATES: + value_str = str(value) + url = FIELD_LINK_TEMPLATES[key].format(value=value_str) + value = ui.terminal_link(url, value_str) formatted[key] = value if len(formatted) == 0: @@ -181,6 +202,11 @@ class InfoPlugin(BeetsPlugin): 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] @@ -231,8 +257,8 @@ class InfoPlugin(BeetsPlugin): 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) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2354e6539..071f23b92 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,8 @@ New features :bug:`2661` - :doc:`plugins/play`: Added ``-R``/``--randomize`` flag to shuffle the playlist order before passing it to the player. +- :doc:`plugins/info`: Added ``--links`` command-line flag, which results in + external IDs (MusicBrainz, Discogs) being clickable links in the terminal. Bug fixes ~~~~~~~~~ @@ -103,7 +105,6 @@ Bug fixes New features ~~~~~~~~~~~~ - - :doc:`plugins/lastgenre`: Added ``cleanup_existing`` configuration flag to allow whitelist canonicalization of existing genres. - Add native support for multiple genres per album/track. The ``genres`` field diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst index 77dc60efd..4eb61d5f2 100644 --- a/docs/plugins/info.rst +++ b/docs/plugins/info.rst @@ -46,6 +46,8 @@ Additional command-line options include: item. This uses the same template syntax as beets’ :doc:`path formats `. - ``--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/ diff --git a/test/plugins/test_info.py b/test/plugins/test_info.py index 3ad4d0884..857f877b6 100644 --- a/test/plugins/test_info.py +++ b/test/plugins/test_info.py @@ -15,6 +15,7 @@ from mediafile import MediaFile +from beets import ui from beets.test.helper import IOMixin, PluginTestCase from beets.util import displayable_path @@ -116,3 +117,38 @@ class InfoTest(IOMixin, PluginTestCase): "$track. $title - $artist ($length)", ) assert "02. tïtle 0 - the artist (0:01)\n" == out + + def test_links(self): + (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", + ) + + # ID fields are wrapped in terminal hyperlinks + mb_album_link = ui.terminal_link( + "https://musicbrainz.org/release/album-uuid", "album-uuid" + ) + assert f"mb_albumid: {mb_album_link}" in out + + mb_track_link = ui.terminal_link( + "https://musicbrainz.org/recording/track-uuid", "track-uuid" + ) + assert f"mb_trackid: {mb_track_link}" in out + + discogs_link = ui.terminal_link( + "https://www.discogs.com/release/99999", "99999" + ) + assert f"discogs_albumid: {discogs_link}" in out + + # Non-ID fields remain plain text + assert "album: MyAlbum" in out