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,
isMpcBeReachable,
fetchResumePositions,
normalizeMediaPath,
getPlayerStatus,
} from "./api"
import { useSettings } from "./composables/useSettings"
@@ -547,10 +546,9 @@ async function refreshPlayerStatus() {
}
}
function hasResumePosition(filePath: string | null) {
if (!filePath) return false
const normalizedPath = normalizeMediaPath(filePath)
return Number(resumePositions.value[normalizedPath] || 0) > 0
function hasResumePosition(mediaId: string | null) {
if (!mediaId) return false
return Number(resumePositions.value[mediaId] || 0) > 0
}
function startMpcBePolling() {
+9 -1
View File
@@ -126,7 +126,15 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
if (!positions || typeof positions !== "object") {
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 {
return {}
}
+3 -2
View File
@@ -256,7 +256,7 @@ const props = defineProps<{
item: MediaItem
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean
hasResumePosition: (mediaId: string | null) => boolean
getRootName: (rootId: string | null | undefined) => string | null
}>()
const emit = defineEmits<{
@@ -828,7 +828,8 @@ function closeVersionActionMenu() {
}
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) {
+8
View File
@@ -117,6 +117,14 @@ class RootsRequest(msgspec.Struct):
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):
"""Single root entry in responses."""
+273 -18
View File
@@ -16,10 +16,13 @@ import os
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
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.protocol import (
OpenFolderRequest,
PlaybackStateUpdateRequest,
PlayMediaRequest,
RootsRequest,
WsInit,
@@ -83,6 +87,218 @@ if sys.platform == "win32":
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
# ---------------------------------------------------------------------------
@@ -95,6 +311,37 @@ def _get_context(root_id: str):
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):
"""Load allowed per-root metadata values from .mediahive."""
key = meta_key.strip().lower().strip("/")
@@ -437,6 +684,7 @@ async def _activate_all_roots() -> None:
@asynccontextmanager
async def lifespan(_app: FastAPI):
await frontend.load()
playback_state_cache.start()
# Defer root activation to a background task so the server starts
# immediately and macOS permission dialogs do not block startup.
@@ -448,6 +696,8 @@ async def lifespan(_app: FastAPI):
try:
yield
finally:
playback_state_cache.stop()
activation_task.cancel()
with suppress(asyncio.CancelledError):
await activation_task
@@ -723,26 +973,31 @@ async def root_metadata(root_id: str, meta_key: str):
@app.get("/api/meta/playback-state")
async def merged_playback_state():
"""Return merged playback-state resume positions from all active roots."""
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
"""Return merged playback-state resume positions from in-memory cache.
if not isinstance(data, dict):
continue
positions = data.get("resume_positions")
if not isinstance(positions, dict):
continue
for key, value in positions.items():
if isinstance(value, int | float):
merged[str(key)] = float(value)
Merges across all roots, preferring the most recent timestamp for each slug.
Format: {"key": "playback-state", "data": {"resume_positions": {slug: {pos, ts}}}}
"""
entries = playback_state_cache.get_merged_entries()
positions = {slug: entry.to_dict() for slug, entry in entries.items()}
return {"key": "playback-state", "data": {"resume_positions": positions}}
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 ---
+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]:
return {
"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:
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:
return _default_playback_state()
return {}
if not isinstance(raw, dict):
return _default_playback_state()
movies = raw.get("movies") if isinstance(raw, dict) else None
if not isinstance(movies, dict):
return {}
current = raw.get("current")
resume_positions = raw.get("resume_positions")
normalized: dict[str, object] = {
"current": current if isinstance(current, dict) else None,
"resume_positions": {},
}
if isinstance(resume_positions, dict):
cleaned_positions: dict[str, int] = {}
for key, value in resume_positions.items():
if isinstance(key, str) and isinstance(value, (int, float)):
cleaned_positions[key] = max(0, int(value))
normalized["resume_positions"] = cleaned_positions
return normalized
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(path)
mapping: dict[str, str] = {}
for movie_id, movie in movies.items():
if not isinstance(movie_id, str) or not isinstance(movie, dict):
continue
files = movie.get("files")
if not isinstance(files, dict):
continue
for file_key, torrent in files.items():
if not isinstance(file_key, str):
continue
normalized_key = _normalize_media_path(file_key)
mapping[normalized_key] = movie_id
playable_file = (
torrent.get("playable_file") if isinstance(torrent, dict) else None
)
expanded = _expand_playable_file(
file_key, playable_file if isinstance(playable_file, str) else None
)
mapping[_normalize_media_path(expanded)] = movie_id
return mapping
def _media_key_for_filepath(
filepath: str, roots: list[Path]
) -> tuple[str, Path] | None:
"""Resolve a filepath to a (relative_key, matched_root) tuple."""
for root in roots:
filepath: str, roots: dict[str, Path]
) -> tuple[str | None, str, str] | None:
"""Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
for root_id, root in roots.items():
try:
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:
continue
return None
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:
return False
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(
stop_event: threading.Event, roots: list[Path]
stop_event: threading.Event, roots: dict[str, Path], backend_url: str
) -> threading.Thread:
"""Start background XInput polling and send mapped commands to MPC-BE."""
get_state = _load_xinput_get_state()
@@ -278,18 +346,11 @@ def _start_gamepad_remote(
status_updated_at = 0.0
status_miss_count = 0
# Use the first root's playback state path as primary
primary_root = roots[0] if roots else Path.cwd()
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)
playback_state = _default_playback_state()
resume_positions = _fetch_resume_positions(backend_url)
tracked_media_key: str | None = None
tracked_root_id: str | None = None
tracked_relative_path = ""
tracked_filepath = ""
resume_applied_for_key: str | None = None
last_playback_state_flush_at = 0.0
@@ -302,12 +363,11 @@ def _start_gamepad_remote(
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:
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
last_playback_state_flush_at, \
resume_applied_for_key
@@ -316,38 +376,56 @@ def _start_gamepad_remote(
resume_applied_for_key = None
return
tracked_media_key = None
tracked_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
last_playback_state_flush_at = 0.0
if clear_resume_applied:
resume_applied_for_key = None
flush_playback_state()
def finalize_tracked_current() -> None:
nonlocal \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key, \
last_playback_state_flush_at
if tracked_media_key is None:
if playback_state.get("current") is not None:
playback_state["current"] = None
flush_playback_state()
return
position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0
if _should_clear_resume(position_ms, duration_ms):
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:
position_seconds = max(0, position_ms // 1000)
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_root_id = None
tracked_relative_path = ""
tracked_filepath = ""
playback_state["current"] = None
resume_applied_for_key = None
last_playback_state_flush_at = 0.0
flush_playback_state()
def persist_tracked_current(now: float, *, force: bool = False) -> None:
nonlocal last_playback_state_flush_at
@@ -367,7 +445,6 @@ def _start_gamepad_remote(
"updated_at": int(time.time()),
}
last_playback_state_flush_at = now
flush_playback_state()
def maybe_apply_resume(now: float) -> None:
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):
resume_positions.pop(tracked_media_key, None)
resume_applied_for_key = tracked_media_key
flush_playback_state()
return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
return
@@ -409,6 +485,8 @@ def _start_gamepad_remote(
status_updated_at, \
status_miss_count, \
tracked_media_key, \
tracked_root_id, \
tracked_relative_path, \
tracked_filepath, \
resume_applied_for_key
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
resolved = _media_key_for_filepath(filepath, roots) if filepath 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:
finalize_tracked_current()
@@ -445,6 +525,8 @@ def _start_gamepad_remote(
clear_tracked_current(clear_resume_applied=True)
elif tracked_media_key != media_key:
tracked_media_key = media_key
tracked_root_id = root_id
tracked_relative_path = relative_path
tracked_filepath = filepath
resume_applied_for_key = None
@@ -900,7 +982,7 @@ def winmain() -> None:
poll_thread: threading.Thread | None = None
# 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:
api._window = window
@@ -913,7 +995,7 @@ def winmain() -> None:
nonlocal poll_thread
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(
target=_activate_initial_roots,