streamer-widgets/app/providers/gsmtc.py

204 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]:
"""
Extract album info from artist string if embedded.
Supports formats:
- "Artist [ALBUM:Album Name]" -> ("Artist", "Album Name")
- "Artist — Album Name" -> ("Artist", "Album Name") (em dash)
- "Artist - Album Name" -> ("Artist", "Album Name") (hyphen with spaces)
"""
if not artist_raw:
return "", ""
# First, check for [ALBUM:...] pattern
m = re.search(r"\s*\[ALBUM:(.*?)\]\s*$", artist_raw, re.IGNORECASE)
if m:
album_hint = m.group(1).strip()
clean_artist = artist_raw[: m.start()].strip()
return clean_artist, album_hint
# Check for em dash (—) or en dash () separator
for dash in ["", ""]:
if dash in artist_raw:
parts = artist_raw.split(dash, 1)
if len(parts) == 2:
artist = parts[0].strip()
album = parts[1].strip()
if artist and album:
return artist, album
# Check for spaced hyphen " - " separator (but not "Artist-Name")
if " - " in artist_raw:
parts = artist_raw.split(" - ", 1)
if len(parts) == 2:
artist = parts[0].strip()
album = parts[1].strip()
if artist and album:
return artist, album
return artist_raw.strip(), ""
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)