Expand MusicBrainz support to be much more comprehensive
This commit is contained in:
parent
60116fd616
commit
09fe3b3e6c
8 changed files with 188 additions and 72 deletions
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
12
src/mpd_now_playable/song/__init__.py
Normal file
12
src/mpd_now_playable/song/__init__.py
Normal 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",
|
||||||
|
)
|
25
src/mpd_now_playable/song/artwork.py
Normal file
25
src/mpd_now_playable/song/artwork.py
Normal 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)
|
96
src/mpd_now_playable/song/musicbrainz.py
Normal file
96
src/mpd_now_playable/song/musicbrainz.py
Normal 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")),
|
||||||
|
)
|
32
src/mpd_now_playable/song/song.py
Normal file
32
src/mpd_now_playable/song/song.py
Normal 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
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue