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.
This commit is contained in:
2026-09-10 20:56:03 +00:00
parent 6a6a012efe
commit 9ec4f877eb
9 changed files with 916 additions and 130 deletions
+5 -2
View File
@@ -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 `<root>/.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`: `"S<season>E<episode>"``{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.
+31 -2
View File
@@ -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<MediaItem[]>([])
const isSearching = ref(false)
const mpcBeConnected = ref(false)
const resumePositions = ref<Record<string, number>>({})
const resumePositions = ref<Record<string, ResumePositionEntry>>({})
const searchQuery = ref(getRouteSearchQuery())
const searchReturnPath = ref<string | null>(null)
const browsePanelRef = ref<HTMLElement | null>(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<string, EpisodeWatchEntry> | 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()
})
+63 -5
View File
@@ -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 "S<season>E<episode>". */
episodes?: Record<string, EpisodeWatchEntry>
}
/**
* Fetch merged resume positions from all roots.
*/
export async function fetchResumePositions(): Promise<Record<string, number>> {
export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
try {
const response = await fetch("/api/meta/playback-state")
if (!response.ok) return {}
@@ -160,12 +175,32 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
if (!positions || typeof positions !== "object") {
return {}
}
const normalized: Record<string, number> = {}
const normalized: Record<string, ResumePositionEntry> = {}
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
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<string, EpisodeWatchEntry> = {}
for (const [key, watch] of Object.entries(
rawEpisodes as Record<string, unknown>,
)) {
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<Record<string, number>> {
}
}
/**
* 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
*/
+6 -2
View File
@@ -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 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue"
import type { CastMember, MediaItem, Movie, MovieUi, Series, Torrent } from "../types"
import type { CastMember, MediaItem, Movie, MovieUi, Series, SeriesResumePoint, Torrent } from "../types"
import type { EpisodeWatchEntry } from "../api"
import {
getCoverUrl,
getVideoPreviewUrl,
@@ -260,6 +262,8 @@ const props = defineProps<{
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (mediaId: string | null) => boolean
getResumePoint: (mediaId: string | null) => SeriesResumePoint | null
getResumeEpisodes: (mediaId: string | null) => Record<string, EpisodeWatchEntry> | null
getRootName: (rootId: string | null | undefined) => string | null
}>()
const emit = defineEmits<{
+244 -55
View File
@@ -158,6 +158,14 @@
/>
</video>
<span class="ep-number">{{ episode.episode_number }}</span>
<span
v-if="episodeWatchIndicator(episode)"
class="ep-watch"
:title="
episodeWatchIndicator(episode) === '●' ? 'Watched' : 'Partially watched'
"
>{{ episodeWatchIndicator(episode) }}</span
>
<div class="tile-play"></div>
</div>
@@ -230,7 +238,7 @@
`Episode ${episodeReleaseMenu.episode?.episode_number}`
"
:releases="episodeReleaseMenuReleases"
:has-resume-position="props.hasResumePosition"
:has-resume-position="hasEpisodeResumePosition"
@play="handlePlayVersion"
@open-folder="handleOpenFolderFromMenu"
@close="closeEpisodeReleaseMenu"
@@ -241,8 +249,9 @@
<script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, MovieUi } from "../types"
import type { Series, Season, Episode, MovieUi, SeriesResumePoint } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import type { EpisodeWatchEntry } from "../api"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
@@ -252,7 +261,8 @@ const props = defineProps<{
series: Series & { root_id?: string | null }
allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (filePath: string | null) => boolean
resumePoint?: SeriesResumePoint | null
resumeEpisodes?: Record<string, EpisodeWatchEntry> | null
getRootName: (rootId: string | null | undefined) => string | null
}>()
@@ -269,7 +279,7 @@ const linkedMoviesNavRow = ref(3)
let navLayoutFrame: number | null = null
function getInitialSeasonIndex(): number {
const seasonNumber = props.focusEpisode?.seasonNumber
const seasonNumber = props.focusEpisode?.seasonNumber ?? props.resumePoint?.seasonNumber
if (typeof seasonNumber === "number") {
const index = props.series.seasons.findIndex((s) => s.season_number === seasonNumber)
if (index >= 0) return index
@@ -279,6 +289,10 @@ function getInitialSeasonIndex(): number {
const selectedSeasonIndex = ref(getInitialSeasonIndex())
// Tracks whether the user has deliberately navigated the season selector or
// episode grid; a late-arriving resume point must not yank focus afterwards.
const seasonUserInteracted = ref(false)
const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null,
)
@@ -286,6 +300,7 @@ const selectedSeason = computed<Season | null>(
function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return
seasonUserInteracted.value = true
selectedSeasonIndex.value = index
episodeCursorIndex.value = null
scheduleEpisodeMediaReady()
@@ -316,16 +331,80 @@ function scheduleEpisodeMediaReady() {
}, EPISODE_MEDIA_SETTLE_MS)
}
// Spoiler avoidance: episodes ahead of the cursor (keyboard/gamepad focus, or
// mouse hover via the focus it triggers) are dimmed with their synopsis hidden.
// Spoiler avoidance: episodes past the visibility threshold are dimmed with
// their synopsis hidden (playback is stopped via the cursor watch). The
// threshold is the further of the cursor (keyboard/gamepad focus, or mouse
// hover via the focus it triggers) and the series' continue point, so
// already-watched episodes stay visible even when the cursor moves back.
const episodeCursorIndex = ref<number | null>(null)
// Global episode ordering across the series: seasons in list order,
// episodes in list order within each season.
function seasonEpisodeOffset(seasonIndex: number): number {
let total = 0
const seasons = props.series.seasons
for (let i = 0; i < seasonIndex && i < seasons.length; i += 1) {
total += seasons[i]?.episodes.length ?? 0
}
return total
}
const cursorGlobalIndex = computed(() =>
episodeCursorIndex.value === null
? null
: seasonEpisodeOffset(selectedSeasonIndex.value) + episodeCursorIndex.value,
)
const resumePointGlobalIndex = computed(() => {
const point = props.resumePoint
if (!point) return null
const seasonIndex = props.series.seasons.findIndex(
(s) => s.season_number === point.seasonNumber,
)
if (seasonIndex < 0) return null
const episodeIndex = props.series.seasons[seasonIndex]?.episodes.findIndex(
(e) => e.episode_number === point.episodeNumber,
)
if (episodeIndex === undefined || episodeIndex < 0) return null
// The continue point itself stays visible; anything past it is hidden.
return seasonEpisodeOffset(seasonIndex) + episodeIndex
})
function isEpisodeAhead(episodeIndex: number): boolean {
return episodeCursorIndex.value !== null && episodeIndex > episodeCursorIndex.value
const threshold = Math.max(cursorGlobalIndex.value ?? -1, resumePointGlobalIndex.value ?? -1)
if (threshold < 0) return false
return seasonEpisodeOffset(selectedSeasonIndex.value) + episodeIndex > threshold
}
// Small watch-progress indicator per episode: quadrant circle chars, none
// when there is no watch data; near the end counts as fully watched.
function episodeWatchIndicator(episode: Episode): string | null {
const season = selectedSeason.value
const watches = props.resumeEpisodes
if (!season || !watches) return null
const watch = watches[`S${season.season_number}E${episode.episode_number}`]
if (!watch) return null
if (watch.done) return "●"
if (watch.pos <= 0) return null
const durationS = episode.runtime ? episode.runtime * 60 : null
if (!durationS) return "◔"
const fraction = watch.pos / durationS
if (fraction >= 0.95) return "●"
if (fraction >= 0.5) return "◕"
if (fraction >= 0.25) return "◑"
return "◔"
}
function handleEpisodeFocusIn(event: FocusEvent, episodeIndex: number) {
episodeCursorIndex.value = episodeIndex
// Keyboard/gamepad navigation lands here without any mouse event, so this
// is where those paths claim audio. Programmatic focus on open is
// suppressed so the auto-focused resume episode stays silent.
if (suppressNextFocusAudio) {
suppressNextFocusAudio = false
} else {
setAudioOwner(`${episodeIndex}`)
}
// Updating the cursor re-renders this tile's :class binding, and Vue's class
// patch rewrites the whole class attribute, clobbering the "nav-focused"
// class that the keyboard-navigation composable adds imperatively during
@@ -346,7 +425,14 @@ function getEpisodeStill(episode: Episode): string | undefined {
}
function handleVideoPlaying(event: Event) {
;(event.target as HTMLVideoElement | null)?.classList.add("is-playing")
const video = event.target as HTMLVideoElement | null
video?.classList.add("is-playing")
// Deferred hover audio: if this tile was hovered before its video started
// (lazy mount), unmute/ramp now that playback is running.
const key = video?.dataset.previewKey
if (video && key && key === audioOwnerKey) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
}
// Poster browser stage: the selected season is the topmost item of the left
@@ -536,11 +622,28 @@ watch(
{ immediate: true },
)
// Focus on matched episode when provided
// Focus target: an explicit episode (search match) wins; otherwise the
// series' continue point takes us to the season/episode being watched.
const episodeFocusTarget = computed(() => {
if (props.focusEpisode) return props.focusEpisode
const point = props.resumePoint
if (!point) return null
return { seasonNumber: point.seasonNumber, episodeNumber: point.episodeNumber }
})
// Focus on the target episode when provided. The resume-point fallback only
// applies until the user navigates on their own, so late-arriving resume
// data does not yank focus away.
watch(
() => props.focusEpisode,
episodeFocusTarget,
(ep) => {
if (!ep) return
if (
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return
}
const seasonIndex =
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
if (seasonIndex < 0) return
@@ -560,6 +663,7 @@ watch(
const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
suppressNextFocusAudio = true
element.focus()
}
}, 150)
@@ -588,6 +692,20 @@ const episodeReleaseMenuReleases = computed(() => {
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
})
// "Continue" label for the episode release menu: shown when the menu's
// episode is the series' continue point with a real position to resume.
function hasEpisodeResumePosition(_filePath: string | null): boolean {
const point = props.resumePoint
const episode = episodeReleaseMenu.value.episode
if (!point || point.positionSeconds <= 0 || !episode) return false
const seasonIndex = props.series.seasons.findIndex((s) => s.episodes.includes(episode))
if (seasonIndex < 0) return false
return (
props.series.seasons[seasonIndex]?.season_number === point.seasonNumber &&
episode.episode_number === point.episodeNumber
)
}
function normalizeMatchText(value: string | null | undefined): string {
return (value || "")
.toLowerCase()
@@ -806,8 +924,8 @@ function stopEpisodePreviews() {
seasonStartupToken += 1
const token = seasonStartupToken
clearSeasonStartupTimers()
clearEpisodeHoverAudioIdleTimer()
hoveredEpisodeAudioKey = null
clearAudioIdleTimer()
audioOwnerKey = null
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
@@ -844,10 +962,9 @@ function syncSeasonVideoPlayback() {
seasonStartupToken += 1
const token = seasonStartupToken
clearSeasonStartupTimers()
for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval)
}
volumeFadeIntervals.clear()
// Do NOT touch volumeFadeIntervals here: hover/focus audio ramps run
// independently of playback sync, and clearing them mid-fade leaves the
// previous tile stuck audible and the newly hovered one stuck silent.
const videosToStart: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = []
const videosToStop: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = []
@@ -948,12 +1065,21 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
el.dataset.previewKey = key
getEpisodeVisibilityObserver().observe(el)
syncSeasonVideoPlayback()
// A hover/focus that arrived before this video mounted still owns the audio.
if (audioOwnerKey === key && !el.paused && !el.ended) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
} else {
const timeoutId = seasonStartupTimers.get(key)
if (timeoutId) {
clearTimeout(timeoutId)
seasonStartupTimers.delete(key)
}
const fadeInterval = volumeFadeIntervals.get(key)
if (fadeInterval) {
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
}
const old = videoRefs.value.get(key)
if (old) {
cleanupVideo(old)
@@ -969,19 +1095,29 @@ const AUDIO_FADE_INTERVAL_MS = 40
const AUDIO_IDLE_FADE_DELAY_MS = 1600
const AUDIO_LEAVE_FADE_DELAY_MS = 350
const AUDIO_HOVER_TARGET_VOLUME = 0.5
let hoveredEpisodeAudioKey: string | null = null
let hoverEpisodeAudioIdleTimer: ReturnType<typeof setTimeout> | null = null
// Single audio owner: the episode tile currently pointed at (mouse hover or
// keyboard/gamepad focus). Only this tile's video is unmuted; every other
// mounted video is ramped to silence whenever the owner changes.
let audioOwnerKey: string | null = null
let audioIdleTimer: ReturnType<typeof setTimeout> | null = null
// Set while a programmatic focus (season open / resume point) is in flight so
// the resulting focusin does not grab audio the user never asked for.
let suppressNextFocusAudio = false
function clearEpisodeHoverAudioIdleTimer() {
if (hoverEpisodeAudioIdleTimer !== null) {
clearTimeout(hoverEpisodeAudioIdleTimer)
hoverEpisodeAudioIdleTimer = null
function clearAudioIdleTimer() {
if (audioIdleTimer !== null) {
clearTimeout(audioIdleTimer)
audioIdleTimer = null
}
}
function rampEpisodeVolume(key: string, targetVolume: number) {
const video = videoRefs.value.get(key)
if (!video) return
// Never touch a paused element: unmuting before play() would turn the
// start into audible autoplay, which browsers may block. The playing
// event applies pending hover audio once playback is running.
if (video.paused) return
const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) {
@@ -998,6 +1134,13 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
}
const fadeInterval = setInterval(() => {
if (video.paused) {
// Playback stopped mid-fade (e.g. scrolled offscreen or spoiler fade):
// abandon the ramp so it cannot resurrect audio on a stopped video.
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
return
}
const delta = clampedTarget - video.volume
if (Math.abs(delta) <= AUDIO_FADE_STEP) {
video.volume = clampedTarget
@@ -1015,23 +1158,60 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
volumeFadeIntervals.set(key, fadeInterval)
}
function scheduleEpisodeHoverAudioIdleFade(
key: string,
delayMs: number = AUDIO_IDLE_FADE_DELAY_MS,
) {
clearEpisodeHoverAudioIdleTimer()
hoverEpisodeAudioIdleTimer = setTimeout(() => {
// Silence the current owner after a delay. If the owner is reassigned before
// the timer fires, clearAudioIdleTimer() keeps the old tile audible — the new
// owner's setAudioOwner() call ramps it down instead.
function scheduleAudioIdleFade(delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) {
const key = audioOwnerKey
clearAudioIdleTimer()
if (!key) return
audioIdleTimer = setTimeout(() => {
audioIdleTimer = null
if (audioOwnerKey !== key) return
audioOwnerKey = null
rampEpisodeVolume(key, 0)
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
}, delayMs)
}
function setAudioOwner(key: string | null) {
if (key === audioOwnerKey) {
// Same tile pointed at again (mousemove, repeat focus): just re-arm idle.
scheduleAudioIdleFade()
return
}
audioOwnerKey = key
videoRefs.value.forEach((_, k) => {
if (k !== key) {
rampEpisodeVolume(k, 0)
}
})
// No-op while the video is not mounted/playing yet (lazy mount); the
// playing event applies the pending audio once playback starts.
if (key) {
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
}
scheduleAudioIdleFade()
}
// Re-acquire audio for the currently pointed tile after an idle fade, and
// re-arm the idle timer on any continued activity.
function rearmAudioFromActivity() {
const pointedKey = episodeCursorIndex.value === null ? null : `${episodeCursorIndex.value}`
if (audioOwnerKey !== pointedKey) {
setAudioOwner(pointedKey)
} else if (pointedKey) {
scheduleAudioIdleFade()
}
}
function handleEpisodeHoverAudioMouseMove() {
if (!document.documentElement.classList.contains("mouse-active")) return
if (!hoveredEpisodeAudioKey) return
scheduleEpisodeHoverAudioIdleFade(hoveredEpisodeAudioKey)
rearmAudioFromActivity()
}
function handleAudioKeyActivity() {
if (document.documentElement.classList.contains("mouse-active")) return
rearmAudioFromActivity()
}
// Handle hover-based audio fade in/out for episode videos
@@ -1047,27 +1227,15 @@ function handleEpisodeHover(
if (!document.documentElement.classList.contains("mouse-active")) return
const video = videoRefs.value.get(key)
if (!video) return
if (isEntering) {
if (event?.currentTarget instanceof HTMLElement) {
event.currentTarget.focus({ preventScroll: true })
}
syncSeasonVideoPlayback()
hoveredEpisodeAudioKey = key
videoRefs.value.forEach((_, k) => {
if (k !== key) {
rampEpisodeVolume(k, 0)
}
})
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
scheduleEpisodeHoverAudioIdleFade(key)
} else {
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
scheduleEpisodeHoverAudioIdleFade(key, AUDIO_LEAVE_FADE_DELAY_MS)
setAudioOwner(key)
} else if (audioOwnerKey === key) {
// Keep the owner until the leave grace expires, so quick re-entry is
// seamless; scheduleAudioIdleFade drops ownership when it fires.
scheduleAudioIdleFade(AUDIO_LEAVE_FADE_DELAY_MS)
}
}
@@ -1190,6 +1358,7 @@ onMounted(() => {
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.addEventListener("resize", handleStageResize, { passive: true })
window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true })
window.addEventListener("keydown", handleAudioKeyActivity)
nextTick(() => {
measureStageWidth()
scheduleEpisodeMediaReady()
@@ -1201,7 +1370,8 @@ onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.removeEventListener("resize", handleStageResize)
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer()
window.removeEventListener("keydown", handleAudioKeyActivity)
clearAudioIdleTimer()
clearSeasonStartupTimers()
if (episodeMediaReadyTimer !== null) {
clearTimeout(episodeMediaReadyTimer)
@@ -1236,8 +1406,14 @@ watch(
},
)
// Episodes ahead of the cursor are spoiler-faded; stop their playback too.
watch(episodeCursorIndex, () => {
// Episodes past the spoiler threshold (cursor or continue point) are faded;
// stop their playback too.
watch([episodeCursorIndex, resumePointGlobalIndex], () => {
if (episodeCursorIndex.value === null) {
// Cursor left the episode grid (season selector, season switch): no tile
// is pointed at, so audio must go silent regardless of how we got here.
setAudioOwner(null)
}
syncSeasonVideoPlayback()
})
</script>
@@ -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;
@@ -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")
}
+7
View File
@@ -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
+375 -27
View File
@@ -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 "S<season>E<episode>".
"""
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}
+171 -29
View File
@@ -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,17 +231,48 @@ 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
"<series_id>#S<season>E<episode>" 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] = {}
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
@@ -211,31 +282,54 @@ def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
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
_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: