Initial commit with the now playing

This commit is contained in:
Joey Yakimowich-Payne 2026-01-07 10:28:32 -07:00
commit de2f9cccb7
25 changed files with 2729 additions and 0 deletions

174
app/providers/gsmtc.py Normal file
View file

@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
import base64
import os
import re
import time
from pathlib import Path
from typing import Any, Tuple
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as SessionManager,
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
from winsdk.windows.storage.streams import DataReader
from app.paths import get_art_dir
from app.state import AppState, NowPlaying
ART_FILENAME = "album.png" # overwritten when track changes
PLACEHOLDER_FILENAME = "placeholder.png"
PLACEHOLDER_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAwUBAO+X2F8A"
"AAAASUVORK5CYII="
)
def _write_placeholder(out_path: Path) -> None:
data = base64.b64decode(PLACEHOLDER_PNG_B64)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
def ensure_art_files(art_dir: Path) -> None:
placeholder_path = art_dir / PLACEHOLDER_FILENAME
if not placeholder_path.exists():
_write_placeholder(placeholder_path)
album_path = art_dir / ART_FILENAME
if not album_path.exists():
_write_placeholder(album_path)
async def _read_thumbnail_to_file(session: Any, out_path: Path) -> bool:
try:
media_props = await session.try_get_media_properties_async()
thumb_ref = media_props.thumbnail
if thumb_ref is None:
return False
stream = await thumb_ref.open_read_async()
size = int(stream.size or 0)
if size <= 0:
return False
reader = DataReader(stream)
await reader.load_async(size)
buffer = bytearray(size)
reader.read_bytes(buffer)
out_path.write_bytes(buffer)
return True
except Exception:
return False
def _pick_best_session(sessions: Any) -> Any:
best = None
for s in sessions:
try:
info = s.get_playback_info()
status = info.playback_status if info else None
if status == PlaybackStatus.PLAYING:
return s
if best is None:
best = s
except Exception:
continue
return best
def _extract_album_from_artist(artist_raw: str) -> Tuple[str, str]:
if not artist_raw:
return "", ""
m = re.search(r"\s*\[ALBUM:(.*?)\]\s*$", artist_raw, re.IGNORECASE)
if not m:
return artist_raw.strip(), ""
album_hint = m.group(1).strip()
clean_artist = artist_raw[: m.start()].strip()
return clean_artist, album_hint
async def run_gsmtc_provider(state: AppState) -> None:
"""
Poll GSMTC and push state updates + broadcast over websocket.
"""
art_dir = get_art_dir()
ensure_art_files(art_dir)
last_art_sig: str | None = None
last_has_art = False
while True:
try:
manager = await SessionManager.request_async()
sessions = manager.get_sessions()
session = _pick_best_session(sessions)
if session is None:
_write_placeholder(art_dir / ART_FILENAME)
last_art_sig = None
last_has_art = False
np = NowPlaying(updated_unix=int(time.time()))
await state.set_now_playing(np)
await state.broadcast({"type": "nowplaying", "data": np.to_dict()})
await asyncio.sleep(1)
continue
app_id = ""
try:
app_id = session.source_app_user_model_id or ""
except Exception:
pass
info = session.get_playback_info()
status = info.playback_status if info else None
playing = status == PlaybackStatus.PLAYING
props = await session.try_get_media_properties_async()
title = getattr(props, "title", "") or ""
album = getattr(props, "album_title", "") or getattr(props, "album", "") or ""
artist_raw = getattr(props, "artist", "") or getattr(props, "album_artist", "") or ""
artist, album_hint = _extract_album_from_artist(artist_raw)
if not album and album_hint:
album = album_hint
track_key = f"{app_id}||{title}||{album}||{artist}"
has_thumb = getattr(props, "thumbnail", None) is not None
art_sig = f"{track_key}||thumb:{int(has_thumb)}"
art_available = last_has_art
if art_sig != last_art_sig:
out_path = art_dir / ART_FILENAME
if has_thumb:
wrote = await _read_thumbnail_to_file(session, out_path)
if not wrote:
_write_placeholder(out_path)
art_available = wrote
else:
_write_placeholder(out_path)
art_available = False
last_art_sig = art_sig if (title or album or artist) else None
last_has_art = art_available
np = NowPlaying(
title=title,
album=album,
artist=artist,
playing=playing,
source_app=app_id,
art_url=f"/art/{ART_FILENAME}",
has_art=last_has_art,
updated_unix=int(time.time()),
)
await state.set_now_playing(np)
await state.broadcast({"type": "nowplaying", "data": np.to_dict()})
except Exception:
# transient errors: keep last state
pass
await asyncio.sleep(1)