feat(player): skipping an episode marks it watched, not paused

Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
This commit is contained in:
2026-07-25 09:20:56 +02:00
parent c3ead64748
commit eb76c96e94
8 changed files with 383 additions and 5 deletions
@@ -0,0 +1,124 @@
/**
* Continue Watching stale-entry suppression tests.
*
* A partially-watched episode should drop off Continue Watching once the user
* has moved past it — i.e. when Next Up for that series points at a *later*
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
function episode(
id: string,
seriesId: string,
season: number | undefined,
index: number | undefined
): MediaItem {
return {
id,
name: `Episode ${index}`,
kind: "episode",
seriesId,
parentIndexNumber: season,
indexNumber: index,
} as MediaItem;
}
function movie(id: string): MediaItem {
return { id, name: "A Movie", kind: "movie" } as MediaItem;
}
describe("filterSupersededResumeItems", () => {
it("drops a partially-watched episode when next up is later in the same season", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result).toEqual([]);
});
it("drops it when next up is in a later season", () => {
const resume = [episode("s1e9", "series-a", 1, 9)];
const nextUp = [episode("s2e1", "series-a", 2, 1)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("keeps the episode the user is actually mid-way through", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps an episode ahead of next up (user jumped forward)", () => {
const resume = [episode("s1e7", "series-a", 1, 7)];
const nextUp = [episode("s1e3", "series-a", 1, 3)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("only compares within the same series", () => {
const resume = [episode("a-s1e2", "series-a", 1, 2)];
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("never suppresses movies", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps items when ordering is unknown on either side", () => {
const resume = [episode("s1e2", "series-a", undefined, undefined)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("treats a missing season number as season 1 only when both sides agree", () => {
// Flat series (no season folders): episode numbers alone must still order.
const resume = [episode("e2", "series-a", undefined, 2)];
const nextUp = [episode("e6", "series-a", undefined, 6)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("is a no-op when next up is empty", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
});
it("preserves the original order of surviving items", () => {
const resume = [
episode("a-s1e2", "series-a", 1, 2),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s1e3", "series-c", 1, 3),
];
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
});
it("uses the furthest-ahead next-up entry for a series", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [
episode("s1e1", "series-a", 1, 1),
episode("s1e8", "series-a", 1, 8),
];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// Continue Watching stale-entry suppression.
//
// Continue Watching is built from raw resume positions, so an episode the user
// has moved past keeps showing up as a resume suggestion — most visibly after
// skipping an episode, which leaves a partial position behind. Next Up already
// tells us where the user actually is in each series, so an in-progress episode
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
//
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
import type { MediaItem } from "$lib/api/types";
/**
* Position of an episode within its series, as (season, episode).
*
* Returns null when the episode number is unknown — without it there is no
* defensible ordering and we must not suppress anything. A missing *season*
* number is normal for flat series (no season folders), so it is only usable
* when both sides are equally season-less; callers compare via `isAheadOf`.
*/
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
if (item.indexNumber == null) return null;
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
}
/** Is `a` strictly later in series order than `b`? */
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
const oa = episodeOrder(a);
const ob = episodeOrder(b);
if (!oa || !ob) return false;
// Mixed season-numbering (one side foldered, the other flat) is not safely
// comparable — leave the entry alone rather than hide something wrongly.
if ((oa.season == null) !== (ob.season == null)) return false;
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
return oa.season > ob.season;
}
return oa.index > ob.index;
}
/**
* Drop resume entries the user has already moved past.
*
* An episode is suppressed when its series has a Next Up entry strictly later
* in series order. Movies, items without a series, and anything whose ordering
* is unknown are always kept — suppression must never hide something the user
* genuinely still wants to resume.
*/
export function filterSupersededResumeItems(
resumeItems: MediaItem[],
nextUpItems: MediaItem[]
): MediaItem[] {
if (nextUpItems.length === 0) return resumeItems;
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
// entry for a series, and the latest is the true watch frontier.
const frontier = new Map<string, MediaItem>();
for (const item of nextUpItems) {
if (!item.seriesId) continue;
const current = frontier.get(item.seriesId);
if (!current || isAheadOf(item, current)) {
frontier.set(item.seriesId, item);
}
}
return resumeItems.filter(item => {
if (item.kind !== "episode" || !item.seriesId) return true;
const ahead = frontier.get(item.seriesId);
if (!ahead) return true;
return !isAheadOf(ahead, item);
});
}
+7 -2
View File
@@ -1,8 +1,9 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -50,8 +51,12 @@ function createHomeStore() {
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+8 -2
View File
@@ -1,10 +1,11 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -81,7 +82,12 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.