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:
+5
-2
@@ -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
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
|
||||
+179
-37
@@ -54,6 +54,9 @@ MPC_BE_STATE_RUNNING = 2
|
||||
MPC_BE_SEEK_BEGIN_COMMAND = 1085
|
||||
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
|
||||
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
|
||||
# Watching (or presumably watching) less than this leaves no position data:
|
||||
# brief peeks and seeks back to re-view a scene are not true progress.
|
||||
MPC_BE_RESUME_MIN_WATCH_MS = 5 * 60 * 1000
|
||||
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
|
||||
VOLUME_MIN = 0.0
|
||||
VOLUME_MAX = 1.5
|
||||
@@ -140,7 +143,20 @@ def _expand_playable_file(file_key: str, playable_file: str | None) -> str:
|
||||
return f"{file_key}/{playable_file}"
|
||||
|
||||
|
||||
def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
|
||||
def _fetch_resume_positions(
|
||||
backend_url: str,
|
||||
) -> tuple[
|
||||
dict[str, tuple[int, int | None, int | None]],
|
||||
dict[tuple[str, int, int], int],
|
||||
]:
|
||||
"""Fetch resume state from the backend.
|
||||
|
||||
Returns (continue_points, episode_positions): continue_points map a slug
|
||||
to (pos_ms, season_number, episode_number) — season/episode set for the
|
||||
series' single continue point, None for movies. episode_positions map
|
||||
(slug, season, episode) to pos_ms for partially watched episodes;
|
||||
fully watched episodes are absent.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
url=f"{backend_url}/api/meta/playback-state",
|
||||
method="GET",
|
||||
@@ -149,21 +165,45 @@ def _fetch_resume_positions(backend_url: str) -> dict[str, int]:
|
||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
except OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError:
|
||||
return {}
|
||||
return {}, {}
|
||||
|
||||
data = raw.get("data") if isinstance(raw, dict) else None
|
||||
positions = data.get("resume_positions") if isinstance(data, dict) else None
|
||||
if not isinstance(positions, dict):
|
||||
return {}
|
||||
return {}, {}
|
||||
|
||||
cleaned: dict[str, int] = {}
|
||||
cleaned: dict[str, tuple[int, int | None, int | None]] = {}
|
||||
episode_positions: dict[tuple[str, int, int], int] = {}
|
||||
for slug, value in positions.items():
|
||||
if not isinstance(slug, str) or not isinstance(value, dict):
|
||||
continue
|
||||
pos = value.get("pos")
|
||||
season = value.get("season")
|
||||
episode = value.get("episode")
|
||||
if isinstance(pos, int) and pos > 0:
|
||||
cleaned[slug] = pos * 1000
|
||||
return cleaned
|
||||
cleaned[slug] = (
|
||||
pos * 1000,
|
||||
season if isinstance(season, int) else None,
|
||||
episode if isinstance(episode, int) else None,
|
||||
)
|
||||
elif pos == 0 and isinstance(season, int) and isinstance(episode, int):
|
||||
# Series episode boundary marker (previous episode completed).
|
||||
cleaned[slug] = (0, season, episode)
|
||||
|
||||
episodes = value.get("episodes")
|
||||
if not isinstance(episodes, dict):
|
||||
continue
|
||||
for key, watch in episodes.items():
|
||||
match = re.fullmatch(r"S(\d+)E(\d+)", str(key))
|
||||
if not match or not isinstance(watch, dict):
|
||||
continue
|
||||
ep_pos = watch.get("pos")
|
||||
if watch.get("done") or not isinstance(ep_pos, int) or ep_pos <= 0:
|
||||
continue
|
||||
episode_positions[slug, int(match.group(1)), int(match.group(2))] = (
|
||||
ep_pos * 1000
|
||||
)
|
||||
return cleaned, episode_positions
|
||||
|
||||
|
||||
def _post_resume_position(
|
||||
@@ -191,51 +231,105 @@ def _post_resume_position(
|
||||
return False
|
||||
|
||||
|
||||
def _load_movie_slug_map(index_path: Path) -> dict[str, str]:
|
||||
def _media_file_key(
|
||||
mapping: dict[str, str], file_key: str, torrent: object, media_key: str
|
||||
) -> None:
|
||||
"""Map both the raw file key and its expanded playable path to a media key."""
|
||||
mapping[_normalize_media_path(file_key)] = media_key
|
||||
playable_file = torrent.get("playable_file") if isinstance(torrent, dict) else None
|
||||
expanded = _expand_playable_file(
|
||||
file_key, playable_file if isinstance(playable_file, str) else None
|
||||
)
|
||||
mapping[_normalize_media_path(expanded)] = media_key
|
||||
|
||||
|
||||
def _split_media_key(media_key: str) -> tuple[str, int | None, int | None]:
|
||||
"""Split a media key into (slug, season_number, episode_number)."""
|
||||
slug, separator, ep_ref = media_key.partition("#")
|
||||
if not separator:
|
||||
return slug, None, None
|
||||
match = re.fullmatch(r"S(\d+)E(\d+)", ep_ref)
|
||||
if not match:
|
||||
return slug, None, None
|
||||
return slug, int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def _load_media_key_map(index_path: Path) -> dict[str, str]:
|
||||
"""Map normalized playable file paths to media keys.
|
||||
|
||||
Movies map to their movie id; series episodes map to
|
||||
"<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] = {}
|
||||
for movie_id, movie in movies.items():
|
||||
if not isinstance(movie_id, str) or not isinstance(movie, dict):
|
||||
continue
|
||||
files = movie.get("files")
|
||||
if not isinstance(files, dict):
|
||||
continue
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
|
||||
movies = raw.get("movies")
|
||||
if isinstance(movies, dict):
|
||||
for movie_id, movie in movies.items():
|
||||
if not isinstance(movie_id, str) or not isinstance(movie, dict):
|
||||
continue
|
||||
normalized_key = _normalize_media_path(file_key)
|
||||
mapping[normalized_key] = movie_id
|
||||
playable_file = (
|
||||
torrent.get("playable_file") if isinstance(torrent, dict) else None
|
||||
)
|
||||
expanded = _expand_playable_file(
|
||||
file_key, playable_file if isinstance(playable_file, str) else None
|
||||
)
|
||||
mapping[_normalize_media_path(expanded)] = movie_id
|
||||
files = movie.get("files")
|
||||
if not isinstance(files, dict):
|
||||
continue
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
continue
|
||||
_media_file_key(mapping, file_key, torrent, movie_id)
|
||||
|
||||
series = raw.get("series")
|
||||
if isinstance(series, dict):
|
||||
for series_id, show in series.items():
|
||||
if not isinstance(series_id, str) or not isinstance(show, dict):
|
||||
continue
|
||||
seasons = show.get("seasons")
|
||||
if not isinstance(seasons, list):
|
||||
continue
|
||||
for season in seasons:
|
||||
if not isinstance(season, dict):
|
||||
continue
|
||||
season_number = season.get("season_number")
|
||||
episodes = season.get("episodes")
|
||||
if not isinstance(season_number, int) or not isinstance(episodes, list):
|
||||
continue
|
||||
for episode in episodes:
|
||||
if not isinstance(episode, dict):
|
||||
continue
|
||||
episode_number = episode.get("episode_number")
|
||||
files = episode.get("files")
|
||||
if not isinstance(episode_number, int) or not isinstance(
|
||||
files, dict
|
||||
):
|
||||
continue
|
||||
media_key = f"{series_id}#S{season_number}E{episode_number}"
|
||||
for file_key, torrent in files.items():
|
||||
if not isinstance(file_key, str):
|
||||
continue
|
||||
_media_file_key(mapping, file_key, torrent, media_key)
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def _media_key_for_filepath(
|
||||
filepath: str, roots: dict[str, Path]
|
||||
) -> tuple[str | None, str, str] | None:
|
||||
"""Resolve a filepath to a (movie_slug, root_id, relative_key) tuple."""
|
||||
"""Resolve a filepath to a (media_key, root_id, relative_key) tuple."""
|
||||
for root_id, root in roots.items():
|
||||
try:
|
||||
relative = Path(filepath).resolve().relative_to(root.resolve())
|
||||
relative_key = relative.as_posix()
|
||||
index_path = root / ".mediahive" / "index.json"
|
||||
movie_slug = _load_movie_slug_map(index_path).get(
|
||||
media_key = _load_media_key_map(index_path).get(
|
||||
_normalize_media_path(relative_key)
|
||||
)
|
||||
return movie_slug, root_id, relative_key
|
||||
return media_key, root_id, relative_key
|
||||
except OSError, RuntimeError, ValueError:
|
||||
continue
|
||||
return None
|
||||
@@ -348,7 +442,7 @@ def _start_gamepad_remote(
|
||||
status_miss_count = 0
|
||||
|
||||
playback_state = _default_playback_state()
|
||||
resume_positions = _fetch_resume_positions(backend_url)
|
||||
resume_positions, episode_positions = _fetch_resume_positions(backend_url)
|
||||
tracked_media_key: str | None = None
|
||||
tracked_root_id: str | None = None
|
||||
tracked_relative_path = ""
|
||||
@@ -400,18 +494,34 @@ def _start_gamepad_remote(
|
||||
|
||||
position_ms = player_position_ms or 0
|
||||
duration_ms = player_duration_ms or 0
|
||||
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||
tracked_media_key
|
||||
)
|
||||
if _should_clear_resume(position_ms, duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_positions.pop(tracked_slug, None)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions.pop(
|
||||
(tracked_slug, tracked_season, tracked_episode), None
|
||||
)
|
||||
if tracked_root_id and tracked_relative_path:
|
||||
_post_resume_position(
|
||||
backend_url, tracked_root_id, tracked_relative_path, None
|
||||
)
|
||||
elif position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
|
||||
# Ignore brief starts; keep the previous saved resume position.
|
||||
elif position_ms < MPC_BE_RESUME_MIN_WATCH_MS:
|
||||
# Peeks and brief seeks are not true progress; keep the previous
|
||||
# saved resume position.
|
||||
pass
|
||||
else:
|
||||
position_seconds = max(0, position_ms // 1000)
|
||||
resume_positions[tracked_media_key] = position_ms
|
||||
resume_positions[tracked_slug] = (
|
||||
position_ms,
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions[tracked_slug, tracked_season, tracked_episode] = (
|
||||
position_ms
|
||||
)
|
||||
if tracked_root_id and tracked_relative_path:
|
||||
_post_resume_position(
|
||||
backend_url,
|
||||
@@ -454,8 +564,36 @@ def _start_gamepad_remote(
|
||||
if resume_applied_for_key == tracked_media_key:
|
||||
return
|
||||
|
||||
saved_position = resume_positions.get(tracked_media_key)
|
||||
if not isinstance(saved_position, int):
|
||||
tracked_slug, tracked_season, tracked_episode = _split_media_key(
|
||||
tracked_media_key
|
||||
)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
# Series: the episode's own saved position wins; fall back to the
|
||||
# series continue point when it points at this very episode.
|
||||
saved_position = episode_positions.get((
|
||||
tracked_slug,
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
))
|
||||
if saved_position is None:
|
||||
saved = resume_positions.get(tracked_slug)
|
||||
if saved is None or (saved[1], saved[2]) != (
|
||||
tracked_season,
|
||||
tracked_episode,
|
||||
):
|
||||
# The series continue point belongs to a different episode.
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
saved_position = saved[0]
|
||||
else:
|
||||
saved = resume_positions.get(tracked_slug)
|
||||
if saved is None:
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
saved_position = saved[0]
|
||||
|
||||
if saved_position <= 0:
|
||||
# Episode boundary marker (previous episode completed): start at 0.
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if player_position_ms is None or player_duration_ms is None:
|
||||
@@ -464,7 +602,11 @@ def _start_gamepad_remote(
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if _should_clear_resume(saved_position, player_duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_positions.pop(tracked_slug, None)
|
||||
if tracked_season is not None and tracked_episode is not None:
|
||||
episode_positions.pop(
|
||||
(tracked_slug, tracked_season, tracked_episode), None
|
||||
)
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
|
||||
Reference in New Issue
Block a user