Expand MusicBrainz support to be much more comprehensive

This commit is contained in:
Danielle McLean 2024-07-11 12:12:56 +10:00
parent 60116fd616
commit 09fe3b3e6c
Signed by: 00dani
GPG key ID: 6854781A0488421C
8 changed files with 188 additions and 72 deletions

View file

@ -1,7 +1,6 @@
import asyncio import asyncio
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from uuid import UUID
from mpd.asyncio import MPDClient from mpd.asyncio import MPDClient
from mpd.base import CommandError from mpd.base import CommandError
@ -10,7 +9,7 @@ from yarl import URL
from ..config.model import MpdConfig from ..config.model import MpdConfig
from ..player import Player 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 ..song_receiver import Receiver
from ..tools.types import convert_if_exists, un_maybe_plural from ..tools.types import convert_if_exists, un_maybe_plural
from .artwork_cache import MpdArtworkCache from .artwork_cache import MpdArtworkCache
@ -25,10 +24,6 @@ def mpd_current_to_song(
queue_index=int(current["pos"]), queue_index=int(current["pos"]),
queue_length=int(status["playlistlength"]), queue_length=int(status["playlistlength"]),
file=Path(current["file"]), 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"), title=current.get("title"),
artist=un_maybe_plural(current.get("artist")), artist=un_maybe_plural(current.get("artist")),
album=un_maybe_plural(current.get("album")), album=un_maybe_plural(current.get("album")),
@ -39,6 +34,7 @@ def mpd_current_to_song(
disc=convert_if_exists(current.get("disc"), int), disc=convert_if_exists(current.get("disc"), int),
duration=float(status["duration"]), duration=float(status["duration"]),
elapsed=float(status["elapsed"]), elapsed=float(status["elapsed"]),
musicbrainz=to_brainz(current),
art=art, art=art,
) )

View file

@ -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. # The maximum size for a BLAKE2b "person" value is sixteen bytes, so we need to be concise.
HASH_PERSON_PREFIX: Final = b"mnp.mac." HASH_PERSON_PREFIX: Final = b"mnp.mac."
TRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid" RECORDING_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rid"
RELEASETRACKID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_rtid" TRACK_ID_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"mb_tid"
FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f" FILE_HASH_PERSON: Final = HASH_PERSON_PREFIX + b"f"
PERSISTENT_ID_BITS: Final = 64 PERSISTENT_ID_BITS: Final = 64
PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8 PERSISTENT_ID_BYTES: Final = PERSISTENT_ID_BITS // 8
def digest_trackid(trackid: UUID) -> bytes: def digest_recording_id(recording_id: UUID) -> bytes:
return blake2b( 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() ).digest()
def digest_releasetrackid(trackid: UUID) -> bytes: def digest_track_id(track_id: UUID) -> bytes:
return blake2b( return blake2b(
trackid.bytes, track_id.bytes,
digest_size=PERSISTENT_ID_BYTES, digest_size=PERSISTENT_ID_BYTES,
person=RELEASETRACKID_HASH_PERSON, person=TRACK_ID_HASH_PERSON,
).digest() ).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, # that from the file URI. BLAKE2 can be customised to different digest sizes,
# making it perfect for this problem. # making it perfect for this problem.
def song_to_persistent_id(song: Song) -> int: def song_to_persistent_id(song: Song) -> int:
if song.musicbrainz_trackid: if song.musicbrainz.recording:
hashed_id = digest_trackid(song.musicbrainz_trackid) hashed_id = digest_recording_id(song.musicbrainz.recording)
elif song.musicbrainz_releasetrackid: elif song.musicbrainz.track:
hashed_id = digest_releasetrackid(song.musicbrainz_releasetrackid) hashed_id = digest_track_id(song.musicbrainz.track)
else: else:
hashed_id = digest_file_uri(song.file) hashed_id = digest_file_uri(song.file)
return int.from_bytes(hashed_id) return int.from_bytes(hashed_id)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ from typing import Any, TypeAlias, TypeVar
__all__ = ( __all__ = (
"AnyExceptList", "AnyExceptList",
"MaybePlural", "MaybePlural",
"option_fmap",
"convert_if_exists", "convert_if_exists",
"un_maybe_plural", "un_maybe_plural",
) )
@ -29,6 +30,7 @@ AnyExceptList = (
U = TypeVar("U") U = TypeVar("U")
V = TypeVar("V")
def not_none(value: U | None) -> U: def not_none(value: U | None) -> U:
@ -37,6 +39,12 @@ def not_none(value: U | None) -> U:
return value 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: def convert_if_exists(value: str | None, converter: Callable[[str], U]) -> U | None:
if value is None: if value is None:
return None return None