Compare commits
4 commits
1bb2032b9f
...
dbd507bccb
Author | SHA1 | Date | |
---|---|---|---|
dbd507bccb | |||
012bc0b025 | |||
d9c8e0fe28 | |||
b8bcdc5a83 |
11 changed files with 108 additions and 33 deletions
|
@ -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",
|
||||||
|
|
|
@ -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": [
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"})]
|
||||||
)
|
)
|
|
@ -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")
|
||||||
|
|
|
@ -34,6 +34,7 @@ MPNowPlayingInfoPropertyPlaybackQueueIndex: Final = (
|
||||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = (
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: Final = (
|
||||||
"MPNowPlayingInfoPropertyElapsedPlaybackTime"
|
"MPNowPlayingInfoPropertyElapsedPlaybackTime"
|
||||||
)
|
)
|
||||||
|
MPNowPlayingInfoPropertyAssetURL: Final = "MPNowPlayingInfoPropertyAssetURL"
|
||||||
MPNowPlayingInfoPropertyExternalContentIdentifier: Final = (
|
MPNowPlayingInfoPropertyExternalContentIdentifier: Final = (
|
||||||
"MPNowPlayingInfoPropertyExternalContentIdentifier"
|
"MPNowPlayingInfoPropertyExternalContentIdentifier"
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue