Find the current song's URL and pass it on when possible

This commit is contained in:
Danielle McLean 2024-07-23 13:29:50 +10:00
parent b8bcdc5a83
commit d9c8e0fe28
Signed by: 00dani
GPG key ID: 6854781A0488421C
9 changed files with 79 additions and 21 deletions

View file

@ -24,6 +24,12 @@
"title": "Host",
"type": "string"
},
"music_directory": {
"description": "Your music directory, just as it's set up in your mpd.conf. mpd-now-playable uses this setting to figure out an absolute file:// URL for the current song, which MPNowPlayingInfoCenter will use to display cool stuff like audio waveforms. It'll still work fine without setting this, though.",
"format": "directory-path",
"title": "Music Directory",
"type": "string"
},
"password": {
"description": "The password required to connect to your MPD instance, if you need one.",
"format": "password",

View file

@ -194,6 +194,12 @@
"description": "The track number the song has on its album. This is usually one-based, but it's just an arbitrary audio tag so a particular album might start at zero or do something weird with it.",
"title": "Track",
"type": "integer"
},
"url": {
"description": "An absolute URL referring to the current song, if available. If the song's a local file and its absolute path can be determined (mpd-now-playable has been configured with your music directory), then this field will contain a file:// URL. If the song's remote, then MPD itself returns an absolute URL in the first place.",
"format": "uri",
"title": "Url",
"type": "string"
}
},
"required": [

View file

@ -4,7 +4,7 @@ from typing import Annotated, Literal, Optional, Protocol
from pydantic import Field
from ..tools.schema.define import schema
from .fields import Host, Password, Port, Url
from ..tools.schema.fields import DirectoryPath, Host, Password, Port, Url
__all__ = (
"Config",
@ -55,6 +55,12 @@ class MpdConfig:
#: servers on one machine for some reason, you probably haven't changed this
#: from the default port, 6600.
port: Port = Port(6600)
#: Your music directory, just as it's set up in your mpd.conf.
#: mpd-now-playable uses this setting to figure out an absolute file:// URL
#: for the current song, which MPNowPlayingInfoCenter will use to display
#: cool stuff like audio waveforms. It'll still work fine without setting
#: this, though.
music_directory: Optional[DirectoryPath] = None
@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/config-v1.json")

View file

@ -16,31 +16,48 @@ from .artwork_cache import MpdArtworkCache
from .types import MpdState
def mpd_file_to_uri(config: MpdConfig, file: str) -> URL | None:
url = URL(file)
if url.scheme != "":
# We already got an absolute URL - probably a stream? - so we can just return it.
return url
if not config.music_directory:
# We have a relative song URI, but we can't make it absolute since no music directory is configured.
return None
# Prepend the configured music directory, then turn the whole path into a file:// URL.
abs_file = config.music_directory / file
return URL(abs_file.as_uri())
def mpd_state_to_song(config: MpdConfig, mpd: MpdState) -> Song:
file = mpd.current["file"]
url = mpd_file_to_uri(config, file)
def mpd_state_to_song(mpd: MpdState) -> Song:
status = mpd.status
current = mpd.current
return Song(
state=PlaybackState(status["state"]),
queue_index=int(current["pos"]),
queue_length=int(status["playlistlength"]),
file=Path(current["file"]),
title=current.get("title"),
artist=un_maybe_plural(current.get("artist")),
album=un_maybe_plural(current.get("album")),
album_artist=un_maybe_plural(current.get("albumartist")),
composer=un_maybe_plural(current.get("composer")),
genre=un_maybe_plural(current.get("genre")),
track=option_fmap(int, current.get("track")),
disc=option_fmap(int, current.get("disc")),
duration=float(status["duration"]),
elapsed=float(status["elapsed"]),
state=PlaybackState(mpd.status["state"]),
queue_index=int(mpd.current["pos"]),
queue_length=int(mpd.status["playlistlength"]),
file=Path(file),
url=url,
title=mpd.current.get("title"),
artist=un_maybe_plural(mpd.current.get("artist")),
album=un_maybe_plural(mpd.current.get("album")),
album_artist=un_maybe_plural(mpd.current.get("albumartist")),
composer=un_maybe_plural(mpd.current.get("composer")),
genre=un_maybe_plural(mpd.current.get("genre")),
track=option_fmap(int, mpd.current.get("track")),
disc=option_fmap(int, mpd.current.get("disc")),
duration=float(mpd.status["duration"]),
elapsed=float(mpd.status["elapsed"]),
musicbrainz=to_brainz(mpd.current),
art=to_artwork(mpd.art),
)
class MpdStateListener(Player):
config: MpdConfig
client: MPDClient
receivers: Iterable[Receiver]
art_cache: MpdArtworkCache
@ -53,6 +70,7 @@ class MpdStateListener(Player):
)
async def start(self, conf: MpdConfig) -> None:
self.config = conf
print(f"Connecting to MPD server {conf.host}:{conf.port}...")
await self.client.connect(conf.host, conf.port)
if conf.password is not None:
@ -93,7 +111,7 @@ class MpdStateListener(Player):
return
state = MpdState(status, current, art)
song = mpd_state_to_song(state)
song = mpd_state_to_song(self.config, state)
rprint(song)
await self.update(song)

View file

@ -25,6 +25,7 @@ from MediaPlayer import (
MPNowPlayingInfoCenter,
MPNowPlayingInfoMediaTypeAudio,
MPNowPlayingInfoMediaTypeNone,
MPNowPlayingInfoPropertyAssetURL,
MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPNowPlayingInfoPropertyExternalContentIdentifier,
MPNowPlayingInfoPropertyMediaType,
@ -106,6 +107,9 @@ def song_to_media_item(song: Song) -> NSMutableDictionary:
nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer)
nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration
if song.url is not None:
nowplaying_info[MPNowPlayingInfoPropertyAssetURL] = song.url.human_repr()
# MPD can't play back music at different rates, so we just want to set it
# to 1.0 if the song is playing. (Leave it at 0.0 if the song is paused.)
if song.state == PlaybackState.play:

View file

@ -3,6 +3,7 @@ from pathlib import Path
import ormsgpack
from websockets import broadcast
from websockets.server import WebSocketServerProtocol, serve
from yarl import URL
from ...config.model import WebsocketsReceiverConfig
from ...player import Player
@ -15,6 +16,8 @@ MSGPACK_NULL = ormsgpack.packb(None)
def default(value: object) -> object:
if isinstance(value, Path):
return str(value)
if isinstance(value, URL):
return value.human_repr()
raise TypeError

View file

@ -3,6 +3,7 @@ from enum import StrEnum
from pathlib import Path
from ..tools.schema.define import schema
from ..tools.schema.fields import Url
from .artwork import Artwork
from .musicbrainz import MusicBrainzIds
@ -14,7 +15,7 @@ class PlaybackState(StrEnum):
@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json")
@dataclass(slots=True)
@dataclass(slots=True, kw_only=True)
class Song:
#: Whether MPD is currently playing, paused, or stopped. Pretty simple.
state: PlaybackState
@ -30,6 +31,13 @@ class Song:
#: places, so you can safely do the same.
file: Path
#: An absolute URL referring to the current song, if available. If the
#: song's a local file and its absolute path can be determined
#: (mpd-now-playable has been configured with your music directory), then
#: this field will contain a file:// URL. If the song's remote, then MPD
#: itself returns an absolute URL in the first place.
url: Url | None = None
#: The song's title, if it's been tagged with one. Currently only one title
#: is supported, since it doesn't make a lot of sense to tag a single audio
#: file with multiple titles.

View file

@ -1,7 +1,9 @@
from os.path import expanduser
from typing import Annotated, NewType
from annotated_types import Ge, Le
from pydantic import (
BeforeValidator,
Field,
PlainSerializer,
PlainValidator,
@ -9,9 +11,12 @@ from pydantic import (
Strict,
WithJsonSchema,
)
from pydantic import (
DirectoryPath as DirectoryType,
)
from yarl import URL as Yarl
__all__ = ("Host", "Password", "Port", "Url")
__all__ = ("DirectoryPath", "Host", "Password", "Port", "Url")
def from_yarl(url: Yarl) -> str:
@ -24,6 +29,7 @@ def to_yarl(value: object) -> Yarl:
raise NotImplementedError(f"Cannot convert {type(object)} to URL")
DirectoryPath = Annotated[DirectoryType, BeforeValidator(expanduser)]
Host = NewType(
"Host", Annotated[str, Strict(), Field(json_schema_extra={"format": "hostname"})]
)

View file

@ -34,6 +34,7 @@ MPNowPlayingInfoPropertyPlaybackQueueIndex: Final = (
MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = (
"MPNowPlayingInfoPropertyElapsedPlaybackTime"
)
MPNowPlayingInfoPropertyAssetURL: Final = "MPNowPlayingInfoPropertyAssetURL"
MPNowPlayingInfoPropertyExternalContentIdentifier: Final = (
"MPNowPlayingInfoPropertyExternalContentIdentifier"
)