Unify playback-state read/write via /api/meta/playback-state

This commit is contained in:
2026-05-31 01:43:56 +00:00
parent b6742f0f27
commit 626fb9a0ae
6 changed files with 435 additions and 83 deletions
+3 -5
View File
@@ -207,7 +207,6 @@ import {
openFolder, openFolder,
isMpcBeReachable, isMpcBeReachable,
fetchResumePositions, fetchResumePositions,
normalizeMediaPath,
getPlayerStatus, getPlayerStatus,
} from "./api" } from "./api"
import { useSettings } from "./composables/useSettings" import { useSettings } from "./composables/useSettings"
@@ -547,10 +546,9 @@ async function refreshPlayerStatus() {
} }
} }
function hasResumePosition(filePath: string | null) { function hasResumePosition(mediaId: string | null) {
if (!filePath) return false if (!mediaId) return false
const normalizedPath = normalizeMediaPath(filePath) return Number(resumePositions.value[mediaId] || 0) > 0
return Number(resumePositions.value[normalizedPath] || 0) > 0
} }
function startMpcBePolling() { function startMpcBePolling() {
+9 -1
View File
@@ -126,7 +126,15 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
if (!positions || typeof positions !== "object") { if (!positions || typeof positions !== "object") {
return {} return {}
} }
return positions as Record<string, number> const normalized: Record<string, number> = {}
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue
const pos = (value as { pos?: unknown }).pos
if (typeof pos === "number" && Number.isFinite(pos) && pos > 0) {
normalized[slug] = pos
}
}
return normalized
} catch { } catch {
return {} return {}
} }
+3 -2
View File
@@ -256,7 +256,7 @@ const props = defineProps<{
item: MediaItem item: MediaItem
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean hasResumePosition: (mediaId: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null getRootName: (rootId: string | null | undefined) => string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -828,7 +828,8 @@ function closeVersionActionMenu() {
} }
function getPlayLabel(filePath: string | null): string { function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? "Continue" : "Play" if (!filePath || props.item.type !== "movies") return "Play"
return props.hasResumePosition(props.item.id) ? "Continue" : "Play"
} }
function handlePlayVersion(filePath: string | null) { function handlePlayVersion(filePath: string | null) {
+8
View File
@@ -117,6 +117,14 @@ class RootsRequest(msgspec.Struct):
roots: dict[str, str] roots: dict[str, str]
class PlaybackStateUpdateRequest(msgspec.Struct):
"""POST /api/meta/playback-state body."""
root_id: str
file_path: str
pos: int | None = None
class RootEntryResponse(msgspec.Struct): class RootEntryResponse(msgspec.Struct):
"""Single root entry in responses.""" """Single root entry in responses."""
+273 -18
View File
@@ -16,10 +16,13 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@@ -43,6 +46,7 @@ from mediahive.hivescan.tmdb_client import close_http_client
from mediahive.models.events import Remove, Task, Upsert from mediahive.models.events import Remove, Task, Upsert
from mediahive.models.protocol import ( from mediahive.models.protocol import (
OpenFolderRequest, OpenFolderRequest,
PlaybackStateUpdateRequest,
PlayMediaRequest, PlayMediaRequest,
RootsRequest, RootsRequest,
WsInit, WsInit,
@@ -83,6 +87,218 @@ if sys.platform == "win32":
from ctypes import wintypes from ctypes import wintypes
@dataclass
class _PlaybackEntry:
"""Single resume position entry with timestamp."""
pos: int
ts: datetime
def to_dict(self) -> dict:
return {
"pos": self.pos,
"ts": self.ts,
}
@staticmethod
def from_dict(data: dict) -> _PlaybackEntry | None:
if not isinstance(data, dict):
return None
pos = data.get("pos")
ts = data.get("ts")
if not isinstance(pos, int) or pos < 0:
return None
if isinstance(ts, str):
try:
ts = datetime.fromisoformat(ts)
except ValueError, TypeError:
return None
elif isinstance(ts, datetime):
pass
else:
return None
return _PlaybackEntry(pos=pos, ts=ts)
@dataclass
class _PlaybackRootSnapshot:
file_path: Path
signature: tuple[bool, int, int] | None = None
entries: dict[str, _PlaybackEntry] = field(default_factory=dict)
class PlaybackStateCache:
"""Background cache for merged playback-state across all active roots.
Stores resume positions by movie slug with timestamps. When merging
across roots, picks the most recent entry for each slug.
"""
def __init__(self, poll_interval: float = 60.0) -> None:
self._poll_interval = poll_interval
self._roots: dict[str, _PlaybackRootSnapshot] = {}
self._merged_entries: dict[str, _PlaybackEntry] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name="mediahive-playback-state-cache",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=max(2.0, self._poll_interval + 1.0))
self._thread = None
def get_merged_entries(self) -> dict[str, _PlaybackEntry]:
"""Return merged resume entries keyed by slug (most recent wins)."""
with self._lock:
return {
slug: _PlaybackEntry(e.pos, e.ts)
for slug, e in self._merged_entries.items()
}
def update_resume_position(
self,
root_id: str,
root_path: Path,
slug: str,
pos: int | None,
) -> None:
"""Read-modify-write one root file and refresh the in-memory cache immediately."""
file_path = root_path / ".mediahive" / "playback-state.json"
entries = self._read_resume_entries(file_path)
if pos is None:
entries.pop(slug, None)
else:
entries[slug] = _PlaybackEntry(pos=pos, ts=datetime.now())
self._write_resume_entries(file_path, entries)
snapshot = _PlaybackRootSnapshot(
file_path=file_path,
signature=self._signature(file_path),
entries=entries,
)
with self._lock:
self._roots[root_id] = snapshot
self._merged_entries = self._build_merged_entries(self._roots)
def _run(self) -> None:
while not self._stop.is_set():
try:
self._refresh_once()
except Exception:
logger.exception("Playback-state cache refresh failed")
if self._stop.wait(self._poll_interval):
break
def _refresh_once(self) -> None:
contexts = supervisor.all_contexts()
with self._lock:
previous = self._roots
next_roots: dict[str, _PlaybackRootSnapshot] = {}
merged: dict[str, _PlaybackEntry] = {}
for root_id, ctx in contexts.items():
file_path = ctx.root_path / ".mediahive" / "playback-state.json"
snapshot = previous.get(root_id)
if snapshot is None or snapshot.file_path != file_path:
snapshot = _PlaybackRootSnapshot(file_path=file_path)
signature = self._signature(file_path)
if signature != snapshot.signature:
snapshot.signature = signature
snapshot.entries = self._read_resume_entries(file_path)
next_roots[root_id] = snapshot
# Merge: for each slug, keep the entry with the most recent timestamp
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
with self._lock:
self._roots = next_roots
self._merged_entries = merged
@staticmethod
def _signature(path: Path) -> tuple[bool, int, int]:
try:
stat = path.stat()
return (True, stat.st_mtime_ns, stat.st_size)
except OSError:
return (False, 0, 0)
@staticmethod
def _read_resume_entries(path: Path) -> dict[str, _PlaybackEntry]:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
return {}
if not isinstance(raw, dict):
return {}
positions = raw.get("resume_positions")
if not isinstance(positions, dict):
return {}
entries: dict[str, _PlaybackEntry] = {}
for slug, data in positions.items():
entry = _PlaybackEntry.from_dict(data)
if entry is not None:
entries[str(slug)] = entry
return entries
@staticmethod
def _write_resume_entries(path: Path, entries: dict[str, _PlaybackEntry]) -> None:
data = {
"resume_positions": {
slug: entry.to_dict() for slug, entry in sorted(entries.items())
}
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(
json.dumps(data, indent=2, sort_keys=True, default=str),
encoding="utf-8",
)
tmp_path.replace(path)
except OSError:
logger.exception("Failed to write playback-state: %s", path)
@staticmethod
def _build_merged_entries(
roots: dict[str, _PlaybackRootSnapshot],
) -> dict[str, _PlaybackEntry]:
merged: dict[str, _PlaybackEntry] = {}
for snapshot in roots.values():
for slug, entry in snapshot.entries.items():
existing = merged.get(slug)
if existing is None or entry.ts > existing.ts:
merged[slug] = entry
return merged
playback_state_cache = PlaybackStateCache()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -95,6 +311,37 @@ def _get_context(root_id: str):
return ctx return ctx
def _normalize_media_path_value(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_torrent_playable_path(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _resolve_movie_slug_for_file_path(ctx, file_path: str) -> str | None:
target = _normalize_media_path_value(file_path)
for movie_id, movie in ctx.store.movies.items():
for file_key, torrent in movie.files.items():
normalized_key = _normalize_media_path_value(file_key)
if normalized_key == target:
return movie_id
playable_path = _expand_torrent_playable_path(
file_key, torrent.playable_file
)
if _normalize_media_path_value(playable_path) == target:
return movie_id
return None
def _load_root_metadata(root_path: Path, meta_key: str): def _load_root_metadata(root_path: Path, meta_key: str):
"""Load allowed per-root metadata values from .mediahive.""" """Load allowed per-root metadata values from .mediahive."""
key = meta_key.strip().lower().strip("/") key = meta_key.strip().lower().strip("/")
@@ -437,6 +684,7 @@ async def _activate_all_roots() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
await frontend.load() await frontend.load()
playback_state_cache.start()
# Defer root activation to a background task so the server starts # Defer root activation to a background task so the server starts
# immediately and macOS permission dialogs do not block startup. # immediately and macOS permission dialogs do not block startup.
@@ -448,6 +696,8 @@ async def lifespan(_app: FastAPI):
try: try:
yield yield
finally: finally:
playback_state_cache.stop()
activation_task.cancel() activation_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await activation_task await activation_task
@@ -723,26 +973,31 @@ async def root_metadata(root_id: str, meta_key: str):
@app.get("/api/meta/playback-state") @app.get("/api/meta/playback-state")
async def merged_playback_state(): async def merged_playback_state():
"""Return merged playback-state resume positions from all active roots.""" """Return merged playback-state resume positions from in-memory cache.
merged: dict[str, float] = {}
for ctx in supervisor.all_contexts().values():
try:
data = _load_root_metadata(ctx.root_path, "playback-state")
except HTTPException as exc:
if exc.status_code == 404:
continue
raise
if not isinstance(data, dict): Merges across all roots, preferring the most recent timestamp for each slug.
continue Format: {"key": "playback-state", "data": {"resume_positions": {slug: {pos, ts}}}}
positions = data.get("resume_positions") """
if not isinstance(positions, dict): entries = playback_state_cache.get_merged_entries()
continue positions = {slug: entry.to_dict() for slug, entry in entries.items()}
for key, value in positions.items(): return {"key": "playback-state", "data": {"resume_positions": positions}}
if isinstance(value, int | float):
merged[str(key)] = float(value)
return {"key": "playback-state", "data": {"resume_positions": merged}}
@app.post("/api/meta/playback-state")
async def write_playback_state(request: Request):
"""Update one playback-state entry via backend-managed read-modify-write."""
req = msgspec.json.decode(await request.body(), type=PlaybackStateUpdateRequest)
ctx = _get_context(req.root_id)
slug = _resolve_movie_slug_for_file_path(ctx, req.file_path)
if slug is None:
raise HTTPException(
status_code=404,
detail=f"Movie not found for file path: {req.file_path}",
)
pos = None if req.pos is None or req.pos <= 0 else int(req.pos)
playback_state_cache.update_resume_position(req.root_id, ctx.root_path, slug, pos)
return {"status": "ok", "slug": slug, "pos": pos}
# --- MPC-BE / Player status --- # --- MPC-BE / Player status ---
+139 -57
View File
@@ -120,59 +120,127 @@ _DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
def _default_playback_state() -> dict[str, object]: def _default_playback_state() -> dict[str, object]:
return { return {
"current": None, "current": None,
"resume_positions": {},
} }
def _load_playback_state(path: Path) -> dict[str, object]: def _normalize_media_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
if not playable_file:
return file_key
if playable_file.startswith("concat:") or "://" in playable_file:
return playable_file
if playable_file.startswith(f"{file_key}/"):
return playable_file
if playable_file.startswith("/"):
return playable_file.lstrip("/")
return f"{file_key}/{playable_file}"
def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
method="GET",
)
try: try:
raw = json.loads(path.read_text(encoding="utf-8")) with urllib.request.urlopen(req, timeout=2) as resp:
raw = json.loads(resp.read().decode("utf-8"))
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
positions = data.get("resume_positions") if isinstance(data, dict) else None
if not isinstance(positions, dict):
return {}
cleaned: dict[str, int] = {}
for slug, value in positions.items():
if not isinstance(slug, str) or not isinstance(value, dict):
continue
pos = value.get("pos")
if isinstance(pos, int) and pos > 0:
cleaned[slug] = pos * 1000
return cleaned
def _post_resume_position(
backend_url: str,
root_id: str,
file_path: str,
pos: int | None,
) -> bool:
body = json.dumps({
"root_id": root_id,
"file_path": file_path,
"pos": pos,
}).encode("utf-8")
req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=2):
return True
except OSError, TimeoutError, urllib.error.URLError:
logger.warning("Failed to post playback-state update for %s", file_path)
return False
def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
try:
raw = json.loads(index_path.read_text(encoding="utf-8"))
except OSError, TypeError, json.JSONDecodeError: except OSError, TypeError, json.JSONDecodeError:
return _default_playback_state() return {}
if not isinstance(raw, dict): movies = raw.get("movies") if isinstance(raw, dict) else None
return _default_playback_state() if not isinstance(movies, dict):
return {}
current = raw.get("current") mapping: dict[str, str] = {}
resume_positions = raw.get("resume_positions") for movie_id, movie in movies.items():
normalized: dict[str, object] = { if not isinstance(movie_id, str) or not isinstance(movie, dict):
"current": current if isinstance(current, dict) else None, continue
"resume_positions": {}, files = movie.get("files")
} if not isinstance(files, dict):
continue
if isinstance(resume_positions, dict): for file_key, torrent in files.items():
cleaned_positions: dict[str, int] = {} if not isinstance(file_key, str):
for key, value in resume_positions.items(): continue
if isinstance(key, str) and isinstance(value, (int, float)): normalized_key = _normalize_media_path(file_key)
cleaned_positions[key] = max(0, int(value)) mapping[normalized_key] = movie_id
normalized["resume_positions"] = cleaned_positions playable_file = (
torrent.get("playable_file") if isinstance(torrent, dict) else None
return normalized )
expanded = _expand_playable_file(
file_key, playable_file if isinstance(playable_file, str) else None
def _save_playback_state(path: Path, state: dict[str, object]) -> None: )
path.parent.mkdir(parents=True, exist_ok=True) mapping[_normalize_media_path(expanded)] = movie_id
tmp_path = path.with_suffix(f"{path.suffix}.tmp") return mapping
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(path)
def _media_key_for_filepath( def _media_key_for_filepath(
filepath: str, roots: list[Path] filepath: str, roots: dict[str, Path]
) -> tuple[str, Path] | None: ) -> tuple[str | None, str, str] | None:
"""Resolve a filepath to a (relative_key, matched_root) tuple.""" """Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
for root in roots: for root_id, root in roots.items():
try: try:
relative = Path(filepath).resolve().relative_to(root.resolve()) relative = Path(filepath).resolve().relative_to(root.resolve())
return relative.as_posix(), root relative_key = relative.as_posix()
index_path = root / ".mediahive" / "index.json"
movie_slug = _load_movie_slug_map(index_path).get(
_normalize_media_path(relative_key)
)
return movie_slug, root_id, relative_key
except OSError, RuntimeError, ValueError: except OSError, RuntimeError, ValueError:
continue continue
return None return None
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool: def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
return True
if duration_ms <= 0: if duration_ms <= 0:
return False return False
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
@@ -248,7 +316,7 @@ def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
def _start_gamepad_remote( def _start_gamepad_remote(
stop_event: threading.Event, roots: list[Path] stop_event: threading.Event, roots: dict[str, Path], backend_url: str
) -> threading.Thread: ) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE.""" """Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state() get_state = _load_xinput_get_state()
@@ -278,18 +346,11 @@ def _start_gamepad_remote(
status_updated_at = 0.0 status_updated_at = 0.0
status_miss_count = 0 status_miss_count = 0
# Use the first root's playback state path as primary playback_state = _default_playback_state()
primary_root = roots[0] if roots else Path.cwd() resume_positions = _fetch_resume_positions(backend_url)
playback_state_path = primary_root / ".mediahive" / "playback-state.json"
playback_state = _load_playback_state(playback_state_path)
resume_positions = playback_state["resume_positions"]
if not isinstance(resume_positions, dict):
resume_positions = {}
playback_state["resume_positions"] = resume_positions
if playback_state.get("current") is not None:
playback_state["current"] = None
_save_playback_state(playback_state_path, playback_state)
tracked_media_key: str | None = None tracked_media_key: str | None = None
tracked_root_id: str | None = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
resume_applied_for_key: str | None = None resume_applied_for_key: str | None = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
@@ -302,12 +363,11 @@ def _start_gamepad_remote(
request_pool.submit(_seek_mpcbe_to_position, position_ms) request_pool.submit(_seek_mpcbe_to_position, position_ms)
) )
def flush_playback_state() -> None:
_save_playback_state(playback_state_path, playback_state)
def clear_tracked_current(*, clear_resume_applied: bool) -> None: def clear_tracked_current(*, clear_resume_applied: bool) -> None:
nonlocal \ nonlocal \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
last_playback_state_flush_at, \ last_playback_state_flush_at, \
resume_applied_for_key resume_applied_for_key
@@ -316,38 +376,56 @@ def _start_gamepad_remote(
resume_applied_for_key = None resume_applied_for_key = None
return return
tracked_media_key = None tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
playback_state["current"] = None playback_state["current"] = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
if clear_resume_applied: if clear_resume_applied:
resume_applied_for_key = None resume_applied_for_key = None
flush_playback_state()
def finalize_tracked_current() -> None: def finalize_tracked_current() -> None:
nonlocal \ nonlocal \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
resume_applied_for_key, \ resume_applied_for_key, \
last_playback_state_flush_at last_playback_state_flush_at
if tracked_media_key is None: if tracked_media_key is None:
if playback_state.get("current") is not None: if playback_state.get("current") is not None:
playback_state["current"] = None playback_state["current"] = None
flush_playback_state()
return return
position_ms = player_position_ms or 0 position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0 duration_ms = player_duration_ms or 0
if _should_clear_resume(position_ms, duration_ms): if _should_clear_resume(position_ms, duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_media_key, None)
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url, tracked_root_id, tracked_relative_path, None
)
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
# Ignore brief starts; keep the previous saved resume position.
pass
else: else:
position_seconds = max(0, position_ms // 1000)
resume_positions[tracked_media_key] = position_ms resume_positions[tracked_media_key] = position_ms
if tracked_root_id and tracked_relative_path:
_post_resume_position(
backend_url,
tracked_root_id,
tracked_relative_path,
position_seconds,
)
tracked_media_key = None tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = "" tracked_filepath = ""
playback_state["current"] = None playback_state["current"] = None
resume_applied_for_key = None resume_applied_for_key = None
last_playback_state_flush_at = 0.0 last_playback_state_flush_at = 0.0
flush_playback_state()
def persist_tracked_current(now: float, *, force: bool = False) -> None: def persist_tracked_current(now: float, *, force: bool = False) -> None:
nonlocal last_playback_state_flush_at nonlocal last_playback_state_flush_at
@@ -367,7 +445,6 @@ def _start_gamepad_remote(
"updated_at": int(time.time()), "updated_at": int(time.time()),
} }
last_playback_state_flush_at = now last_playback_state_flush_at = now
flush_playback_state()
def maybe_apply_resume(now: float) -> None: def maybe_apply_resume(now: float) -> None:
nonlocal player_position_ms, resume_applied_for_key nonlocal player_position_ms, resume_applied_for_key
@@ -388,7 +465,6 @@ def _start_gamepad_remote(
if _should_clear_resume(saved_position, player_duration_ms): if _should_clear_resume(saved_position, player_duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_media_key, None)
resume_applied_for_key = tracked_media_key resume_applied_for_key = tracked_media_key
flush_playback_state()
return return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS: if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
return return
@@ -409,6 +485,8 @@ def _start_gamepad_remote(
status_updated_at, \ status_updated_at, \
status_miss_count, \ status_miss_count, \
tracked_media_key, \ tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \ tracked_filepath, \
resume_applied_for_key resume_applied_for_key
if status_future is None or not status_future.done(): if status_future is None or not status_future.done():
@@ -437,6 +515,8 @@ def _start_gamepad_remote(
filepath, position_ms, duration_ms, state = status filepath, position_ms, duration_ms, state = status
resolved = _media_key_for_filepath(filepath, roots) if filepath else None resolved = _media_key_for_filepath(filepath, roots) if filepath else None
media_key = resolved[0] if resolved else None media_key = resolved[0] if resolved else None
root_id = resolved[1] if resolved else None
relative_path = resolved[2] if resolved else ""
if tracked_media_key is not None and media_key != tracked_media_key: if tracked_media_key is not None and media_key != tracked_media_key:
finalize_tracked_current() finalize_tracked_current()
@@ -445,6 +525,8 @@ def _start_gamepad_remote(
clear_tracked_current(clear_resume_applied=True) clear_tracked_current(clear_resume_applied=True)
elif tracked_media_key != media_key: elif tracked_media_key != media_key:
tracked_media_key = media_key tracked_media_key = media_key
tracked_root_id = root_id
tracked_relative_path = relative_path
tracked_filepath = filepath tracked_filepath = filepath
resume_applied_for_key = None resume_applied_for_key = None
@@ -900,7 +982,7 @@ def winmain() -> None:
poll_thread: threading.Thread | None = None poll_thread: threading.Thread | None = None
# Resolve all root paths for gamepad remote # Resolve all root paths for gamepad remote
gamepad_roots = [Path(p) for p in initial_roots.values()] gamepad_roots = {root_id: Path(p) for root_id, p in initial_roots.items()}
def on_shown() -> None: def on_shown() -> None:
api._window = window api._window = window
@@ -913,7 +995,7 @@ def winmain() -> None:
nonlocal poll_thread nonlocal poll_thread
if poll_thread is None and _supports_gamepad_remote(): if poll_thread is None and _supports_gamepad_remote():
poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots) poll_thread = _start_gamepad_remote(poll_stop, gamepad_roots, backend_url)
threading.Thread( threading.Thread(
target=_activate_initial_roots, target=_activate_initial_roots,