This commit is contained in:
aaronk6 2026-02-06 23:54:17 +01:00 committed by GitHub
commit 7fee76a3ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 265 additions and 19 deletions

View file

@ -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"),

View file

@ -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"],

View file

@ -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``

View file

@ -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``

View file

@ -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"

View file

@ -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"