diff --git a/beets/importer/tasks.py b/beets/importer/tasks.py index 976331aaf..a7e708eb4 100644 --- a/beets/importer/tasks.py +++ b/beets/importer/tasks.py @@ -64,6 +64,11 @@ REIMPORT_FRESH_FIELDS_ITEM = [ "deezer_album_id", "beatport_album_id", "tidal_album_id", + "tidal_track_id", + "tidal_artist_id", + "tidal_track_popularity", + "tidal_alb_popularity", + "tidal_updated", "data_url", ] REIMPORT_FRESH_FIELDS_ALBUM = [*REIMPORT_FRESH_FIELDS_ITEM, "media"] diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 8fcce5aa3..3354d78a7 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -3,13 +3,15 @@ from __future__ import annotations import itertools import os import re +import time from functools import cached_property -from typing import TYPE_CHECKING, overload +from typing import TYPE_CHECKING, ClassVar, overload import confuse from beets import ui from beets.autotag import AlbumInfo, TrackInfo +from beets.dbcore import types from beets.exceptions import UserError from beets.logging import getLogger from beets.metadata_plugins import MetadataSourcePlugin @@ -36,6 +38,15 @@ log = getLogger("beets.tidal") class TidalPlugin(MetadataSourcePlugin): + item_types: ClassVar[dict[str, types.Type]] = { + "tidal_track_id": types.INTEGER, + "tidal_album_id": types.INTEGER, + "tidal_artist_id": types.INTEGER, + "tidal_track_popularity": types.INTEGER, + "tidal_alb_popularity": types.INTEGER, + "tidal_updated": types.DATE, + } + def __init__(self) -> None: super().__init__() @@ -66,28 +77,6 @@ class TidalPlugin(MetadataSourcePlugin): " using `beet tidal --auth` or disable tidal plugin" ) - def commands(self) -> list[ui.Subcommand]: - tidal_cmd = ui.Subcommand( - "tidal", help="Tidal metadata plugin commands" - ) - tidal_cmd.parser.add_option( - "-a", - "--auth", - action="store_true", - help="Authenticate and login to Tidal", - default=False, - ) - - def func(lib: Library, opts: optparse.Values, args: list[str]): - if opts.auth: - self.api.ui_authenticate_flow() - else: - tidal_cmd.print_help() - - tidal_cmd.func = func - - return [tidal_cmd] - def album_for_id(self, album_id: str) -> AlbumInfo | None: if not (tidal_id := self._extract_id(album_id)): return None @@ -345,7 +334,6 @@ class TidalPlugin(MetadataSourcePlugin): track_by_id: dict[str, TidalTrack], artist_by_id: dict[str, TidalArtist], ) -> AlbumInfo: - track_infos: list[TrackInfo] = [] for i, track_rel in enumerate( album["relationships"]["items"]["data"], start=1 @@ -377,6 +365,13 @@ class TidalPlugin(MetadataSourcePlugin): year=date_parts[0] if date_parts else None, month=date_parts[1] if date_parts else None, day=date_parts[2] if date_parts else None, + # Flexattrs + tidal_album_id=int(album["id"]), + tidal_artist_id=int(artist_ids[0]) if artist_ids else None, + tidal_alb_popularity=self._popularity( + album["attributes"]["popularity"] + ), + tidal_updated=time.time(), ) def _get_track_info( @@ -399,6 +394,13 @@ class TidalPlugin(MetadataSourcePlugin): artists=artist_names, duration=self._duration_to_seconds(track["attributes"]["duration"]), label=self._parse_label(track["attributes"]), + # Flexattrs + tidal_track_id=int(track["id"]), + tidal_artist_id=int(artist_ids[0]) if artist_ids else None, + tidal_track_popularity=self._popularity( + track["attributes"]["popularity"] + ), + tidal_updated=time.time(), ) @staticmethod @@ -474,6 +476,100 @@ class TidalPlugin(MetadataSourcePlugin): return int(parts[0]), int(parts[1]), int(parts[2]) return None + @staticmethod + def _popularity(val: object) -> int | None: + if val is None: + return None + if isinstance(val, float): + return int(val * 100) + return int(val) + + def tidalsync( + self, items: Iterable[Item], write: bool, force: bool = False + ) -> None: + """Sync Tidal popularity data for library items.""" + items = list(items) + self._log.info("Syncing popularity for {0} tracks", len(items)) + + for item in items: + if not force and item.get("tidal_track_popularity") is not None: + continue + + tidal_track_id = item.get("tidal_track_id") + if tidal_track_id is None: + continue + + results = list( + self.search_tracks_by_ids(tidal_ids=[str(tidal_track_id)]) + ) + if not results or results[0] is None: + self._log.debug("Track {0} not found in Tidal", tidal_track_id) + continue + + track_info = results[0] + if track_info.tidal_track_popularity is not None: + item["tidal_track_popularity"] = ( + track_info.tidal_track_popularity + ) + item["tidal_updated"] = time.time() + item.store() + if write: + item.try_write() + + self._log.debug( + "Updated popularity for track {0}: {1}", + tidal_track_id, + track_info.tidal_track_popularity, + ) + + def commands(self) -> list[ui.Subcommand]: + tidal_cmd = ui.Subcommand( + "tidal", help="Tidal metadata plugin commands" + ) + tidal_cmd.parser.add_option( + "-a", + "--auth", + action="store_true", + help="Authenticate and login to Tidal", + default=False, + ) + + def func(lib: Library, opts: optparse.Values, args: list[str]): + if opts.auth: + self.api.ui_authenticate_flow() + else: + tidal_cmd.print_help() + + tidal_cmd.func = func + + tidalsync_cmd = ui.Subcommand( + "tidalsync", help="sync Tidal popularity data for library items" + ) + tidalsync_cmd.parser.add_option( + "-f", + "--force", + action="store_true", + dest="force", + default=False, + help="re-fetch popularity even if already present", + ) + tidalsync_cmd.parser.add_option( + "-w", + "--write", + action="store_true", + dest="write", + default=False, + help="write updated tags to media files", + ) + + def sync_func(lib: Library, opts: optparse.Values, args: list[str]): + items = lib.items(args) + self.tidalsync(items, write=opts.write, force=opts.force) + + tidalsync_cmd.func = sync_func + + return [tidal_cmd, tidalsync_cmd] + ISO_8601_RE = re.compile( r"^P" diff --git a/docs/changelog.rst b/docs/changelog.rst index 23812a7b7..0de3f6eb1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -27,6 +27,11 @@ New features :conf:`plugins.musicbrainz:aliases_as_credits` to make aliases-as-artist-credit optional. - :doc:`plugins/badfiles`: Added settings for auto error and warning actions. +- :doc:`plugins/tidal`: New ``item_types`` flexible attributes + ``tidal_track_id``, ``tidal_album_id``, ``tidal_artist_id``, + ``tidal_track_popularity``, ``tidal_alb_popularity``, and ``tidal_updated`` + are now populated during import. Added new ``beet tidalsync`` command to sync + popularity data for imported items. Bug fixes ~~~~~~~~~ diff --git a/docs/plugins/tidal.rst b/docs/plugins/tidal.rst index 8b08c6fd3..61aeaf175 100644 --- a/docs/plugins/tidal.rst +++ b/docs/plugins/tidal.rst @@ -102,4 +102,76 @@ Default The path to the file where the Tidal authentication token is stored. +Flexible Attributes +------------------- + +The plugin stores the following flexible attributes on your items during import. +You can use them in queries and path formats. + +.. list-table:: + :header-rows: 1 + + - - Attribute + - Type + - Description + - - ``tidal_track_id`` + - INTEGER + - Tidal track ID + - - ``tidal_album_id`` + - INTEGER + - Tidal album ID + - - ``tidal_artist_id`` + - INTEGER + - Tidal artist ID + - - ``tidal_track_popularity`` + - INTEGER + - Track popularity score (0-100) + - - ``tidal_alb_popularity`` + - INTEGER + - Album popularity score (0-100) + - - ``tidal_updated`` + - DATE + - Unix timestamp of last popularity sync + +For example, to list your most popular tracks: + +:: + + beet ls tidal_track_popularity:80.. + +To find tracks that have not been synced yet: + +:: + + beet ls tidal_track_popularity:0 + +tidalsync Command +----------------- + +After importing tracks, you can refresh their popularity data by running the +``tidalsync`` subcommand: + +.. code-block:: bash + + beet tidalsync + +By default, ``tidalsync`` skips items that already have popularity data. Use +``--force`` (``-f``) to re-fetch all items: + +:: + + beet tidalsync -f + +Use ``--write`` (``-w``) to also write the updated metadata to the media files: + +:: + + beet tidalsync -w + +You can also filter which items to sync by passing a query: + +:: + + beet tidalsync artist:"My Artist" + .. include:: ./shared_metadata_source_config.rst diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index 1d94ea7d1..c1a1e0212 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import TYPE_CHECKING from unittest.mock import Mock @@ -123,25 +124,30 @@ class TestAlbumParsing(TidalPluginTest): """High-level tests for album parsing.""" def test_parse_album(self): - track = _make_track("t1", "My Song", "PT3M30S", "ISRC001", ["a1"]) + track = _make_track("101", "My Song", "PT3M30S", "ISRC001", ["1001"]) album, track_lookup, artist_lookup = _make_album( - "al1", "My Album", [track], ["a1"] + "1", "My Album", [track], ["1001"] ) info = self.tidal._get_album_info(album, track_lookup, artist_lookup) assert info.album == "My Album" - assert info.album_id == "al1" + assert info.album_id == "1" assert len(info.tracks) == 1 assert info.tracks[0].title == "My Song" + assert info.tidal_album_id == 1 + assert info.tidal_artist_id == 1001 + assert info.tidal_alb_popularity == 50 + assert info.tracks[0].tidal_track_id == 101 + assert info.tracks[0].tidal_track_popularity == 50 def test_parse_album_with_multiple_tracks(self): tracks = [ - _make_track("t1", "Track One", "PT3M", "ISRC1", ["a1"]), - _make_track("t2", "Track Two", "PT4M", "ISRC2", ["a1"]), + _make_track("101", "Track One", "PT3M", "ISRC1", ["1001"]), + _make_track("102", "Track Two", "PT4M", "ISRC2", ["1001"]), ] album, track_lookup, artist_lookup = _make_album( - "al2", "Album Two", tracks, ["a1"] + "2", "Album Two", tracks, ["1001"] ) info = self.tidal._get_album_info(album, track_lookup, artist_lookup) @@ -149,12 +155,13 @@ class TestAlbumParsing(TidalPluginTest): assert len(info.tracks) == 2 assert info.tracks[0].index == 1 assert info.tracks[1].index == 2 + assert info.tidal_album_id == 2 def test_parse_album_with_version(self): """Album title should have version appended.""" - track = _make_track("t1", "My Song", "PT3M", "ISRC001", ["a1"]) + track = _make_track("101", "My Song", "PT3M", "ISRC001", ["1001"]) album, track_lookup, artist_lookup = _make_album( - "al3", "My Album", [track], ["a1"], version="Deluxe Edition" + "3", "My Album", [track], ["1001"], version="Deluxe Edition" ) info = self.tidal._get_album_info(album, track_lookup, artist_lookup) @@ -166,27 +173,31 @@ class TestTrackParsing(TidalPluginTest): """High-level tests for track parsing.""" def test_parse_track(self): - track = _make_track("t1", "My Track", "PT4M", "ISRC456", ["a1"]) - artist_lookup = {"a1": _make_artist("a1", "My Artist")} + track = _make_track("101", "My Track", "PT4M", "ISRC456", ["1001"]) + artist_lookup = {"1001": _make_artist("1001", "My Artist")} info = self.tidal._get_track_info(track, artist_lookup) assert info.title == "My Track" - assert info.track_id == "t1" + assert info.track_id == "101" assert info.duration == 240 # PT4M = 240 seconds assert info.isrc == "ISRC456" assert info.artist == "My Artist" + assert info.tidal_track_id == 101 + assert info.tidal_artist_id == 1001 + assert info.tidal_track_popularity == 50 def test_parse_track_with_version(self): """Track title should have version appended.""" track = _make_track( - "t2", "My Song", "PT3M", "ISRC002", ["a1"], version="Remastered" + "102", "My Song", "PT3M", "ISRC002", ["1001"], version="Remastered" ) - artist_lookup = {"a1": _make_artist("a1", "My Artist")} + artist_lookup = {"1001": _make_artist("1001", "My Artist")} info = self.tidal._get_track_info(track, artist_lookup) assert info.title == "My Song (Remastered)" + assert info.tidal_track_id == 102 class TestTrackForID(TidalPluginTest): @@ -194,8 +205,10 @@ class TestTrackForID(TidalPluginTest): def test_track_for_id(self): """Test fetching track by ID via API.""" - track = _make_track("490839595", "API Track", "PT3M", "ISRC001", ["a1"]) - artist = _make_artist("a1", "API Artist") + track = _make_track( + "490839595", "API Track", "PT3M", "ISRC001", ["1001"] + ) + artist = _make_artist("1001", "API Artist") self.tidal.api.get_tracks = Mock( return_value={"data": [track], "included": [artist]} @@ -225,12 +238,12 @@ class TestTracksForIDs(TidalPluginTest): def test_tracks_for_ids(self): """Test fetching multiple tracks by IDs via API.""" track1 = _make_track( - "490839595", "API Track 1", "PT3M", "ISRC001", ["a1"] + "490839595", "API Track 1", "PT3M", "ISRC001", ["1001"] ) track2 = _make_track( - "490839596", "API Track 2", "PT4M", "ISRC002", ["a1"] + "490839596", "API Track 2", "PT4M", "ISRC002", ["1001"] ) - artist = _make_artist("a1", "API Artist") + artist = _make_artist("1001", "API Artist") self.tidal.api.get_tracks = Mock( return_value={"data": [track1, track2], "included": [artist]} @@ -253,8 +266,10 @@ class TestTracksForIDs(TidalPluginTest): def test_tracks_for_ids_with_missing(self): """Test tracks_for_ids yields None for IDs not found.""" - track = _make_track("490839595", "API Track", "PT3M", "ISRC001", ["a1"]) - artist = _make_artist("a1", "API Artist") + track = _make_track( + "490839595", "API Track", "PT3M", "ISRC001", ["1001"] + ) + artist = _make_artist("1001", "API Artist") self.tidal.api.get_tracks = Mock( return_value={"data": [track], "included": [artist]} @@ -277,9 +292,11 @@ class TestAlbumForID(TidalPluginTest): def test_album_for_id(self): """Test fetching album by ID via API.""" - track = _make_track("t1", "Album Track", "PT3M30S", "ISRC001", ["a1"]) + track = _make_track( + "101", "Album Track", "PT3M30S", "ISRC001", ["1001"] + ) album, track_lookup, artist_lookup = _make_album( - "226495055", "API Album", [track], ["a1"] + "226495055", "API Album", [track], ["1001"] ) self.tidal.api.get_albums = Mock( return_value={ @@ -311,13 +328,17 @@ class TestAlbumsForIDs(TidalPluginTest): def test_albums_for_ids(self): """Test fetching multiple albums by IDs via API.""" - track1 = _make_track("t1", "Album Track 1", "PT3M", "ISRC001", ["a1"]) - track2 = _make_track("t2", "Album Track 2", "PT4M", "ISRC002", ["a1"]) + track1 = _make_track( + "101", "Album Track 1", "PT3M", "ISRC001", ["1001"] + ) + track2 = _make_track( + "102", "Album Track 2", "PT4M", "ISRC002", ["1001"] + ) album1, track_lookup1, artist_lookup1 = _make_album( - "226495055", "API Album 1", [track1], ["a1"] + "226495055", "API Album 1", [track1], ["1001"] ) album2, track_lookup2, artist_lookup2 = _make_album( - "226495056", "API Album 2", [track2], ["a1"] + "226495056", "API Album 2", [track2], ["1001"] ) # Combine lookups to simulate API response @@ -351,9 +372,9 @@ class TestAlbumsForIDs(TidalPluginTest): def test_albums_for_ids_with_missing(self): """Test albums_for_ids yields None for IDs not found.""" - track = _make_track("t1", "Album Track", "PT3M", "ISRC001", ["a1"]) + track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"]) album, track_lookup, artist_lookup = _make_album( - "226495055", "API Album", [track], ["a1"] + "226495055", "API Album", [track], ["1001"] ) self.tidal.api.get_albums = Mock( @@ -381,9 +402,9 @@ class TestCandidates(TidalPluginTest): def test_candidates_with_barcode(self): """Test that candidates uses barcode lookup first.""" - track = _make_track("t1", "Album Track", "PT3M", "ISRC001", ["a1"]) + track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"]) album, track_lookup, artist_lookup = _make_album( - "al1", "Barcode Album", [track], ["a1"] + "1", "Barcode Album", [track], ["1001"] ) self.tidal.api.get_albums = Mock( @@ -412,16 +433,16 @@ class TestCandidates(TidalPluginTest): return_value={ "data": { "relationships": { - "albums": {"data": [{"id": "al1", "type": "albums"}]} + "albums": {"data": [{"id": "1", "type": "albums"}]} } } } ) # Mock album lookup by ID - track = _make_track("t1", "Album Track", "PT3M", "ISRC001", ["a1"]) + track = _make_track("101", "Album Track", "PT3M", "ISRC001", ["1001"]) album, track_lookup, artist_lookup = _make_album( - "al1", "Query Album", [track], ["a1"] + "1", "Query Album", [track], ["1001"] ) self.tidal.api.get_albums = Mock( return_value={ @@ -446,9 +467,9 @@ class TestItemCandidates(TidalPluginTest): def test_item_candidates_with_isrc(self): """Test that item_candidates uses ISRC lookup first.""" track = _make_track( - "490839595", "ISRC Track", "PT3M", "ISRC001", ["a1"] + "490839595", "ISRC Track", "PT3M", "ISRC001", ["1001"] ) - artist = _make_artist("a1", "ISRC Artist") + artist = _make_artist("1001", "ISRC Artist") self.tidal.api.get_tracks = Mock( return_value={"data": [track], "included": [artist]} @@ -477,9 +498,9 @@ class TestItemCandidates(TidalPluginTest): }, "included": [ _make_track( - "490839595", "Query Track", "PT3M", "ISRC002", ["a1"] + "490839595", "Query Track", "PT3M", "ISRC002", ["1001"] ), - _make_artist("a1", "Query Artist"), + _make_artist("1001", "Query Artist"), ], } ) @@ -550,3 +571,103 @@ class TestStaticHelpers: with caplog.at_level(logging.WARNING): TidalPlugin._duration_to_seconds("invalid") assert "Invalid ISO 8601 duration: invalid" in caplog.text + + def test_popularity_with_float(self): + assert TidalPlugin._popularity(0.5) == 50 + assert TidalPlugin._popularity(1.0) == 100 + assert TidalPlugin._popularity(0.0) == 0 + + def test_popularity_with_int(self): + assert TidalPlugin._popularity(50) == 50 + assert TidalPlugin._popularity(100) == 100 + assert TidalPlugin._popularity(0) == 0 + + def test_popularity_with_none(self): + assert TidalPlugin._popularity(None) is None + + +class TestTidalsync(TidalPluginTest): + """Tests for the tidalsync command.""" + + def test_sync_updates_popularity(self): + """Test that tidalsync fetches and stores popularity.""" + item = self.lib.add_item( + Item( + tidal_track_id=490839595, + title="Test Track", + artist="Test Artist", + path="/dev/null/test.flac", + ) + ) + + track = _make_track( + "490839595", "Test Track", "PT3M", "ISRC001", ["1001"] + ) + artist = _make_artist("1001", "Test Artist") + + self.tidal.api.get_tracks = Mock( + return_value={"data": [track], "included": [artist]} + ) + + self.tidal.tidalsync([item], write=False) + + assert item["tidal_track_popularity"] == 50 + assert item["tidal_updated"] is not None + assert isinstance(item["tidal_updated"], (int, float)) + + def test_sync_skips_existing(self): + """Test that tidalsync skips items with existing popularity.""" + item = self.lib.add_item( + Item( + tidal_track_id=490839595, + tidal_track_popularity=42, + tidal_updated=time.time(), + title="Test Track", + artist="Test Artist", + path="/dev/null/test.flac", + ) + ) + + self.tidal.tidalsync([item], write=False) + + assert item["tidal_track_popularity"] == 42 + + def test_sync_force_updates_existing(self): + """Test that tidalsync with --force re-fetches.""" + item = self.lib.add_item( + Item( + tidal_track_id=490839595, + tidal_track_popularity=42, + tidal_updated=time.time(), + title="Test Track", + artist="Test Artist", + path="/dev/null/test.flac", + ) + ) + + track = _make_track( + "490839595", "Test Track", "PT3M", "ISRC001", ["1001"] + ) + artist = _make_artist("1001", "Test Artist") + + self.tidal.api.get_tracks = Mock( + return_value={"data": [track], "included": [artist]} + ) + + self.tidal.tidalsync([item], write=False, force=True) + + assert item["tidal_track_popularity"] == 50 + + def test_sync_skips_items_without_track_id(self): + """Test that tidalsync skips items without tidal_track_id.""" + item = self.lib.add_item( + Item( + title="No ID Track", + artist="No ID Artist", + path="/dev/null/test.flac", + ) + ) + + self.tidal.tidalsync([item], write=False) + + assert item.get("tidal_track_popularity") is None