From 9ec4f877eb74d8ba681e161bdc90dfb805a21fb0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 10 Sep 2026 20:56:03 +0000 Subject: [PATCH] Resume tracking: per-episode positions, player-agnostic fallback, series continue point - Fix merged playback-state dropping season/episode, which broke series resume restore entirely. - Track watch progress per episode (episodes map keyed S..E.., with done markers on completion) while keeping one continue point per series as the last watched episode, used for spoiler protection and season selection. MPC-BE tracker resumes from each episode's own position. - Assumed-playback fallback for any player and server-only mode: a launch starts a session and the guessed position (base + elapsed, capped at runtime) is written when frontend activity resumes; the real MPC-BE tracker overrides guesses via timestamps. Frontend reports input activity (throttled) via POST /api/activity. - Ignore watches under 5 minutes in both trackers. - Episode tiles show a small quadrant-circle watch indicator; near end counts as fully watched, no data shows nothing. - Fix episode audio ownership: single audioOwnerKey shared by mouse hover and keyboard/gamepad focus, no longer clobbered by playback sync; idle fade can be re-armed by continued activity. --- docs/API.md | 7 +- frontend/src/App.vue | 33 +- frontend/src/api.ts | 68 +++- frontend/src/components/MediaDetail.vue | 8 +- frontend/src/components/SeriesFullView.vue | 299 +++++++++++--- frontend/src/composables/useInputModality.ts | 6 + frontend/src/types.ts | 7 + mediahive/server.py | 402 +++++++++++++++++-- mediahive/winmain.py | 216 ++++++++-- 9 files changed, 916 insertions(+), 130 deletions(-) diff --git a/docs/API.md b/docs/API.md index 0a65870..963acbb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,10 +11,12 @@ All media paths are scoped to a **root**, identified by a friendly `root_id` | `GET` | `/api/health` | Lightweight health check. | | `GET` | `/api/config` | Returns the current root configuration. | | `PUT` | `/api/config/roots` | Atomically replace the full root set. | -| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. | +| `POST` | `/api/play/{root_id}` | Opens a media file with the system player. Also starts an assumed-playback session (see notes). | +| `POST` | `/api/activity` | Reports user input activity; finalizes any assumed-playback session. Returns `{ "status": "ok", "finalized": bool }`. | | `POST` | `/api/open-folder/{root_id}` | Opens a folder in the system file explorer. | | `GET` | `/api/meta/{root_id}/{meta_key}` | Returns allowed metadata from `/.mediahive`. | -| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots. | +| `GET` | `/api/meta/playback-state` | Returns merged resume positions across all roots. Series entries carry one continue point per series (`season`/`episode` = last watched) plus a per-episode watch map (`episodes`: `"SE"` → `{pos, ts, done}`); completing an episode marks it done and advances the point to the next episode. | +| `POST` | `/api/meta/playback-state` | Updates one resume entry (`root_id`, `file_path`, `pos`; null `pos` clears a movie or advances a series' continue point). | | `GET` | `/api/player/status` | Returns whether remote player control is currently available. | | `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. | | `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. | @@ -30,3 +32,4 @@ All media paths are scoped to a **root**, identified by a friendly `root_id` - `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`. - `GET /api/player/status` returns `{ "remote": true|false }`. - `GET /api/mpcbe/status` returns `false` on non-Windows platforms. +- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6dc3b96..2e68ea8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -177,6 +177,8 @@ :all-movies="mediaIndex?.movies ?? []" :focus-episode="focusEpisode" :has-resume-position="hasResumePosition" + :get-resume-point="getResumePoint" + :get-resume-episodes="getResumeEpisodes" :get-root-name="getRootName" @close="closeDetail" @play="handlePlay" @@ -201,6 +203,7 @@ import type { MediaItem, EpisodeWithSeries, TaskInfo, + SeriesResumePoint, } from "./types" import { playMedia, @@ -208,6 +211,8 @@ import { isMpcBeReachable, fetchResumePositions, getPlayerStatus, + type ResumePositionEntry, + type EpisodeWatchEntry, } from "./api" import { useSettings } from "./composables/useSettings" import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" @@ -497,7 +502,7 @@ const settings = useSettings() const searchResults = ref([]) const isSearching = ref(false) const mpcBeConnected = ref(false) -const resumePositions = ref>({}) +const resumePositions = ref>({}) const searchQuery = ref(getRouteSearchQuery()) const searchReturnPath = ref(null) const browsePanelRef = ref(null) @@ -540,6 +545,10 @@ async function refreshResumePositions() { resumePositions.value = await fetchResumePositions() } +function refreshResumePositionsAsEvent() { + void refreshResumePositions() +} + async function refreshPlayerStatus() { if (!isMpcFamilySelected()) { mpcBeConnected.value = false @@ -555,7 +564,25 @@ async function refreshPlayerStatus() { function hasResumePosition(mediaId: string | null) { if (!mediaId) return false - return Number(resumePositions.value[mediaId] || 0) > 0 + return (resumePositions.value[mediaId]?.pos || 0) > 0 +} + +function getResumePoint(mediaId: string | null): SeriesResumePoint | null { + if (!mediaId) return null + const entry = resumePositions.value[mediaId] + if (!entry || entry.season === null || entry.episode === null) return null + return { + seasonNumber: entry.season, + episodeNumber: entry.episode, + positionSeconds: entry.pos, + } +} + +function getResumeEpisodes( + mediaId: string | null, +): Record | null { + if (!mediaId) return null + return resumePositions.value[mediaId]?.episodes ?? null } function startMpcBePolling() { @@ -936,6 +963,7 @@ onMounted(() => { document.addEventListener("keydown", handleDetailAdjacentKey) window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) + window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent) }) onUnmounted(() => { @@ -943,6 +971,7 @@ onUnmounted(() => { document.removeEventListener("keydown", handleDetailAdjacentKey) window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) + window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent) stopMpcBePolling() }) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 95cd99a..b155b7c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -148,10 +148,25 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") } } +/** Watch progress for one episode of a series. */ +export interface EpisodeWatchEntry { + pos: number + done: boolean +} + +/** One stored continue point. season/episode are set for series, null for movies. */ +export interface ResumePositionEntry { + pos: number + season: number | null + episode: number | null + /** Per-episode watch progress for series, keyed "SE". */ + episodes?: Record +} + /** * Fetch merged resume positions from all roots. */ -export async function fetchResumePositions(): Promise> { +export async function fetchResumePositions(): Promise> { try { const response = await fetch("/api/meta/playback-state") if (!response.ok) return {} @@ -160,12 +175,32 @@ export async function fetchResumePositions(): Promise> { if (!positions || typeof positions !== "object") { return {} } - const normalized: Record = {} + const normalized: Record = {} for (const [slug, value] of Object.entries(positions as Record)) { 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 + const entry = value as { pos?: unknown; season?: unknown; episode?: unknown } + if (typeof entry.pos !== "number" || !Number.isFinite(entry.pos) || entry.pos < 0) { + continue + } + normalized[slug] = { + pos: entry.pos, + season: typeof entry.season === "number" ? entry.season : null, + episode: typeof entry.episode === "number" ? entry.episode : null, + } + const rawEpisodes = (entry as { episodes?: unknown }).episodes + if (rawEpisodes && typeof rawEpisodes === "object") { + const watches: Record = {} + for (const [key, watch] of Object.entries( + rawEpisodes as Record, + )) { + if (!watch || typeof watch !== "object") continue + const w = watch as { pos?: unknown; done?: unknown } + if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue + watches[key] = { pos: w.pos, done: w.done === true } + } + if (Object.keys(watches).length > 0) { + normalized[slug].episodes = watches + } } } return normalized @@ -174,6 +209,29 @@ export async function fetchResumePositions(): Promise> { } } +/** + * Report that the user is actively interacting with the UI. + * + * Ends any server-side assumed-playback session (launched item is assumed + * watched while the UI sees no input). Throttled; fire-and-forget. + */ +let lastActivityReportAt = 0 +export function reportUserActivity(): void { + const now = Date.now() + if (now - lastActivityReportAt < 5000) return + lastActivityReportAt = now + void fetch("/api/activity", { method: "POST" }) + .then(async (response) => { + if (!response.ok) return + const data = await response.json().catch(() => null) + if (data?.finalized) { + // An assumed-playback position was just written; let views refetch. + window.dispatchEvent(new Event("mediahive:resume-updated")) + } + }) + .catch(() => {}) +} + /** * Replace the full root set atomically */ diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 5f53819..e7c46cd 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -5,7 +5,8 @@ :series="item.data as Series" :all-movies="allMovies" :focus-episode="focusEpisode" - :has-resume-position="hasResumePosition" + :resume-point="getResumePoint(item.id)" + :resume-episodes="getResumeEpisodes(item.id)" :get-root-name="getRootName" @close="$emit('close')" @play="handlePlay" @@ -233,7 +234,8 @@ @@ -1723,8 +1899,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect { opacity: 1; } -/* Spoiler avoidance: episodes ahead of the cursor fade out completely, - including the synopsis (playback is stopped via the cursor watch). */ +/* Spoiler avoidance: episodes past the spoiler threshold (cursor or + continue point) fade out completely, including the synopsis (playback is + stopped via the threshold watch). */ .episode-tile--ahead .tile-media { opacity: 0; } @@ -1776,6 +1953,18 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect { pointer-events: none; } +.ep-watch { + position: absolute; + top: 10px; + right: 10px; + font-size: 0.85rem; + color: white; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9); + opacity: 0.85; + line-height: 1; + pointer-events: none; +} + /* Play indicator */ .tile-play { position: absolute; diff --git a/frontend/src/composables/useInputModality.ts b/frontend/src/composables/useInputModality.ts index 8540ac1..624668d 100644 --- a/frontend/src/composables/useInputModality.ts +++ b/frontend/src/composables/useInputModality.ts @@ -1,5 +1,8 @@ +import { reportUserActivity } from "../api" + type InputModality = "mouse" | "keyboard" | "gamepad" + const MOUSE_IDLE_MS = 1400 const MOUSE_INTENT_DISTANCE_PX = 28 const MOUSE_INTENT_WINDOW_MS = 700 @@ -90,6 +93,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean { } function handleMouseMove(event: MouseEvent) { + reportUserActivity() showPointerFromMotion() if (modality === "mouse") { @@ -112,6 +116,7 @@ function handleMouseOver(event: MouseEvent) { } function handleMouseIntentAction(event: MouseEvent | WheelEvent) { + reportUserActivity() pointerVisible = true if (isMouseIntentTarget(event.target)) { activateMouseInput() @@ -124,6 +129,7 @@ function handleMouseIntentAction(event: MouseEvent | WheelEvent) { function handleKeyboardActivity(event: KeyboardEvent) { if (event.metaKey || event.ctrlKey || event.altKey) return + reportUserActivity() activateNonMouseInput("keyboard") } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d543795..5ef8df5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -109,6 +109,13 @@ export interface Series { seasons: Season[] } +/** A series' single continue point (last watched position). */ +export interface SeriesResumePoint { + seasonNumber: number + episodeNumber: number + positionSeconds: number +} + export interface MovieUi extends Movie { id: string root_id: string | null diff --git a/mediahive/server.py b/mediahive/server.py index 8d83a98..00af587 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -81,16 +81,62 @@ if sys.platform == "win32": @dataclass -class _PlaybackEntry: - """Single resume position entry with timestamp.""" +class _EpisodeWatch: + """Per-episode watch progress within a series entry.""" pos: int ts: datetime + done: bool = False + + def to_dict(self) -> dict: + return {"pos": self.pos, "ts": self.ts, "done": self.done} + + @staticmethod + def from_dict(data: dict) -> _EpisodeWatch | 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 not isinstance(ts, datetime): + return None + return _EpisodeWatch(pos=pos, ts=ts, done=bool(data.get("done"))) + + +def _episode_watch_key(season: int, episode: int) -> str: + return f"S{season}E{episode}" + + +@dataclass +class _PlaybackEntry: + """Resume entry for one media slug. + + season/episode form the series' single continue point (last watched + episode), None for movies. episodes holds per-episode watch progress + for series, keyed "SE". + """ + + pos: int + ts: datetime + season: int | None = None + episode: int | None = None + episodes: dict[str, _EpisodeWatch] = field(default_factory=dict) def to_dict(self) -> dict: return { "pos": self.pos, "ts": self.ts, + "season": self.season, + "episode": self.episode, + "episodes": { + key: watch.to_dict() for key, watch in sorted(self.episodes.items()) + }, } @staticmethod @@ -110,7 +156,22 @@ class _PlaybackEntry: pass else: return None - return _PlaybackEntry(pos=pos, ts=ts) + season = data.get("season") + episode = data.get("episode") + episodes: dict[str, _EpisodeWatch] = {} + raw_episodes = data.get("episodes") + if isinstance(raw_episodes, dict): + for key, watch_data in raw_episodes.items(): + watch = _EpisodeWatch.from_dict(watch_data) + if watch is not None: + episodes[str(key)] = watch + return _PlaybackEntry( + pos=pos, + ts=ts, + season=season if isinstance(season, int) else None, + episode=episode if isinstance(episode, int) else None, + episodes=episodes, + ) @dataclass @@ -123,7 +184,8 @@ class _PlaybackRootSnapshot: class PlaybackStateCache: """Background cache for merged playback-state across all active roots. - Stores resume positions by movie slug with timestamps. When merging + Stores resume positions by media slug (movie id, or series id with a + season/episode continue point) with timestamps. When merging across roots, picks the most recent entry for each slug. """ @@ -156,7 +218,7 @@ class PlaybackStateCache: """Return merged resume entries keyed by slug (most recent wins).""" with self._lock: return { - slug: _PlaybackEntry(e.pos, e.ts) + slug: _PlaybackEntry(e.pos, e.ts, e.season, e.episode, dict(e.episodes)) for slug, e in self._merged_entries.items() } @@ -166,15 +228,50 @@ class PlaybackStateCache: root_path: Path, slug: str, pos: int | None, + season: int | None = None, + episode: int | None = None, + *, + done_episode: tuple[int, int] | None = None, ) -> None: - """Read-modify-write one root file and refresh the in-memory cache immediately.""" + """Read-modify-write one root file and refresh the in-memory cache immediately. + + For series entries, updates both the series continue point (last + watched episode) and the per-episode watch map. done_episode marks a + completed episode as fully watched without touching the continue + point position semantics (used together with advancing the point). + """ file_path = root_path / ".mediahive" / "playback-state.json" entries = self._read_resume_entries(file_path) - if pos is None: + if pos is None and done_episode is None: entries.pop(slug, None) else: - entries[slug] = _PlaybackEntry(pos=pos, ts=datetime.now()) + now = datetime.now() + entry = entries.get(slug) + if entry is None: + entry = _PlaybackEntry(pos=pos or 0, ts=now) + entries[slug] = entry + if pos is not None: + entry.pos = pos + entry.ts = now + entry.season = season + entry.episode = episode + if season is not None and episode is not None and pos > 0: + entry.episodes[_episode_watch_key(season, episode)] = _EpisodeWatch( + pos=pos, ts=now + ) + elif done_episode is not None: + # Final episode completed: no continue point remains, but the + # per-episode watch history is kept for indicators. + entry.pos = 0 + entry.ts = now + entry.season = None + entry.episode = None + if done_episode is not None: + done_season, done_ep = done_episode + entry.episodes[_episode_watch_key(done_season, done_ep)] = ( + _EpisodeWatch(pos=0, ts=now, done=True) + ) self._write_resume_entries(file_path, entries) @@ -203,7 +300,6 @@ class PlaybackStateCache: 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" @@ -218,15 +314,9 @@ class PlaybackStateCache: 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 + self._merged_entries = self._build_merged_entries(next_roots) @staticmethod def _signature(path: Path) -> tuple[bool, int, int]: @@ -280,12 +370,33 @@ class PlaybackStateCache: def _build_merged_entries( roots: dict[str, _PlaybackRootSnapshot], ) -> dict[str, _PlaybackEntry]: + """Merge resume entries across roots. + + Newest continue point per slug, and newest watch state per episode + key within each slug. + """ 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 + if existing is None: + merged[slug] = _PlaybackEntry( + entry.pos, + entry.ts, + entry.season, + entry.episode, + dict(entry.episodes), + ) + continue + if entry.ts > existing.ts: + existing.pos = entry.pos + existing.ts = entry.ts + existing.season = entry.season + existing.episode = entry.episode + for key, watch in entry.episodes.items(): + current = existing.episodes.get(key) + if current is None or watch.ts > current.ts: + existing.episodes[key] = watch return merged @@ -370,21 +481,195 @@ def _expand_torrent_playable_path(file_key: str, playable_file: str | None) -> s return f"{file_key}/{playable_file}" -def _resolve_movie_slug_for_file_path(ctx, file_path: str) -> str | None: +def _resolve_media_ref_for_file_path( + ctx, file_path: str +) -> tuple[str, int | None, int | None] | None: + """Resolve a playable file path to (slug, season_number, episode_number). + + Movies return (movie_id, None, None); series episode files return + (series_id, season_number, episode_number). + """ 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 + return movie_id, None, None playable_path = _expand_torrent_playable_path( file_key, torrent.playable_file ) if _normalize_media_path_value(playable_path) == target: - return movie_id + return movie_id, None, None + for series_id, show in ctx.store.series.items(): + for season in show.seasons: + for episode in season.episodes: + for file_key, torrent in episode.files.items(): + normalized_key = _normalize_media_path_value(file_key) + if normalized_key == target: + return series_id, season.season_number, episode.episode_number + playable_path = _expand_torrent_playable_path( + file_key, torrent.playable_file + ) + if _normalize_media_path_value(playable_path) == target: + return series_id, season.season_number, episode.episode_number return None +def _next_episode_ref( + show, season_number: int, episode_number: int +) -> tuple[int, int] | None: + """Return the (season_number, episode_number) following the given episode.""" + for season_index, season in enumerate(show.seasons): + if season.season_number != season_number: + continue + for episode_index, episode in enumerate(season.episodes): + if episode.episode_number != episode_number: + continue + if episode_index + 1 < len(season.episodes): + return season.season_number, season.episodes[ + episode_index + 1 + ].episode_number + if season_index + 1 < len(show.seasons): + next_season = show.seasons[season_index + 1] + if next_season.episodes: + return next_season.season_number, next_season.episodes[ + 0 + ].episode_number + return None + return None + + +# --- Assumed playback tracking (player-agnostic fallback) --- +# +# Launching an external player returns immediately and no player API is +# guaranteed, so for arbitrary players we cannot observe real progress. +# Instead: when the user launches an item and the frontend then sees no +# input activity, the item is assumed to be playing. The resume position is +# written once, when frontend activity resumes (i.e. the user came back). +# The MPC-BE tracker in the GUI overrides this: if a newer entry for the +# slug was written while the session ran, the guess is discarded. + +ASSUMED_PLAYBACK_MIN_WATCH_S = 300 + + +@dataclass +class _AssumedPlaybackSession: + root_id: str + slug: str + season: int | None + episode: int | None + base_pos_s: int + started_mono: float + started_wall: datetime + duration_s: int | None + + +_assumed_playback: _AssumedPlaybackSession | None = None + + +def _media_duration_seconds( + ctx, slug: str, season_number: int | None, episode_number: int | None +) -> int | None: + """Best-known runtime in seconds (TMDb, minutes) for a media ref.""" + minutes: int | None = None + if season_number is None: + movie = ctx.store.movies.get(slug) + if movie is not None and movie.info is not None: + minutes = movie.info.runtime + else: + show = ctx.store.series.get(slug) + if show is not None: + for season in show.seasons: + if season.season_number != season_number: + continue + for episode in season.episodes: + if episode.episode_number == episode_number: + minutes = episode.runtime + break + break + return minutes * 60 if minutes else None + + +def _start_assumed_playback(ctx, root_id: str, file_path: str) -> None: + """Begin a guessed-watch session for a freshly launched file.""" + global _assumed_playback + # Time between two launches counts as watching the previous item. + _finalize_assumed_playback() + + ref = _resolve_media_ref_for_file_path(ctx, file_path) + if ref is None: + return + slug, season_number, episode_number = ref + + entry = playback_state_cache.get_merged_entries().get(slug) + base_pos_s = 0 + if entry is not None: + if season_number is None or (entry.season, entry.episode) == ( + season_number, + episode_number, + ): + base_pos_s = entry.pos + + _assumed_playback = _AssumedPlaybackSession( + root_id=root_id, + slug=slug, + season=season_number, + episode=episode_number, + base_pos_s=base_pos_s, + started_mono=time.monotonic(), + started_wall=datetime.now(), + duration_s=_media_duration_seconds(ctx, slug, season_number, episode_number), + ) + + +def _finalize_assumed_playback() -> bool: + """Close the guessed-watch session, writing the assumed position. + + Returns True when a resume position was actually written. + """ + global _assumed_playback + session = _assumed_playback + _assumed_playback = None + if session is None: + return False + + elapsed_s = int(time.monotonic() - session.started_mono) + if elapsed_s < ASSUMED_PLAYBACK_MIN_WATCH_S: + return False + pos_s = session.base_pos_s + elapsed_s + if session.duration_s: + pos_s = min(pos_s, session.duration_s) + if pos_s <= 0: + return False + + # A newer entry written while this session ran (e.g. the GUI's real + # MPC-BE tracker finalizing on player close) overrides the guess. + current = playback_state_cache.get_merged_entries().get(session.slug) + if current is not None and current.ts > session.started_wall: + return False + + ctx = supervisor.get(session.root_id) + if ctx is None: + return False + playback_state_cache.update_resume_position( + session.root_id, + ctx.root_path, + session.slug, + pos_s, + session.season, + session.episode, + ) + logger.info( + "Assumed playback: %s S%sE%s +%ds -> pos %ds", + session.slug, + session.season, + session.episode, + elapsed_s, + pos_s, + ) + return True + + def _load_root_metadata(root_path: Path, meta_key: str): """Load allowed per-root metadata values from .mediahive.""" key = meta_key.strip().lower().strip("/") @@ -745,6 +1030,7 @@ async def lifespan(_app: FastAPI): try: yield finally: + _finalize_assumed_playback() playback_state_cache.stop() await event_loop_lag_monitor.stop() @@ -986,6 +1272,10 @@ async def play_media(root_id: str, request: Request, response: Response): launch_ms = (time.perf_counter() - launch_t0) * 1000.0 total_ms = (time.perf_counter() - req_start) * 1000.0 + + # Player-agnostic fallback: assume the launched item is being watched + # until frontend activity resumes (real MPC-BE tracking overrides). + _start_assumed_playback(ctx, root_id, req.file_path) loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot() if trace_id: @@ -1097,6 +1387,16 @@ async def root_metadata(root_id: str, meta_key: str): return {"key": meta_key, "data": _load_root_metadata(ctx.root_path, meta_key)} +@app.post("/api/activity") +async def report_activity(): + """Report user input activity in the frontend. + + Ends any assumed-playback session: activity means the user is back at + the UI, so the launched item's guessed watch time is written out. + """ + return {"status": "ok", "finalized": _finalize_assumed_playback()} + + @app.get("/api/meta/playback-state") async def merged_playback_state(): """Return merged playback-state resume positions from in-memory cache. @@ -1111,18 +1411,66 @@ async def merged_playback_state(): @app.post("/api/meta/playback-state") async def write_playback_state(request: Request): - """Update one playback-state entry via backend-managed read-modify-write.""" + """Update one playback-state entry via backend-managed read-modify-write. + + A series episode played to completion (pos null) is marked fully watched + in the per-episode watch map and advances the series' single continue + point to the next episode (pos 0); finishing the final episode clears + the continue point but keeps the watch history. + """ 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: + ref = _resolve_media_ref_for_file_path(ctx, req.file_path) + if ref is None: raise HTTPException( status_code=404, - detail=f"Movie not found for file path: {req.file_path}", + detail=f"Media 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) + slug, season_number, episode_number = ref + + if req.pos is None or req.pos <= 0: + if season_number is None: + playback_state_cache.update_resume_position( + req.root_id, ctx.root_path, slug, None + ) + return {"status": "ok", "slug": slug, "pos": None} + + show = ctx.store.series.get(slug) + next_ref = ( + _next_episode_ref(show, season_number, episode_number) + if show is not None + else None + ) + done = (season_number, episode_number) + if next_ref is None: + playback_state_cache.update_resume_position( + req.root_id, ctx.root_path, slug, None, done_episode=done + ) + return {"status": "ok", "slug": slug, "pos": None} + + next_season, next_episode = next_ref + playback_state_cache.update_resume_position( + req.root_id, + ctx.root_path, + slug, + 0, + next_season, + next_episode, + done_episode=done, + ) + return { + "status": "ok", + "slug": slug, + "pos": 0, + "season": next_season, + "episode": next_episode, + } + + pos = int(req.pos) + playback_state_cache.update_resume_position( + req.root_id, ctx.root_path, slug, pos, season_number, episode_number + ) return {"status": "ok", "slug": slug, "pos": pos} diff --git a/mediahive/winmain.py b/mediahive/winmain.py index f2b80f5..e5e30b3 100644 --- a/mediahive/winmain.py +++ b/mediahive/winmain.py @@ -54,6 +54,9 @@ MPC_BE_STATE_RUNNING = 2 MPC_BE_SEEK_BEGIN_COMMAND = 1085 MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000 MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000 +# Watching (or presumably watching) less than this leaves no position data: +# brief peeks and seeks back to re-view a scene are not true progress. +MPC_BE_RESUME_MIN_WATCH_MS = 5 * 60 * 1000 MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0 VOLUME_MIN = 0.0 VOLUME_MAX = 1.5 @@ -140,7 +143,20 @@ def _expand_playable_file(file_key: str, playable_file: str | None) -> str: return f"{file_key}/{playable_file}" -def _fetch_resume_positions(backend_url: str) -> dict[str, int]: +def _fetch_resume_positions( + backend_url: str, +) -> tuple[ + dict[str, tuple[int, int | None, int | None]], + dict[tuple[str, int, int], int], +]: + """Fetch resume state from the backend. + + Returns (continue_points, episode_positions): continue_points map a slug + to (pos_ms, season_number, episode_number) — season/episode set for the + series' single continue point, None for movies. episode_positions map + (slug, season, episode) to pos_ms for partially watched episodes; + fully watched episodes are absent. + """ req = urllib.request.Request( url=f"{backend_url}/api/meta/playback-state", method="GET", @@ -149,21 +165,45 @@ def _fetch_resume_positions(backend_url: str) -> dict[str, int]: 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 {} + 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 {} + return {}, {} - cleaned: dict[str, int] = {} + cleaned: dict[str, tuple[int, int | None, int | None]] = {} + episode_positions: dict[tuple[str, int, int], int] = {} for slug, value in positions.items(): if not isinstance(slug, str) or not isinstance(value, dict): continue pos = value.get("pos") + season = value.get("season") + episode = value.get("episode") if isinstance(pos, int) and pos > 0: - cleaned[slug] = pos * 1000 - return cleaned + cleaned[slug] = ( + pos * 1000, + season if isinstance(season, int) else None, + episode if isinstance(episode, int) else None, + ) + elif pos == 0 and isinstance(season, int) and isinstance(episode, int): + # Series episode boundary marker (previous episode completed). + cleaned[slug] = (0, season, episode) + + episodes = value.get("episodes") + if not isinstance(episodes, dict): + continue + for key, watch in episodes.items(): + match = re.fullmatch(r"S(\d+)E(\d+)", str(key)) + if not match or not isinstance(watch, dict): + continue + ep_pos = watch.get("pos") + if watch.get("done") or not isinstance(ep_pos, int) or ep_pos <= 0: + continue + episode_positions[slug, int(match.group(1)), int(match.group(2))] = ( + ep_pos * 1000 + ) + return cleaned, episode_positions def _post_resume_position( @@ -191,51 +231,105 @@ def _post_resume_position( return False -def _load_movie_slug_map(index_path: Path) -> dict[str, str]: +def _media_file_key( + mapping: dict[str, str], file_key: str, torrent: object, media_key: str +) -> None: + """Map both the raw file key and its expanded playable path to a media key.""" + mapping[_normalize_media_path(file_key)] = media_key + 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)] = media_key + + +def _split_media_key(media_key: str) -> tuple[str, int | None, int | None]: + """Split a media key into (slug, season_number, episode_number).""" + slug, separator, ep_ref = media_key.partition("#") + if not separator: + return slug, None, None + match = re.fullmatch(r"S(\d+)E(\d+)", ep_ref) + if not match: + return slug, None, None + return slug, int(match.group(1)), int(match.group(2)) + + +def _load_media_key_map(index_path: Path) -> dict[str, str]: + """Map normalized playable file paths to media keys. + + Movies map to their movie id; series episodes map to + "#SE" so episode switches are detected while + the backend keeps a single continue point per series. + """ try: raw = json.loads(index_path.read_text(encoding="utf-8")) except OSError, TypeError, json.JSONDecodeError: return {} - movies = raw.get("movies") if isinstance(raw, dict) else None - if not isinstance(movies, dict): + if not isinstance(raw, dict): return {} 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): + + movies = raw.get("movies") + if isinstance(movies, dict): + for movie_id, movie in movies.items(): + if not isinstance(movie_id, str) or not isinstance(movie, dict): 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 + files = movie.get("files") + if not isinstance(files, dict): + continue + for file_key, torrent in files.items(): + if not isinstance(file_key, str): + continue + _media_file_key(mapping, file_key, torrent, movie_id) + + series = raw.get("series") + if isinstance(series, dict): + for series_id, show in series.items(): + if not isinstance(series_id, str) or not isinstance(show, dict): + continue + seasons = show.get("seasons") + if not isinstance(seasons, list): + continue + for season in seasons: + if not isinstance(season, dict): + continue + season_number = season.get("season_number") + episodes = season.get("episodes") + if not isinstance(season_number, int) or not isinstance(episodes, list): + continue + for episode in episodes: + if not isinstance(episode, dict): + continue + episode_number = episode.get("episode_number") + files = episode.get("files") + if not isinstance(episode_number, int) or not isinstance( + files, dict + ): + continue + media_key = f"{series_id}#S{season_number}E{episode_number}" + for file_key, torrent in files.items(): + if not isinstance(file_key, str): + continue + _media_file_key(mapping, file_key, torrent, media_key) + return mapping def _media_key_for_filepath( filepath: str, roots: dict[str, Path] ) -> tuple[str | None, str, str] | None: - """Resolve a filepath to a (movie_slug, root_id, relative_key) tuple.""" + """Resolve a filepath to a (media_key, root_id, relative_key) tuple.""" for root_id, root in roots.items(): try: relative = Path(filepath).resolve().relative_to(root.resolve()) relative_key = relative.as_posix() index_path = root / ".mediahive" / "index.json" - movie_slug = _load_movie_slug_map(index_path).get( + media_key = _load_media_key_map(index_path).get( _normalize_media_path(relative_key) ) - return movie_slug, root_id, relative_key + return media_key, root_id, relative_key except OSError, RuntimeError, ValueError: continue return None @@ -348,7 +442,7 @@ def _start_gamepad_remote( status_miss_count = 0 playback_state = _default_playback_state() - resume_positions = _fetch_resume_positions(backend_url) + resume_positions, episode_positions = _fetch_resume_positions(backend_url) tracked_media_key: str | None = None tracked_root_id: str | None = None tracked_relative_path = "" @@ -400,18 +494,34 @@ def _start_gamepad_remote( position_ms = player_position_ms or 0 duration_ms = player_duration_ms or 0 + tracked_slug, tracked_season, tracked_episode = _split_media_key( + tracked_media_key + ) if _should_clear_resume(position_ms, duration_ms): - resume_positions.pop(tracked_media_key, None) + resume_positions.pop(tracked_slug, None) + if tracked_season is not None and tracked_episode is not None: + episode_positions.pop( + (tracked_slug, tracked_season, tracked_episode), 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. + elif position_ms < MPC_BE_RESUME_MIN_WATCH_MS: + # Peeks and brief seeks are not true progress; keep the previous + # saved resume position. pass else: position_seconds = max(0, position_ms // 1000) - resume_positions[tracked_media_key] = position_ms + resume_positions[tracked_slug] = ( + position_ms, + tracked_season, + tracked_episode, + ) + if tracked_season is not None and tracked_episode is not None: + episode_positions[tracked_slug, tracked_season, tracked_episode] = ( + position_ms + ) if tracked_root_id and tracked_relative_path: _post_resume_position( backend_url, @@ -454,8 +564,36 @@ def _start_gamepad_remote( if resume_applied_for_key == tracked_media_key: return - saved_position = resume_positions.get(tracked_media_key) - if not isinstance(saved_position, int): + tracked_slug, tracked_season, tracked_episode = _split_media_key( + tracked_media_key + ) + if tracked_season is not None and tracked_episode is not None: + # Series: the episode's own saved position wins; fall back to the + # series continue point when it points at this very episode. + saved_position = episode_positions.get(( + tracked_slug, + tracked_season, + tracked_episode, + )) + if saved_position is None: + saved = resume_positions.get(tracked_slug) + if saved is None or (saved[1], saved[2]) != ( + tracked_season, + tracked_episode, + ): + # The series continue point belongs to a different episode. + resume_applied_for_key = tracked_media_key + return + saved_position = saved[0] + else: + saved = resume_positions.get(tracked_slug) + if saved is None: + resume_applied_for_key = tracked_media_key + return + saved_position = saved[0] + + if saved_position <= 0: + # Episode boundary marker (previous episode completed): start at 0. resume_applied_for_key = tracked_media_key return if player_position_ms is None or player_duration_ms is None: @@ -464,7 +602,11 @@ def _start_gamepad_remote( resume_applied_for_key = tracked_media_key return if _should_clear_resume(saved_position, player_duration_ms): - resume_positions.pop(tracked_media_key, None) + resume_positions.pop(tracked_slug, None) + if tracked_season is not None and tracked_episode is not None: + episode_positions.pop( + (tracked_slug, tracked_season, tracked_episode), None + ) resume_applied_for_key = tracked_media_key return if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS: