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", "title": "Host",
"type": "string" "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": { "password": {
"description": "The password required to connect to your MPD instance, if you need one.", "description": "The password required to connect to your MPD instance, if you need one.",
"format": "password", "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.", "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", "title": "Track",
"type": "integer" "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": [ "required": [

View file

@ -4,7 +4,7 @@ from typing import Annotated, Literal, Optional, Protocol
from pydantic import Field from pydantic import Field
from ..tools.schema.define import schema from ..tools.schema.define import schema
from .fields import Host, Password, Port, Url from ..tools.schema.fields import DirectoryPath, Host, Password, Port, Url
__all__ = ( __all__ = (
"Config", "Config",
@ -55,6 +55,12 @@ class MpdConfig:
#: servers on one machine for some reason, you probably haven't changed this #: servers on one machine for some reason, you probably haven't changed this
#: from the default port, 6600. #: from the default port, 6600.
port: Port = 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") @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 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( return Song(
state=PlaybackState(status["state"]), state=PlaybackState(mpd.status["state"]),
queue_index=int(current["pos"]), queue_index=int(mpd.current["pos"]),
queue_length=int(status["playlistlength"]), queue_length=int(mpd.status["playlistlength"]),
file=Path(current["file"]), file=Path(file),
title=current.get("title"), url=url,
artist=un_maybe_plural(current.get("artist")), title=mpd.current.get("title"),
album=un_maybe_plural(current.get("album")), artist=un_maybe_plural(mpd.current.get("artist")),
album_artist=un_maybe_plural(current.get("albumartist")), album=un_maybe_plural(mpd.current.get("album")),
composer=un_maybe_plural(current.get("composer")), album_artist=un_maybe_plural(mpd.current.get("albumartist")),
genre=un_maybe_plural(current.get("genre")), composer=un_maybe_plural(mpd.current.get("composer")),
track=option_fmap(int, current.get("track")), genre=un_maybe_plural(mpd.current.get("genre")),
disc=option_fmap(int, current.get("disc")), track=option_fmap(int, mpd.current.get("track")),
duration=float(status["duration"]), disc=option_fmap(int, mpd.current.get("disc")),
elapsed=float(status["elapsed"]), duration=float(mpd.status["duration"]),
elapsed=float(mpd.status["elapsed"]),
musicbrainz=to_brainz(mpd.current), musicbrainz=to_brainz(mpd.current),
art=to_artwork(mpd.art), art=to_artwork(mpd.art),
) )
class MpdStateListener(Player): class MpdStateListener(Player):
config: MpdConfig
client: MPDClient client: MPDClient
receivers: Iterable[Receiver] receivers: Iterable[Receiver]
art_cache: MpdArtworkCache art_cache: MpdArtworkCache
@ -53,6 +70,7 @@ class MpdStateListener(Player):
) )
async def start(self, conf: MpdConfig) -> None: async def start(self, conf: MpdConfig) -> None:
self.config = conf
print(f"Connecting to MPD server {conf.host}:{conf.port}...") print(f"Connecting to MPD server {conf.host}:{conf.port}...")
await self.client.connect(conf.host, conf.port) await self.client.connect(conf.host, conf.port)
if conf.password is not None: if conf.password is not None:
@ -93,7 +111,7 @@ class MpdStateListener(Player):
return return
state = MpdState(status, current, art) state = MpdState(status, current, art)
song = mpd_state_to_song(state) song = mpd_state_to_song(self.config, state)
rprint(song) rprint(song)
await self.update(song) await self.update(song)

View file

@ -25,6 +25,7 @@ from MediaPlayer import (
MPNowPlayingInfoCenter, MPNowPlayingInfoCenter,
MPNowPlayingInfoMediaTypeAudio, MPNowPlayingInfoMediaTypeAudio,
MPNowPlayingInfoMediaTypeNone, MPNowPlayingInfoMediaTypeNone,
MPNowPlayingInfoPropertyAssetURL,
MPNowPlayingInfoPropertyElapsedPlaybackTime, MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPNowPlayingInfoPropertyExternalContentIdentifier, MPNowPlayingInfoPropertyExternalContentIdentifier,
MPNowPlayingInfoPropertyMediaType, MPNowPlayingInfoPropertyMediaType,
@ -106,6 +107,9 @@ def song_to_media_item(song: Song) -> NSMutableDictionary:
nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer) nowplaying_info[MPMediaItemPropertyComposer] = join_plural_field(song.composer)
nowplaying_info[MPMediaItemPropertyPlaybackDuration] = song.duration 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 # 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.) # to 1.0 if the song is playing. (Leave it at 0.0 if the song is paused.)
if song.state == PlaybackState.play: if song.state == PlaybackState.play:

View file

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

View file

@ -3,6 +3,7 @@ from enum import StrEnum
from pathlib import Path from pathlib import Path
from ..tools.schema.define import schema from ..tools.schema.define import schema
from ..tools.schema.fields import Url
from .artwork import Artwork from .artwork import Artwork
from .musicbrainz import MusicBrainzIds from .musicbrainz import MusicBrainzIds
@ -14,7 +15,7 @@ class PlaybackState(StrEnum):
@schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json") @schema("https://cdn.00dani.me/m/schemata/mpd-now-playable/song-v1.json")
@dataclass(slots=True) @dataclass(slots=True, kw_only=True)
class Song: class Song:
#: Whether MPD is currently playing, paused, or stopped. Pretty simple. #: Whether MPD is currently playing, paused, or stopped. Pretty simple.
state: PlaybackState state: PlaybackState
@ -30,6 +31,13 @@ class Song:
#: places, so you can safely do the same. #: places, so you can safely do the same.
file: Path 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 #: 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 #: is supported, since it doesn't make a lot of sense to tag a single audio
#: file with multiple titles. #: file with multiple titles.

View file

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

View file

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