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

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

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__ = (
"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