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/health` | Lightweight health check. |
| `GET` | `/api/config` | Returns the current root configuration. | | `GET` | `/api/config` | Returns the current root configuration. |
| `PUT` | `/api/config/roots` | Atomically replace the full root set. | | `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. | | `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/{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/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/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. | | `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/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/player/status` returns `{ "remote": true|false }`.
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms. - `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 ?? []" :all-movies="mediaIndex?.movies ?? []"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :has-resume-position="hasResumePosition"
:get-resume-point="getResumePoint"
:get-resume-episodes="getResumeEpisodes"
:get-root-name="getRootName" :get-root-name="getRootName"
@close="closeDetail" @close="closeDetail"
@play="handlePlay" @play="handlePlay"
@@ -201,6 +203,7 @@ import type {
MediaItem, MediaItem,
EpisodeWithSeries, EpisodeWithSeries,
TaskInfo, TaskInfo,
SeriesResumePoint,
} from "./types" } from "./types"
import { import {
playMedia, playMedia,
@@ -208,6 +211,8 @@ import {
isMpcBeReachable, isMpcBeReachable,
fetchResumePositions, fetchResumePositions,
getPlayerStatus, getPlayerStatus,
type ResumePositionEntry,
type EpisodeWatchEntry,
} from "./api" } from "./api"
import { useSettings } from "./composables/useSettings" import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation" import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
@@ -497,7 +502,7 @@ const settings = useSettings()
const searchResults = ref<MediaItem[]>([]) const searchResults = ref<MediaItem[]>([])
const isSearching = ref(false) const isSearching = ref(false)
const mpcBeConnected = ref(false) const mpcBeConnected = ref(false)
const resumePositions = ref<Record<string, number>>({}) const resumePositions = ref<Record<string, ResumePositionEntry>>({})
const searchQuery = ref(getRouteSearchQuery()) const searchQuery = ref(getRouteSearchQuery())
const searchReturnPath = ref<string | null>(null) const searchReturnPath = ref<string | null>(null)
const browsePanelRef = ref<HTMLElement | null>(null) const browsePanelRef = ref<HTMLElement | null>(null)
@@ -540,6 +545,10 @@ async function refreshResumePositions() {
resumePositions.value = await fetchResumePositions() resumePositions.value = await fetchResumePositions()
} }
function refreshResumePositionsAsEvent() {
void refreshResumePositions()
}
async function refreshPlayerStatus() { async function refreshPlayerStatus() {
if (!isMpcFamilySelected()) { if (!isMpcFamilySelected()) {
mpcBeConnected.value = false mpcBeConnected.value = false
@@ -555,7 +564,25 @@ async function refreshPlayerStatus() {
function hasResumePosition(mediaId: string | null) { function hasResumePosition(mediaId: string | null) {
if (!mediaId) return false 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() { function startMpcBePolling() {
@@ -936,6 +963,7 @@ onMounted(() => {
document.addEventListener("keydown", handleDetailAdjacentKey) document.addEventListener("keydown", handleDetailAdjacentKey)
window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) window.addEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) window.addEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.addEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -943,6 +971,7 @@ onUnmounted(() => {
document.removeEventListener("keydown", handleDetailAdjacentKey) document.removeEventListener("keydown", handleDetailAdjacentKey)
window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener) window.removeEventListener("mediahive:gamepad-action", onGamepadAction as EventListener)
window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener) window.removeEventListener("mediahive:gamepad-button", onRawGamepadButton as EventListener)
window.removeEventListener("mediahive:resume-updated", refreshResumePositionsAsEvent)
stopMpcBePolling() stopMpcBePolling()
}) })
+63 -5
View File
@@ -148,10 +148,25 @@ function splitAssetTypePath(assetPath: string): { assetType: string; relativePat
return { assetType: assetType.toLowerCase(), relativePath: rest.join("/") } 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. * Fetch merged resume positions from all roots.
*/ */
export async function fetchResumePositions(): Promise<Record<string, number>> { export async function fetchResumePositions(): Promise<Record<string, ResumePositionEntry>> {
try { try {
const response = await fetch("/api/meta/playback-state") const response = await fetch("/api/meta/playback-state")
if (!response.ok) return {} if (!response.ok) return {}
@@ -160,12 +175,32 @@ export async function fetchResumePositions(): Promise<Record<string, number>> {
if (!positions || typeof positions !== "object") { if (!positions || typeof positions !== "object") {
return {} return {}
} }
const normalized: Record<string, number> = {} const normalized: Record<string, ResumePositionEntry> = {}
for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) { for (const [slug, value] of Object.entries(positions as Record<string, unknown>)) {
if (!value || typeof value !== "object") continue if (!value || typeof value !== "object") continue
const pos = (value as { pos?: unknown }).pos const entry = value as { pos?: unknown; season?: unknown; episode?: unknown }
if (typeof pos === "number" && Number.isFinite(pos) && pos > 0) { if (typeof entry.pos !== "number" || !Number.isFinite(entry.pos) || entry.pos < 0) {
normalized[slug] = pos 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 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 * Replace the full root set atomically
*/ */
+6 -2
View File
@@ -5,7 +5,8 @@
:series="item.data as Series" :series="item.data as Series"
:all-movies="allMovies" :all-movies="allMovies"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition" :resume-point="getResumePoint(item.id)"
:resume-episodes="getResumeEpisodes(item.id)"
:get-root-name="getRootName" :get-root-name="getRootName"
@close="$emit('close')" @close="$emit('close')"
@play="handlePlay" @play="handlePlay"
@@ -233,7 +234,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue" 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 { import {
getCoverUrl, getCoverUrl,
getVideoPreviewUrl, getVideoPreviewUrl,
@@ -260,6 +262,8 @@ const props = defineProps<{
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null focusEpisode?: { seasonNumber: number; episodeNumber: number } | null
hasResumePosition: (mediaId: string | null) => boolean 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 getRootName: (rootId: string | null | undefined) => string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+244 -55
View File
@@ -158,6 +158,14 @@
/> />
</video> </video>
<span class="ep-number">{{ episode.episode_number }}</span> <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 class="tile-play"></div>
</div> </div>
@@ -230,7 +238,7 @@
`Episode ${episodeReleaseMenu.episode?.episode_number}` `Episode ${episodeReleaseMenu.episode?.episode_number}`
" "
:releases="episodeReleaseMenuReleases" :releases="episodeReleaseMenuReleases"
:has-resume-position="props.hasResumePosition" :has-resume-position="hasEpisodeResumePosition"
@play="handlePlayVersion" @play="handlePlayVersion"
@open-folder="handleOpenFolderFromMenu" @open-folder="handleOpenFolderFromMenu"
@close="closeEpisodeReleaseMenu" @close="closeEpisodeReleaseMenu"
@@ -241,8 +249,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue" 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 { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import type { EpisodeWatchEntry } from "../api"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation" import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback" import { useIdlePreviewPlayback } from "../composables/useIdlePreviewPlayback"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue" import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
@@ -252,7 +261,8 @@ const props = defineProps<{
series: Series & { root_id?: string | null } series: Series & { root_id?: string | null }
allMovies: MovieUi[] allMovies: MovieUi[]
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null 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 getRootName: (rootId: string | null | undefined) => string | null
}>() }>()
@@ -269,7 +279,7 @@ const linkedMoviesNavRow = ref(3)
let navLayoutFrame: number | null = null let navLayoutFrame: number | null = null
function getInitialSeasonIndex(): number { function getInitialSeasonIndex(): number {
const seasonNumber = props.focusEpisode?.seasonNumber const seasonNumber = props.focusEpisode?.seasonNumber ?? props.resumePoint?.seasonNumber
if (typeof seasonNumber === "number") { if (typeof seasonNumber === "number") {
const index = props.series.seasons.findIndex((s) => s.season_number === seasonNumber) const index = props.series.seasons.findIndex((s) => s.season_number === seasonNumber)
if (index >= 0) return index if (index >= 0) return index
@@ -279,6 +289,10 @@ function getInitialSeasonIndex(): number {
const selectedSeasonIndex = ref(getInitialSeasonIndex()) 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>( const selectedSeason = computed<Season | null>(
() => props.series.seasons[selectedSeasonIndex.value] || null, () => props.series.seasons[selectedSeasonIndex.value] || null,
) )
@@ -286,6 +300,7 @@ const selectedSeason = computed<Season | null>(
function selectSeason(index: number) { function selectSeason(index: number) {
if (index < 0 || index >= props.series.seasons.length) return if (index < 0 || index >= props.series.seasons.length) return
if (selectedSeasonIndex.value === index) return if (selectedSeasonIndex.value === index) return
seasonUserInteracted.value = true
selectedSeasonIndex.value = index selectedSeasonIndex.value = index
episodeCursorIndex.value = null episodeCursorIndex.value = null
scheduleEpisodeMediaReady() scheduleEpisodeMediaReady()
@@ -316,16 +331,80 @@ function scheduleEpisodeMediaReady() {
}, EPISODE_MEDIA_SETTLE_MS) }, EPISODE_MEDIA_SETTLE_MS)
} }
// Spoiler avoidance: episodes ahead of the cursor (keyboard/gamepad focus, or // Spoiler avoidance: episodes past the visibility threshold are dimmed with
// mouse hover via the focus it triggers) are dimmed with their synopsis hidden. // 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) 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 { 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) { function handleEpisodeFocusIn(event: FocusEvent, episodeIndex: number) {
episodeCursorIndex.value = episodeIndex 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 // Updating the cursor re-renders this tile's :class binding, and Vue's class
// patch rewrites the whole class attribute, clobbering the "nav-focused" // patch rewrites the whole class attribute, clobbering the "nav-focused"
// class that the keyboard-navigation composable adds imperatively during // class that the keyboard-navigation composable adds imperatively during
@@ -346,7 +425,14 @@ function getEpisodeStill(episode: Episode): string | undefined {
} }
function handleVideoPlaying(event: Event) { 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 // Poster browser stage: the selected season is the topmost item of the left
@@ -536,11 +622,28 @@ watch(
{ immediate: true }, { 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( watch(
() => props.focusEpisode, episodeFocusTarget,
(ep) => { (ep) => {
if (!ep) return if (!ep) return
if (
!props.focusEpisode &&
(seasonUserInteracted.value || episodeCursorIndex.value !== null)
) {
return
}
const seasonIndex = const seasonIndex =
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1 props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
if (seasonIndex < 0) return if (seasonIndex < 0) return
@@ -560,6 +663,7 @@ watch(
const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null
if (element) { if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
suppressNextFocusAudio = true
element.focus() element.focus()
} }
}, 150) }, 150)
@@ -588,6 +692,20 @@ const episodeReleaseMenuReleases = computed(() => {
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {})) 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 { function normalizeMatchText(value: string | null | undefined): string {
return (value || "") return (value || "")
.toLowerCase() .toLowerCase()
@@ -806,8 +924,8 @@ function stopEpisodePreviews() {
seasonStartupToken += 1 seasonStartupToken += 1
const token = seasonStartupToken const token = seasonStartupToken
clearSeasonStartupTimers() clearSeasonStartupTimers()
clearEpisodeHoverAudioIdleTimer() clearAudioIdleTimer()
hoveredEpisodeAudioKey = null audioOwnerKey = null
for (const interval of volumeFadeIntervals.values()) { for (const interval of volumeFadeIntervals.values()) {
clearInterval(interval) clearInterval(interval)
} }
@@ -844,10 +962,9 @@ function syncSeasonVideoPlayback() {
seasonStartupToken += 1 seasonStartupToken += 1
const token = seasonStartupToken const token = seasonStartupToken
clearSeasonStartupTimers() clearSeasonStartupTimers()
for (const interval of volumeFadeIntervals.values()) { // Do NOT touch volumeFadeIntervals here: hover/focus audio ramps run
clearInterval(interval) // independently of playback sync, and clearing them mid-fade leaves the
} // previous tile stuck audible and the newly hovered one stuck silent.
volumeFadeIntervals.clear()
const videosToStart: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = [] const videosToStart: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = []
const videosToStop: 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 el.dataset.previewKey = key
getEpisodeVisibilityObserver().observe(el) getEpisodeVisibilityObserver().observe(el)
syncSeasonVideoPlayback() 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 { } else {
const timeoutId = seasonStartupTimers.get(key) const timeoutId = seasonStartupTimers.get(key)
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
seasonStartupTimers.delete(key) seasonStartupTimers.delete(key)
} }
const fadeInterval = volumeFadeIntervals.get(key)
if (fadeInterval) {
clearInterval(fadeInterval)
volumeFadeIntervals.delete(key)
}
const old = videoRefs.value.get(key) const old = videoRefs.value.get(key)
if (old) { if (old) {
cleanupVideo(old) cleanupVideo(old)
@@ -969,19 +1095,29 @@ const AUDIO_FADE_INTERVAL_MS = 40
const AUDIO_IDLE_FADE_DELAY_MS = 1600 const AUDIO_IDLE_FADE_DELAY_MS = 1600
const AUDIO_LEAVE_FADE_DELAY_MS = 350 const AUDIO_LEAVE_FADE_DELAY_MS = 350
const AUDIO_HOVER_TARGET_VOLUME = 0.5 const AUDIO_HOVER_TARGET_VOLUME = 0.5
let hoveredEpisodeAudioKey: string | null = null // Single audio owner: the episode tile currently pointed at (mouse hover or
let hoverEpisodeAudioIdleTimer: ReturnType<typeof setTimeout> | null = null // 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() { function clearAudioIdleTimer() {
if (hoverEpisodeAudioIdleTimer !== null) { if (audioIdleTimer !== null) {
clearTimeout(hoverEpisodeAudioIdleTimer) clearTimeout(audioIdleTimer)
hoverEpisodeAudioIdleTimer = null audioIdleTimer = null
} }
} }
function rampEpisodeVolume(key: string, targetVolume: number) { function rampEpisodeVolume(key: string, targetVolume: number) {
const video = videoRefs.value.get(key) const video = videoRefs.value.get(key)
if (!video) return 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) const existingInterval = volumeFadeIntervals.get(key)
if (existingInterval) { if (existingInterval) {
@@ -998,6 +1134,13 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
} }
const fadeInterval = setInterval(() => { 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 const delta = clampedTarget - video.volume
if (Math.abs(delta) <= AUDIO_FADE_STEP) { if (Math.abs(delta) <= AUDIO_FADE_STEP) {
video.volume = clampedTarget video.volume = clampedTarget
@@ -1015,23 +1158,60 @@ function rampEpisodeVolume(key: string, targetVolume: number) {
volumeFadeIntervals.set(key, fadeInterval) volumeFadeIntervals.set(key, fadeInterval)
} }
function scheduleEpisodeHoverAudioIdleFade( // Silence the current owner after a delay. If the owner is reassigned before
key: string, // the timer fires, clearAudioIdleTimer() keeps the old tile audible — the new
delayMs: number = AUDIO_IDLE_FADE_DELAY_MS, // owner's setAudioOwner() call ramps it down instead.
) { function scheduleAudioIdleFade(delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) {
clearEpisodeHoverAudioIdleTimer() const key = audioOwnerKey
hoverEpisodeAudioIdleTimer = setTimeout(() => { clearAudioIdleTimer()
if (!key) return
audioIdleTimer = setTimeout(() => {
audioIdleTimer = null
if (audioOwnerKey !== key) return
audioOwnerKey = null
rampEpisodeVolume(key, 0) rampEpisodeVolume(key, 0)
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
}, delayMs) }, 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() { function handleEpisodeHoverAudioMouseMove() {
if (!document.documentElement.classList.contains("mouse-active")) return if (!document.documentElement.classList.contains("mouse-active")) return
if (!hoveredEpisodeAudioKey) return rearmAudioFromActivity()
scheduleEpisodeHoverAudioIdleFade(hoveredEpisodeAudioKey) }
function handleAudioKeyActivity() {
if (document.documentElement.classList.contains("mouse-active")) return
rearmAudioFromActivity()
} }
// Handle hover-based audio fade in/out for episode videos // Handle hover-based audio fade in/out for episode videos
@@ -1047,27 +1227,15 @@ function handleEpisodeHover(
if (!document.documentElement.classList.contains("mouse-active")) return if (!document.documentElement.classList.contains("mouse-active")) return
const video = videoRefs.value.get(key)
if (!video) return
if (isEntering) { if (isEntering) {
if (event?.currentTarget instanceof HTMLElement) { if (event?.currentTarget instanceof HTMLElement) {
event.currentTarget.focus({ preventScroll: true }) event.currentTarget.focus({ preventScroll: true })
} }
syncSeasonVideoPlayback() setAudioOwner(key)
hoveredEpisodeAudioKey = key } else if (audioOwnerKey === key) {
videoRefs.value.forEach((_, k) => { // Keep the owner until the leave grace expires, so quick re-entry is
if (k !== key) { // seamless; scheduleAudioIdleFade drops ownership when it fires.
rampEpisodeVolume(k, 0) scheduleAudioIdleFade(AUDIO_LEAVE_FADE_DELAY_MS)
}
})
rampEpisodeVolume(key, AUDIO_HOVER_TARGET_VOLUME)
scheduleEpisodeHoverAudioIdleFade(key)
} else {
if (hoveredEpisodeAudioKey === key) {
hoveredEpisodeAudioKey = null
}
scheduleEpisodeHoverAudioIdleFade(key, AUDIO_LEAVE_FADE_DELAY_MS)
} }
} }
@@ -1190,6 +1358,7 @@ onMounted(() => {
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.addEventListener("resize", handleStageResize, { passive: true }) window.addEventListener("resize", handleStageResize, { passive: true })
window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true }) window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true })
window.addEventListener("keydown", handleAudioKeyActivity)
nextTick(() => { nextTick(() => {
measureStageWidth() measureStageWidth()
scheduleEpisodeMediaReady() scheduleEpisodeMediaReady()
@@ -1201,7 +1370,8 @@ onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.removeEventListener("resize", handleStageResize) window.removeEventListener("resize", handleStageResize)
window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove) window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove)
clearEpisodeHoverAudioIdleTimer() window.removeEventListener("keydown", handleAudioKeyActivity)
clearAudioIdleTimer()
clearSeasonStartupTimers() clearSeasonStartupTimers()
if (episodeMediaReadyTimer !== null) { if (episodeMediaReadyTimer !== null) {
clearTimeout(episodeMediaReadyTimer) clearTimeout(episodeMediaReadyTimer)
@@ -1236,8 +1406,14 @@ watch(
}, },
) )
// Episodes ahead of the cursor are spoiler-faded; stop their playback too. // Episodes past the spoiler threshold (cursor or continue point) are faded;
watch(episodeCursorIndex, () => { // 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() syncSeasonVideoPlayback()
}) })
</script> </script>
@@ -1723,8 +1899,9 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
opacity: 1; opacity: 1;
} }
/* Spoiler avoidance: episodes ahead of the cursor fade out completely, /* Spoiler avoidance: episodes past the spoiler threshold (cursor or
including the synopsis (playback is stopped via the cursor watch). */ continue point) fade out completely, including the synopsis (playback is
stopped via the threshold watch). */
.episode-tile--ahead .tile-media { .episode-tile--ahead .tile-media {
opacity: 0; opacity: 0;
} }
@@ -1776,6 +1953,18 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
pointer-events: none; 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 */ /* Play indicator */
.tile-play { .tile-play {
position: absolute; position: absolute;
@@ -1,5 +1,8 @@
import { reportUserActivity } from "../api"
type InputModality = "mouse" | "keyboard" | "gamepad" type InputModality = "mouse" | "keyboard" | "gamepad"
const MOUSE_IDLE_MS = 1400 const MOUSE_IDLE_MS = 1400
const MOUSE_INTENT_DISTANCE_PX = 28 const MOUSE_INTENT_DISTANCE_PX = 28
const MOUSE_INTENT_WINDOW_MS = 700 const MOUSE_INTENT_WINDOW_MS = 700
@@ -90,6 +93,7 @@ function registerMouseIntentTravel(event: MouseEvent): boolean {
} }
function handleMouseMove(event: MouseEvent) { function handleMouseMove(event: MouseEvent) {
reportUserActivity()
showPointerFromMotion() showPointerFromMotion()
if (modality === "mouse") { if (modality === "mouse") {
@@ -112,6 +116,7 @@ function handleMouseOver(event: MouseEvent) {
} }
function handleMouseIntentAction(event: MouseEvent | WheelEvent) { function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
reportUserActivity()
pointerVisible = true pointerVisible = true
if (isMouseIntentTarget(event.target)) { if (isMouseIntentTarget(event.target)) {
activateMouseInput() activateMouseInput()
@@ -124,6 +129,7 @@ function handleMouseIntentAction(event: MouseEvent | WheelEvent) {
function handleKeyboardActivity(event: KeyboardEvent) { function handleKeyboardActivity(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return if (event.metaKey || event.ctrlKey || event.altKey) return
reportUserActivity()
activateNonMouseInput("keyboard") activateNonMouseInput("keyboard")
} }
+7
View File
@@ -109,6 +109,13 @@ export interface Series {
seasons: Season[] seasons: Season[]
} }
/** A series' single continue point (last watched position). */
export interface SeriesResumePoint {
seasonNumber: number
episodeNumber: number
positionSeconds: number
}
export interface MovieUi extends Movie { export interface MovieUi extends Movie {
id: string id: string
root_id: string | null root_id: string | null
+375 -27
View File
@@ -81,16 +81,62 @@ if sys.platform == "win32":
@dataclass @dataclass
class _PlaybackEntry: class _EpisodeWatch:
"""Single resume position entry with timestamp.""" """Per-episode watch progress within a series entry."""
pos: int pos: int
ts: datetime 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: def to_dict(self) -> dict:
return { return {
"pos": self.pos, "pos": self.pos,
"ts": self.ts, "ts": self.ts,
"season": self.season,
"episode": self.episode,
"episodes": {
key: watch.to_dict() for key, watch in sorted(self.episodes.items())
},
} }
@staticmethod @staticmethod
@@ -110,7 +156,22 @@ class _PlaybackEntry:
pass pass
else: else:
return None 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 @dataclass
@@ -123,7 +184,8 @@ class _PlaybackRootSnapshot:
class PlaybackStateCache: class PlaybackStateCache:
"""Background cache for merged playback-state across all active roots. """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. 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).""" """Return merged resume entries keyed by slug (most recent wins)."""
with self._lock: with self._lock:
return { 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() for slug, e in self._merged_entries.items()
} }
@@ -166,15 +228,50 @@ class PlaybackStateCache:
root_path: Path, root_path: Path,
slug: str, slug: str,
pos: int | None, pos: int | None,
season: int | None = None,
episode: int | None = None,
*,
done_episode: tuple[int, int] | None = 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" file_path = root_path / ".mediahive" / "playback-state.json"
entries = self._read_resume_entries(file_path) entries = self._read_resume_entries(file_path)
if pos is None: if pos is None and done_episode is None:
entries.pop(slug, None) entries.pop(slug, None)
else: 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) self._write_resume_entries(file_path, entries)
@@ -203,7 +300,6 @@ class PlaybackStateCache:
previous = self._roots previous = self._roots
next_roots: dict[str, _PlaybackRootSnapshot] = {} next_roots: dict[str, _PlaybackRootSnapshot] = {}
merged: dict[str, _PlaybackEntry] = {}
for root_id, ctx in contexts.items(): for root_id, ctx in contexts.items():
file_path = ctx.root_path / ".mediahive" / "playback-state.json" file_path = ctx.root_path / ".mediahive" / "playback-state.json"
@@ -218,15 +314,9 @@ class PlaybackStateCache:
next_roots[root_id] = snapshot 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: with self._lock:
self._roots = next_roots self._roots = next_roots
self._merged_entries = merged self._merged_entries = self._build_merged_entries(next_roots)
@staticmethod @staticmethod
def _signature(path: Path) -> tuple[bool, int, int]: def _signature(path: Path) -> tuple[bool, int, int]:
@@ -280,12 +370,33 @@ class PlaybackStateCache:
def _build_merged_entries( def _build_merged_entries(
roots: dict[str, _PlaybackRootSnapshot], roots: dict[str, _PlaybackRootSnapshot],
) -> dict[str, _PlaybackEntry]: ) -> 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] = {} merged: dict[str, _PlaybackEntry] = {}
for snapshot in roots.values(): for snapshot in roots.values():
for slug, entry in snapshot.entries.items(): for slug, entry in snapshot.entries.items():
existing = merged.get(slug) existing = merged.get(slug)
if existing is None or entry.ts > existing.ts: if existing is None:
merged[slug] = entry 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 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}" 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) target = _normalize_media_path_value(file_path)
for movie_id, movie in ctx.store.movies.items(): for movie_id, movie in ctx.store.movies.items():
for file_key, torrent in movie.files.items(): for file_key, torrent in movie.files.items():
normalized_key = _normalize_media_path_value(file_key) normalized_key = _normalize_media_path_value(file_key)
if normalized_key == target: if normalized_key == target:
return movie_id return movie_id, None, None
playable_path = _expand_torrent_playable_path( playable_path = _expand_torrent_playable_path(
file_key, torrent.playable_file file_key, torrent.playable_file
) )
if _normalize_media_path_value(playable_path) == target: 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 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): def _load_root_metadata(root_path: Path, meta_key: str):
"""Load allowed per-root metadata values from .mediahive.""" """Load allowed per-root metadata values from .mediahive."""
key = meta_key.strip().lower().strip("/") key = meta_key.strip().lower().strip("/")
@@ -745,6 +1030,7 @@ async def lifespan(_app: FastAPI):
try: try:
yield yield
finally: finally:
_finalize_assumed_playback()
playback_state_cache.stop() playback_state_cache.stop()
await event_loop_lag_monitor.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 launch_ms = (time.perf_counter() - launch_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 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() loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id: 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)} 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") @app.get("/api/meta/playback-state")
async def merged_playback_state(): async def merged_playback_state():
"""Return merged playback-state resume positions from in-memory cache. """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") @app.post("/api/meta/playback-state")
async def write_playback_state(request: Request): 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) req = msgspec.json.decode(await request.body(), type=PlaybackStateUpdateRequest)
ctx = _get_context(req.root_id) ctx = _get_context(req.root_id)
slug = _resolve_movie_slug_for_file_path(ctx, req.file_path) ref = _resolve_media_ref_for_file_path(ctx, req.file_path)
if slug is None: if ref is None:
raise HTTPException( raise HTTPException(
status_code=404, 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) slug, season_number, episode_number = ref
playback_state_cache.update_resume_position(req.root_id, ctx.root_path, slug, pos)
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} 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_SEEK_BEGIN_COMMAND = 1085
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000 MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
MPC_BE_RESUME_CLEAR_MARGIN_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 MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
VOLUME_MIN = 0.0 VOLUME_MIN = 0.0
VOLUME_MAX = 1.5 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}" 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( req = urllib.request.Request(
url=f"{backend_url}/api/meta/playback-state", url=f"{backend_url}/api/meta/playback-state",
method="GET", 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: with urllib.request.urlopen(req, timeout=2) as resp:
raw = json.loads(resp.read().decode("utf-8")) raw = json.loads(resp.read().decode("utf-8"))
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError: except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
return {} return {}, {}
data = raw.get("data") if isinstance(raw, dict) else None data = raw.get("data") if isinstance(raw, dict) else None
positions = data.get("resume_positions") if isinstance(data, dict) else None positions = data.get("resume_positions") if isinstance(data, dict) else None
if not isinstance(positions, dict): 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(): for slug, value in positions.items():
if not isinstance(slug, str) or not isinstance(value, dict): if not isinstance(slug, str) or not isinstance(value, dict):
continue continue
pos = value.get("pos") pos = value.get("pos")
season = value.get("season")
episode = value.get("episode")
if isinstance(pos, int) and pos > 0: if isinstance(pos, int) and pos > 0:
cleaned[slug] = pos * 1000 cleaned[slug] = (
return cleaned 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( def _post_resume_position(
@@ -191,17 +231,48 @@ def _post_resume_position(
return False 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: try:
raw = json.loads(index_path.read_text(encoding="utf-8")) raw = json.loads(index_path.read_text(encoding="utf-8"))
except OSError, TypeError, json.JSONDecodeError: except OSError, TypeError, json.JSONDecodeError:
return {} return {}
movies = raw.get("movies") if isinstance(raw, dict) else None if not isinstance(raw, dict):
if not isinstance(movies, dict):
return {} return {}
mapping: dict[str, str] = {} mapping: dict[str, str] = {}
movies = raw.get("movies")
if isinstance(movies, dict):
for movie_id, movie in movies.items(): for movie_id, movie in movies.items():
if not isinstance(movie_id, str) or not isinstance(movie, dict): if not isinstance(movie_id, str) or not isinstance(movie, dict):
continue continue
@@ -211,31 +282,54 @@ def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
for file_key, torrent in files.items(): for file_key, torrent in files.items():
if not isinstance(file_key, str): if not isinstance(file_key, str):
continue continue
normalized_key = _normalize_media_path(file_key) _media_file_key(mapping, file_key, torrent, movie_id)
mapping[normalized_key] = movie_id
playable_file = ( series = raw.get("series")
torrent.get("playable_file") if isinstance(torrent, dict) else None if isinstance(series, dict):
) for series_id, show in series.items():
expanded = _expand_playable_file( if not isinstance(series_id, str) or not isinstance(show, dict):
file_key, playable_file if isinstance(playable_file, str) else None continue
) seasons = show.get("seasons")
mapping[_normalize_media_path(expanded)] = movie_id 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 return mapping
def _media_key_for_filepath( def _media_key_for_filepath(
filepath: str, roots: dict[str, Path] filepath: str, roots: dict[str, Path]
) -> tuple[str | None, str, str] | None: ) -> 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(): for root_id, root in roots.items():
try: try:
relative = Path(filepath).resolve().relative_to(root.resolve()) relative = Path(filepath).resolve().relative_to(root.resolve())
relative_key = relative.as_posix() relative_key = relative.as_posix()
index_path = root / ".mediahive" / "index.json" 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) _normalize_media_path(relative_key)
) )
return movie_slug, root_id, relative_key return media_key, root_id, relative_key
except OSError, RuntimeError, ValueError: except OSError, RuntimeError, ValueError:
continue continue
return None return None
@@ -348,7 +442,7 @@ def _start_gamepad_remote(
status_miss_count = 0 status_miss_count = 0
playback_state = _default_playback_state() 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_media_key: str | None = None
tracked_root_id: str | None = None tracked_root_id: str | None = None
tracked_relative_path = "" tracked_relative_path = ""
@@ -400,18 +494,34 @@ def _start_gamepad_remote(
position_ms = player_position_ms or 0 position_ms = player_position_ms or 0
duration_ms = player_duration_ms or 0 duration_ms = player_duration_ms or 0
tracked_slug, tracked_season, tracked_episode = _split_media_key(
tracked_media_key
)
if _should_clear_resume(position_ms, duration_ms): if _should_clear_resume(position_ms, duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_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: if tracked_root_id and tracked_relative_path:
_post_resume_position( _post_resume_position(
backend_url, tracked_root_id, tracked_relative_path, None backend_url, tracked_root_id, tracked_relative_path, None
) )
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS: elif position_ms < MPC_BE_RESUME_MIN_WATCH_MS:
# Ignore brief starts; keep the previous saved resume position. # Peeks and brief seeks are not true progress; keep the previous
# saved resume position.
pass pass
else: else:
position_seconds = max(0, position_ms // 1000) 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: if tracked_root_id and tracked_relative_path:
_post_resume_position( _post_resume_position(
backend_url, backend_url,
@@ -454,8 +564,36 @@ def _start_gamepad_remote(
if resume_applied_for_key == tracked_media_key: if resume_applied_for_key == tracked_media_key:
return return
saved_position = resume_positions.get(tracked_media_key) tracked_slug, tracked_season, tracked_episode = _split_media_key(
if not isinstance(saved_position, int): 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 resume_applied_for_key = tracked_media_key
return return
if player_position_ms is None or player_duration_ms is None: 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 resume_applied_for_key = tracked_media_key
return return
if _should_clear_resume(saved_position, player_duration_ms): if _should_clear_resume(saved_position, player_duration_ms):
resume_positions.pop(tracked_media_key, None) resume_positions.pop(tracked_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 resume_applied_for_key = tracked_media_key
return return
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS: if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS: