Compare commits

...

4 commits

11 changed files with 108 additions and 33 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

@ -9,37 +9,55 @@ 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, to_brainz from ..song import PlaybackState, Song, to_artwork, to_brainz
from ..song_receiver import Receiver from ..song_receiver import Receiver
from ..tools.types import option_fmap, un_maybe_plural from ..tools.types import option_fmap, un_maybe_plural
from .artwork_cache import MpdArtworkCache from .artwork_cache import MpdArtworkCache
from .types import CurrentSongResponse, StatusResponse from .types import MpdState
def mpd_current_to_song( def mpd_file_to_uri(config: MpdConfig, file: str) -> URL | None:
status: StatusResponse, current: CurrentSongResponse, art: Artwork url = URL(file)
) -> Song: 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)
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=option_fmap(float, mpd.status.get("duration")),
musicbrainz=to_brainz(current), elapsed=float(mpd.status["elapsed"]),
art=art, musicbrainz=to_brainz(mpd.current),
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
@ -52,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:
@ -77,6 +96,7 @@ class MpdStateListener(Player):
status, current = await asyncio.gather( status, current = await asyncio.gather(
self.client.status(), self.client.currentsong() self.client.status(), self.client.currentsong()
) )
state = MpdState(status, current)
if starting_idle_count != self.idle_count: if starting_idle_count != self.idle_count:
return return
@ -90,7 +110,8 @@ class MpdStateListener(Player):
if starting_idle_count != self.idle_count: if starting_idle_count != self.idle_count:
return return
song = mpd_current_to_song(status, current, to_artwork(art)) state = MpdState(status, current, art)
song = mpd_state_to_song(self.config, state)
rprint(song) rprint(song)
await self.update(song) await self.update(song)

View file

@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import Literal, NotRequired, Protocol, TypedDict from typing import Literal, NotRequired, Protocol, TypedDict
from ..song.musicbrainz import MusicBrainzTags from ..song.musicbrainz import MusicBrainzTags
@ -19,8 +20,11 @@ OneshotFlag = Literal[BooleanFlag, "oneshot"]
class StatusResponse(TypedDict): class StatusResponse(TypedDict):
state: Literal["play", "stop", "pause"] state: Literal["play", "stop", "pause"]
# The total duration and elapsed playback of the current song, measured in seconds. Fractional seconds are allowed. # The total duration and elapsed playback of the current song, measured in
duration: str # seconds. Fractional seconds are allowed. The duration field may be
# omitted because MPD cannot determine the duration of certain sources,
# such as Internet radio streams.
duration: NotRequired[str]
elapsed: str elapsed: str
# The volume value ranges from 0-100. It may be omitted from # The volume value ranges from 0-100. It may be omitted from
@ -76,3 +80,10 @@ class CurrentSongResponse(CurrentSongTags):
ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes}) ReadPictureResponse = TypedDict("ReadPictureResponse", {"binary": bytes})
@dataclass
class MpdState:
status: StatusResponse
current: CurrentSongResponse
art: bytes | None = None

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.
@ -61,8 +69,9 @@ class Song:
genre: list[str] genre: list[str]
#: The song's duration as read from its tags, measured in seconds. #: The song's duration as read from its tags, measured in seconds.
#: Fractional seconds are allowed. #: Fractional seconds are allowed. The duration may be unavailable for some
duration: float #: sources, such as internet radio streams.
duration: float | None
#: How far into the song MPD is, measured in seconds. Fractional seconds #: How far into the song MPD is, measured in seconds. Fractional seconds
#: are allowed. This is usually going to be less than or equal to the #: are allowed. This is usually going to be less than or equal to the

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

@ -11,8 +11,8 @@ from .define import ModelWithSchema
__all__ = ("write",) __all__ = ("write",)
def write(model: ModelWithSchema) -> None: def write(model: ModelWithSchema, mode: JsonSchemaMode = "validation") -> None:
schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema) schema = model.schema.json_schema(schema_generator=MyGenerateJsonSchema, mode=mode)
schema["$id"] = model.id.human_repr() schema["$id"] = model.id.human_repr()
schema_file = Path(__file__).parents[4] / "schemata" / model.id.name schema_file = Path(__file__).parents[4] / "schemata" / model.id.name
print(f"Writing this schema to {schema_file}") print(f"Writing this schema to {schema_file}")
@ -46,8 +46,10 @@ class MyGenerateJsonSchema(GenerateJsonSchema):
def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue: def nullable_schema(self, schema: s.NullableSchema) -> JsonSchemaValue:
return self.generate_inner(schema["schema"]) return self.generate_inner(schema["schema"])
if __name__ == '__main__':
if __name__ == "__main__":
from ...config.model import Config from ...config.model import Config
from ...song import Song from ...song import Song
write(Config) write(Config)
write(Song) write(Song, mode="serialization")

View file

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