diff --git a/beetsplug/deezer.py b/beetsplug/deezer.py index 61b028361..9a99ef045 100644 --- a/beetsplug/deezer.py +++ b/beetsplug/deezer.py @@ -20,6 +20,7 @@ import collections import time from typing import TYPE_CHECKING, ClassVar, Literal +import mediafile import requests from beets import ui @@ -40,6 +41,8 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): item_types: ClassVar[dict[str, types.Type]] = { "deezer_track_rank": types.INTEGER, "deezer_track_id": types.INTEGER, + "deezer_album_id": types.INTEGER, + "deezer_artist_id": types.INTEGER, "deezer_updated": types.DATE, } # Base URLs for the Deezer API @@ -51,6 +54,20 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): def __init__(self) -> None: super().__init__() + # Register Deezer ID fields as media fields + for field in ["deezer_track_id", "deezer_album_id", "deezer_artist_id"]: + media_field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(field), + mediafile.MP4StorageStyle(f"----:com.apple.iTunes:{field}"), + mediafile.StorageStyle(field), + mediafile.ASFStorageStyle(field), + ) + try: + self.add_media_field(field, media_field) + except ValueError: + # Ignore errors due to duplicate registration + pass + def commands(self): """Add beet UI commands to interact with Deezer.""" deezer_update_cmd = ui.Subcommand( @@ -127,11 +144,10 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): 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, + deezer_artist_id=artist_id, tracks=tracks, albumtype=album_data["record_type"], va=( @@ -202,11 +218,10 @@ class DeezerPlugin(SearchApiMetadataSourcePlugin[IDResponse]): ) return TrackInfo( title=track_data["title"], - track_id=track_data["id"], deezer_track_id=track_data["id"], isrc=track_data.get("isrc"), artist=artist, - artist_id=artist_id, + deezer_artist_id=artist_id, length=track_data["duration"], index=track_data.get("track_position"), medium=track_data.get("disk_number"), diff --git a/beetsplug/spotify.py b/beetsplug/spotify.py index 9b26b1e49..f64767a1e 100644 --- a/beetsplug/spotify.py +++ b/beetsplug/spotify.py @@ -30,6 +30,7 @@ import webbrowser from typing import TYPE_CHECKING, Any, ClassVar, Literal import confuse +import mediafile import requests from beets import ui @@ -152,6 +153,25 @@ class SpotifyPlugin( self._audio_features_lock = ( threading.Lock() ) # Protects audio_features_available + + # Register Spotify ID fields as media fields + for field in [ + "spotify_track_id", + "spotify_album_id", + "spotify_artist_id", + ]: + media_field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(field), + mediafile.MP4StorageStyle(f"----:com.apple.iTunes:{field}"), + mediafile.StorageStyle(field), + mediafile.ASFStorageStyle(field), + ) + try: + self.add_media_field(field, media_field) + except ValueError: + # Ignore errors due to duplicate registration + pass + self.setup() def setup(self): @@ -373,13 +393,10 @@ class SpotifyPlugin( return AlbumInfo( album=album_data["name"], - album_id=spotify_id, spotify_album_id=spotify_id, artist=artist, - artist_id=artists_ids[0] if len(artists_ids) > 0 else None, spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, - artists_ids=artists_ids, tracks=tracks, albumtype=album_data["album_type"], va=len(album_data["artists"]) == 1 @@ -414,14 +431,11 @@ class SpotifyPlugin( album = None return TrackInfo( title=track_data["name"], - track_id=track_data["id"], spotify_track_id=track_data["id"], artist=artist, album=album, - artist_id=artists_ids[0] if len(artists_ids) > 0 else None, spotify_artist_id=artists_ids[0] if len(artists_ids) > 0 else None, artists=artists_names, - artists_ids=artists_ids, length=track_data["duration_ms"] / 1000, index=track_data["track_number"], medium=track_data["disc_number"], diff --git a/docs/plugins/deezer.rst b/docs/plugins/deezer.rst index d44a565ce..3e978cef5 100644 --- a/docs/plugins/deezer.rst +++ b/docs/plugins/deezer.rst @@ -57,3 +57,13 @@ The ``deezer`` plugin provides an additional command ``deezerupdate`` to update the ``rank`` information from Deezer. The ``rank`` (ranges from 0 to 1M) is a global indicator of a song's popularity on Deezer that is updated daily based on streams. The higher the ``rank``, the more popular the track is. + +Stored Fields +------------- + +When Deezer is used as a metadata source during import, the plugin stores these +identifiers: + +- ``deezer_track_id`` +- ``deezer_album_id`` +- ``deezer_artist_id`` diff --git a/docs/plugins/spotify.rst b/docs/plugins/spotify.rst index f0d6ac2ef..f04351380 100644 --- a/docs/plugins/spotify.rst +++ b/docs/plugins/spotify.rst @@ -181,3 +181,13 @@ these track attributes from Spotify: - ``tempo`` - ``time_signature`` - ``valence`` + +Stored Fields +------------- + +When Spotify is used as a metadata source during import, the plugin stores these +identifiers: + +- ``spotify_track_id`` +- ``spotify_album_id`` +- ``spotify_artist_id`` diff --git a/test/plugins/test_deezer.py b/test/plugins/test_deezer.py new file mode 100644 index 000000000..def472104 --- /dev/null +++ b/test/plugins/test_deezer.py @@ -0,0 +1,97 @@ +"""Tests for the 'deezer' plugin""" + +from mediafile import MediaFile + +from beets.library import Item +from beets.test.helper import PluginTestCase +from beets.util import syspath + + +class DeezerMediaFieldTest(PluginTestCase): + """Test that Deezer IDs are written to and read from media files.""" + + plugin = "deezer" + + def test_deezer_track_id_written_to_file(self): + """Verify deezer_track_id is written to media files.""" + item = self.add_item_fixture() + item.deezer_track_id = 123456789 + item.write() + + # Read back from file (media files store as strings) + mf = MediaFile(syspath(item.path)) + assert mf.deezer_track_id == "123456789" + + def test_deezer_album_id_written_to_file(self): + """Verify deezer_album_id is written to media files.""" + item = self.add_item_fixture() + item.deezer_album_id = 987654321 + item.write() + + # Read back from file (media files store as strings) + mf = MediaFile(syspath(item.path)) + assert mf.deezer_album_id == "987654321" + + def test_deezer_artist_id_written_to_file(self): + """Verify deezer_artist_id is written to media files.""" + item = self.add_item_fixture() + item.deezer_artist_id = 111222333 + item.write() + + # Read back from file (media files store as strings) + mf = MediaFile(syspath(item.path)) + assert mf.deezer_artist_id == "111222333" + + def test_deezer_ids_read_from_file(self): + """Verify Deezer IDs can be read from file into Item.""" + item = self.add_item_fixture() + mf = MediaFile(syspath(item.path)) + mf.deezer_track_id = "123456" + mf.deezer_album_id = "654321" + mf.deezer_artist_id = "999888" + mf.save() + + # Read back into Item (beets converts to int from item_types) + item_reloaded = Item.from_path(item.path) + assert item_reloaded.deezer_track_id == 123456 + assert item_reloaded.deezer_album_id == 654321 + assert item_reloaded.deezer_artist_id == 999888 + + def test_deezer_ids_persist_across_writes(self): + """Verify IDs are not lost when updating other fields.""" + item = self.add_item_fixture() + item.deezer_track_id = 123456 + item.deezer_album_id = 654321 + item.write() + + # Update different field + item.title = "New Title" + item.write() + + # Verify IDs still in file (media files store as strings) + mf = MediaFile(syspath(item.path)) + assert mf.deezer_track_id == "123456" + assert mf.deezer_album_id == "654321" + + def test_deezer_ids_not_in_musicbrainz_fields(self): + """Verify Deezer IDs don't pollute MusicBrainz fields.""" + item = self.add_item_fixture() + # Set Deezer IDs + item.deezer_track_id = 123456789 + item.deezer_album_id = 987654321 + item.deezer_artist_id = 111222333 + item.write() + + # Read back and verify MusicBrainz fields are NOT set to Deezer IDs + mf = MediaFile(syspath(item.path)) + + # MusicBrainz fields should be None or empty, not Deezer IDs + assert mf.mb_trackid != "123456789" + assert mf.mb_albumid != "987654321" + assert mf.mb_artistid != "111222333" + assert mf.mb_albumartistid != "111222333" + + # Deezer IDs should be in their own fields + assert mf.deezer_track_id == "123456789" + assert mf.deezer_album_id == "987654321" + assert mf.deezer_artist_id == "111222333" diff --git a/test/plugins/test_spotify.py b/test/plugins/test_spotify.py index 6e322ca0b..d127eacb7 100644 --- a/test/plugins/test_spotify.py +++ b/test/plugins/test_spotify.py @@ -4,10 +4,12 @@ import os from urllib.parse import parse_qs, urlparse import responses +from mediafile import MediaFile from beets.library import Item from beets.test import _common from beets.test.helper import PluginTestCase +from beets.util import syspath from beetsplug import spotify @@ -289,21 +291,119 @@ class SpotifyPluginTest(PluginTestCase): assert album_info is not None assert album_info.artist == "Project Skylate, Sugar Shrill" assert album_info.artists == ["Project Skylate", "Sugar Shrill"] - assert album_info.artist_id == "6m8MRXIVKb6wQaPlBIDMr1" - assert album_info.artists_ids == [ - "6m8MRXIVKb6wQaPlBIDMr1", - "4kkAIoQmNT5xEoNH5BuQLe", - ] + assert album_info.spotify_artist_id == "6m8MRXIVKb6wQaPlBIDMr1" assert len(album_info.tracks) == 1 assert album_info.tracks[0].artist == "Foo, Bar" assert album_info.tracks[0].artists == ["Foo", "Bar"] - assert album_info.tracks[0].artist_id == "12345" - assert album_info.tracks[0].artists_ids == ["12345", "67890"] + assert album_info.tracks[0].spotify_artist_id == "12345" track_info = self.spotify.track_for_id("6sjZfVJworBX6TqyjkxIJ1") assert track_info is not None assert track_info.artist == "Foo, Bar" assert track_info.artists == ["Foo", "Bar"] - assert track_info.artist_id == "12345" - assert track_info.artists_ids == ["12345", "67890"] + assert track_info.spotify_artist_id == "12345" + + +class SpotifyMediaFieldTest(PluginTestCase): + """Test that Spotify IDs are written to and read from media files.""" + + plugin = "spotify" + + @responses.activate + def setUp(self): + responses.add( + responses.POST, + spotify.SpotifyPlugin.oauth_token_url, + status=200, + json={ + "access_token": "test_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + super().setUp() + + def test_spotify_track_id_written_to_file(self): + """Verify spotify_track_id is written to media files.""" + item = self.add_item_fixture() + item.spotify_track_id = "6NPVjNh8Jhru9xOmyQigds" + item.write() + + # Read back from file + mf = MediaFile(syspath(item.path)) + assert mf.spotify_track_id == "6NPVjNh8Jhru9xOmyQigds" + + def test_spotify_album_id_written_to_file(self): + """Verify spotify_album_id is written to media files.""" + item = self.add_item_fixture() + item.spotify_album_id = "5l3zEmMrOhOzG8d8s83GOL" + item.write() + + # Read back from file + mf = MediaFile(syspath(item.path)) + assert mf.spotify_album_id == "5l3zEmMrOhOzG8d8s83GOL" + + def test_spotify_artist_id_written_to_file(self): + """Verify spotify_artist_id is written to media files.""" + item = self.add_item_fixture() + item.spotify_artist_id = "3OWO2LOPTl1u6XvJHkwHmd" + item.write() + + # Read back from file + mf = MediaFile(syspath(item.path)) + assert mf.spotify_artist_id == "3OWO2LOPTl1u6XvJHkwHmd" + + def test_spotify_ids_read_from_file(self): + """Verify Spotify IDs can be read from file into Item.""" + item = self.add_item_fixture() + mf = MediaFile(syspath(item.path)) + mf.spotify_track_id = "track123" + mf.spotify_album_id = "album456" + mf.spotify_artist_id = "artist789" + mf.save() + + # Read back into Item + item_reloaded = Item.from_path(item.path) + assert item_reloaded.spotify_track_id == "track123" + assert item_reloaded.spotify_album_id == "album456" + assert item_reloaded.spotify_artist_id == "artist789" + + def test_spotify_ids_persist_across_writes(self): + """Verify IDs are not lost when updating other fields.""" + item = self.add_item_fixture() + item.spotify_track_id = "track123" + item.spotify_album_id = "album456" + item.write() + + # Update different field + item.title = "New Title" + item.write() + + # Verify IDs still in file + mf = MediaFile(syspath(item.path)) + assert mf.spotify_track_id == "track123" + assert mf.spotify_album_id == "album456" + + def test_spotify_ids_not_in_musicbrainz_fields(self): + """Verify Spotify IDs don't pollute MusicBrainz fields.""" + item = self.add_item_fixture() + # Set Spotify IDs + item.spotify_track_id = "6NPVjNh8Jhru9xOmyQigds" + item.spotify_album_id = "5l3zEmMrOhOzG8d8s83GOL" + item.spotify_artist_id = "3OWO2LOPTl1u6XvJHkwHmd" + item.write() + + # Read back and verify MusicBrainz fields are NOT set to Spotify IDs + mf = MediaFile(syspath(item.path)) + + # MusicBrainz fields should be None or empty, not Spotify IDs + assert mf.mb_trackid != "6NPVjNh8Jhru9xOmyQigds" + assert mf.mb_albumid != "5l3zEmMrOhOzG8d8s83GOL" + assert mf.mb_artistid != "3OWO2LOPTl1u6XvJHkwHmd" + assert mf.mb_albumartistid != "3OWO2LOPTl1u6XvJHkwHmd" + + # Spotify IDs should be in their own fields + assert mf.spotify_track_id == "6NPVjNh8Jhru9xOmyQigds" + assert mf.spotify_album_id == "5l3zEmMrOhOzG8d8s83GOL" + assert mf.spotify_artist_id == "3OWO2LOPTl1u6XvJHkwHmd"