diff --git a/src/mpd_now_playable/mpd/listener.py b/src/mpd_now_playable/mpd/listener.py index f787223..50f256a 100644 --- a/src/mpd_now_playable/mpd/listener.py +++ b/src/mpd_now_playable/mpd/listener.py @@ -1,7 +1,6 @@ import asyncio from collections.abc import Iterable from pathlib import Path -from uuid import UUID from mpd.asyncio import MPDClient from mpd.base import CommandError @@ -10,7 +9,7 @@ from yarl import URL from ..config.model import MpdConfig from ..player import Player -from ..song import Artwork, PlaybackState, Song, to_artwork +from ..song import Artwork, PlaybackState, Song, to_artwork, to_brainz from ..song_receiver import Receiver from ..tools.types import convert_if_exists, un_maybe_plural from .artwork_cache import MpdArtworkCache @@ -25,10 +24,6 @@ def mpd_current_to_song( queue_index=int(current["pos"]), queue_length=int(status["playlistlength"]), file=Path(current["file"]), - musicbrainz_trackid=convert_if_exists(current.get("musicbrainz_trackid"), UUID), - musicbrainz_releasetrackid=convert_if_exists( - current.get("musicbrainz_releasetrackid"), UUID - ), title=current.get("title"), artist=un_maybe_plural(current.get("artist")), album=un_maybe_plural(current.get("album")), @@ -39,6 +34,7 @@ def mpd_current_to_song( disc=convert_if_exists(current.get("disc"), int), duration=float(status["duration"]), elapsed=float(status["elapsed"]), + musicbrainz=to_brainz(current), art=art, ) diff --git a/src/mpd_now_playable/receivers/cocoa/persistent_id.py b/src/mpd_now_playable/receivers/cocoa/persistent_id.py index 0ce50a1..12c992d 100644 --- a/src/mpd_now_playable/receivers/cocoa/persistent_id.py +++ b/src/mpd_now_playable/receivers/cocoa/persistent_id.py @@ -7,25 +7,27 @@ from ...song import Song # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise. HASH_PERSON_PREFIX: Final = b"mnp.mac." -TRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" -RELEASETRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rtid" +RECORDING_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rid" +TRACK_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f" PERSISTENT_ID_BITS: Final = 64 PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8 -def digest_trackid(trackid: UUID) -> bytes: +def digest_recording_id(recording_id: UUID) -> bytes: return blake2b( - trackid.bytes, digest_size=PERSISTENT_ID_BYTES, person=TRACKID_HASH_PERSON + recording_id.bytes, + digest_size=PERSISTENT_ID_BYTES, + person=RECORDING_ID_HASH_PERSON, ).digest() -def digest_releasetrackid(trackid: UUID) -> bytes: +def digest_track_id(track_id: UUID) -> bytes: return blake2b( - trackid.bytes, + track_id.bytes, digest_size=PERSISTENT_ID_BYTES, - person=RELEASETRACKID_HASH_PERSON, + person=TRACK_ID_HASH_PERSON, ).digest() @@ -41,10 +43,10 @@ def digest_file_uri(file: Path) -> bytes: # that from the file URI. BLAKE2 can be customised to different digest sizes, # making it perfect for this problem. def song_to_persistent_id(song: Song) -> int: - if song.musicbrainz_trackid: - hashed_id = digest_trackid(song.musicbrainz_trackid) - elif song.musicbrainz_releasetrackid: - hashed_id = digest_releasetrackid(song.musicbrainz_releasetrackid) + if song.musicbrainz.recording: + hashed_id = digest_recording_id(song.musicbrainz.recording) + elif song.musicbrainz.track: + hashed_id = digest_track_id(song.musicbrainz.track) else: hashed_id = digest_file_uri(song.file) return int.from_bytes(hashed_id) diff --git a/src/mpd_now_playable/song.py b/src/mpd_now_playable/song.py deleted file mode 100644 index 487c9cb..0000000 --- a/src/mpd_now_playable/song.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path -from typing import Literal -from uuid import UUID - -from pydantic.type_adapter import TypeAdapter - - -class PlaybackState(StrEnum): - play = "play" - pause = "pause" - stop = "stop" - - -@dataclass(slots=True) -class HasArtwork: - data: bytes = field(repr=False) - - -@dataclass(slots=True) -class NoArtwork: - def __bool__(self) -> Literal[False]: - return False - - -Artwork = HasArtwork | NoArtwork -ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) - - -def to_artwork(art: bytes | None) -> Artwork: - if art is None: - return NoArtwork() - return HasArtwork(art) - - -@dataclass(slots=True) -class Song: - state: PlaybackState - queue_index: int - queue_length: int - file: Path - musicbrainz_trackid: UUID | None - musicbrainz_releasetrackid: UUID | None - title: str | None - artist: list[str] - composer: list[str] - album: list[str] - album_artist: list[str] - track: int | None - disc: int | None - genre: list[str] - duration: float - elapsed: float - art: Artwork diff --git a/src/mpd_now_playable/song/__init__.py b/src/mpd_now_playable/song/__init__.py new file mode 100644 index 0000000..80e5f4f --- /dev/null +++ b/src/mpd_now_playable/song/__init__.py @@ -0,0 +1,12 @@ +from .artwork import Artwork, ArtworkSchema, to_artwork +from .musicbrainz import to_brainz +from .song import PlaybackState, Song + +__all__ = ( + "Artwork", + "ArtworkSchema", + "to_artwork", + "to_brainz", + "PlaybackState", + "Song", +) diff --git a/src/mpd_now_playable/song/artwork.py b/src/mpd_now_playable/song/artwork.py new file mode 100644 index 0000000..4c4d941 --- /dev/null +++ b/src/mpd_now_playable/song/artwork.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import Literal + +from pydantic.type_adapter import TypeAdapter + + +@dataclass(slots=True) +class HasArtwork: + data: bytes = field(repr=False) + + +@dataclass(slots=True) +class NoArtwork: + def __bool__(self) -> Literal[False]: + return False + + +Artwork = HasArtwork | NoArtwork +ArtworkSchema: TypeAdapter[Artwork] = TypeAdapter(HasArtwork | NoArtwork) + + +def to_artwork(art: bytes | None) -> Artwork: + if art is None: + return NoArtwork() + return HasArtwork(art) diff --git a/src/mpd_now_playable/song/musicbrainz.py b/src/mpd_now_playable/song/musicbrainz.py new file mode 100644 index 0000000..7478d2c --- /dev/null +++ b/src/mpd_now_playable/song/musicbrainz.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from functools import partial +from typing import TypedDict +from uuid import UUID + +from ..tools.types import option_fmap + +option_uuid = partial(option_fmap, UUID) + + +class MusicBrainzTags(TypedDict, total=False): + """ + The MusicBrainz tags mpd-now-playable expects and will load (all optional). + They're named slightly differently than the actual MusicBrainz IDs they + store - use to_brainz to map them across to their canonical form. + https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html + """ + + #: MusicBrainz Recording ID + musicbrainz_trackid: str + #: MusicBrainz Track ID + musicbrainz_releasetrackid: str + #: MusicBrainz Artist ID + musicbrainz_artistid: str + #: MusicBrainz Release ID + musicbrainz_albumid: str + #: MusicBrainz Release Artist ID + musicbrainz_albumartistid: str + #: MusicBrainz Release Group ID + musicbrainz_releasegroupid: str + #: MusicBrainz Work ID + musicbrainz_workid: str + + +@dataclass(slots=True) +class MusicBrainzIds: + #: A MusicBrainz recording represents audio from a specific performance. + #: For example, if the same song was released as a studio recording and as + #: a live performance, those two versions of the song are different + #: recordings. The song itself is considered a "work", of which two + #: recordings were made. However, recordings are not always associated with + #: a work in the MusicBrainz database, and Picard won't load work IDs by + #: default (you have to enable "use track relationships" in the options), + #: so recording IDs are a much more reliable way to identify a particular + #: song. + #: https://musicbrainz.org/doc/Recording + recording: UUID | None + + #: A MusicBrainz work represents the idea of a particular song or creation + #: (it doesn't have to be audio). Each work may have multiple recordings + #: (studio versus live, different performers, etc.), with the work ID + #: grouping them together. + #: https://musicbrainz.org/doc/Work + work: UUID | None + + #: A MusicBrainz track represents a specific instance of a recording + #: appearing as part of some release. For example, if the same song appears + #: on both two-CD and four-CD versions of a soundtrack, then it will be + #: considered the same "recording" in both cases, but different "tracks". + #: https://musicbrainz.org/doc/Track + track: UUID | None + + #: https://musicbrainz.org/doc/Artist + artist: UUID | None + + #: A MusicBrainz release roughly corresponds to an "album", and indeed is + #: stored in a tag called MUSICBRAINZ_ALBUMID. The more general name is + #: meant to encompass all the different ways music can be released. + #: https://musicbrainz.org/doc/Release + release: UUID | None + + #: Again, the release artist corresponds to an "album artist". These MBIDs + #: refer to the same artists in the MusicBrainz database that individual + #: recordings' artist MBIDs do. + release_artist: UUID | None + + #: A MusicBrainz release group roughly corresponds to "all the editions of + #: a particular album". For example, if the same album were released on CD, + #: vinyl records, and as a digital download, then all of those would be + #: different releases but share a release group. Note that MPD's support + #: for this tag is relatively new (July 2023) and doesn't seem especially + #: reliable, so it might be missing here even if your music has been tagged + #: with it. Not sure why. https://musicbrainz.org/doc/Release_Group + release_group: UUID | None + + +def to_brainz(tags: MusicBrainzTags) -> MusicBrainzIds: + return MusicBrainzIds( + recording=option_uuid(tags.get("musicbrainz_trackid")), + work=option_uuid(tags.get("musicbrainz_workid")), + track=option_uuid(tags.get("musicbrainz_releasetrackid")), + artist=option_uuid(tags.get("musicbrainz_artistid")), + release=option_uuid(tags.get("musicbrainz_albumid")), + release_artist=option_uuid(tags.get("musicbrainz_albumartistid")), + release_group=option_uuid(tags.get("musicbrainz_releasegroupid")), + ) diff --git a/src/mpd_now_playable/song/song.py b/src/mpd_now_playable/song/song.py new file mode 100644 index 0000000..e30c9ca --- /dev/null +++ b/src/mpd_now_playable/song/song.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from .artwork import Artwork +from .musicbrainz import MusicBrainzIds + + +class PlaybackState(StrEnum): + play = "play" + pause = "pause" + stop = "stop" + + +@dataclass(slots=True) +class Song: + state: PlaybackState + queue_index: int + queue_length: int + file: Path + title: str | None + artist: list[str] + composer: list[str] + album: list[str] + album_artist: list[str] + track: int | None + disc: int | None + genre: list[str] + duration: float + elapsed: float + art: Artwork + musicbrainz: MusicBrainzIds diff --git a/src/mpd_now_playable/tools/types.py b/src/mpd_now_playable/tools/types.py index 023641d..da62c90 100644 --- a/src/mpd_now_playable/tools/types.py +++ b/src/mpd_now_playable/tools/types.py @@ -4,6 +4,7 @@ from typing import Any, TypeAlias, TypeVar __all__ = ( "AnyExceptList", "MaybePlural", + "option_fmap", "convert_if_exists", "un_maybe_plural", ) @@ -29,6 +30,7 @@ AnyExceptList = ( U = TypeVar("U") +V = TypeVar("V") def not_none(value: U | None) -> U: @@ -37,6 +39,12 @@ def not_none(value: U | None) -> U: return value +def option_fmap(f: Callable[[U], V], value: U | None) -> V | None: + if value is None: + return None + return f(value) + + def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None: if value is None: return None