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: