feat(anime): filter episodes and mark them watched by hand

- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
This commit is contained in:
2026-08-03 01:00:54 -07:00
parent 3dd0750b98
commit 8d55d64ee0
23 changed files with 1601 additions and 111 deletions
+42 -1
View File
@@ -1,6 +1,10 @@
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { AnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBrowserPlayRequest } from '../../types/anime-browser';
import type {
AnimeBrowserPlayRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserWatchStateRequest,
} from '../../types/anime-browser';
export interface AnimeBrowserIpcDeps {
// Structurally typed so tests can pass a fake without importing Electron.
@@ -39,6 +43,12 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
runtime.getEpisodes(String(animeUrl), toOptionalId(sourceId)),
);
handle(channels.animeBrowserGetWatchState, (_event, request) =>
runtime.getWatchState(toWatchStateRequest(request)),
);
handle(channels.animeBrowserSetWatched, (_event, request) =>
runtime.setWatched(toSetWatchedRequest(request)),
);
handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(pkg)),
@@ -60,6 +70,37 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
);
}
/** Coerce a watch-state request; the renderer's arrays arrive untyped. */
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
episodeUrls: Array.isArray(request.episodeUrls)
? request.episodeUrls.filter((url): url is string => typeof url === 'string')
: [],
};
}
/** Coerce a manual watch-mark request, including its per-episode entries. */
function toSetWatchedRequest(value: unknown): AnimeBrowserSetWatchedRequest {
const request = (value ?? {}) as Partial<AnimeBrowserSetWatchedRequest>;
const episodes = Array.isArray(request.episodes) ? request.episodes : [];
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
animeTitle: String(request.animeTitle ?? ''),
episodes: episodes.map((episode) => ({
episodeUrl: String(episode?.episodeUrl ?? ''),
episodeName: String(episode?.episodeName ?? ''),
// NaN and Infinity are numbers as far as typeof is concerned, and either
// one would reach the stats row as a nonsense episode number.
episodeNumber: Number.isFinite(episode?.episodeNumber) ? episode.episodeNumber! : null,
})),
watched: request.watched === true,
};
}
/**
* A source id the renderer may omit. Absent means "use the current selection",
* so an empty value must stay undefined rather than becoming the string "".
@@ -1,4 +1,11 @@
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
import type {
StreamPlaybackMetadataInput,
StreamWatchState,
} from '../../core/services/immersion-tracker-service';
/** What the stats store needs to record a mark against one episode. */
export type StreamWatchMark = StreamPlaybackMetadataInput;
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
@@ -29,6 +36,18 @@ export interface AnimeBrowserRuntimeDeps {
showVisibleOverlay?: () => void;
/** Publishes stream identity before loadfile starts the stats session. */
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void;
/**
* Watch state for the given stats paths, from the same store playback writes
* to. Absent (or resolving empty) when stats tracking is disabled, which the
* browser shows as "no watch history" rather than as an error.
*/
getWatchState?: (statsPaths: string[]) => Promise<Map<string, StreamWatchState>>;
/**
* Sets or clears the watch mark by hand. Marking creates the stats row for an
* episode nobody has played yet, which is what makes catching up on a series
* watched elsewhere possible.
*/
setWatchState?: (episodes: StreamWatchMark[], watched: boolean) => Promise<number>;
/** Lets tests drive the pause between loadfile and track attachment. */
wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */
+97
View File
@@ -23,6 +23,10 @@ import {
installExtension,
removeExtension as removeExtensionFile,
} from '../../anime-bridge/extension-installer';
import {
buildAnimeStreamMetadata,
buildAnimeStreamStatsPath,
} from '../../anime-bridge/episode-metadata';
import { PreferenceStore } from '../../anime-bridge/preference-store';
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
@@ -32,6 +36,9 @@ import type {
AnimeBrowserDetails,
AnimeBrowserEntry,
AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserSetWatchedRequest,
AnimeBrowserWatchStateRequest,
AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot,
@@ -290,6 +297,52 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
};
}
/**
* Which of these episodes have already been watched.
*
* The stats database is the only store: playback records every streamed
* episode under the same derived path, and marks it watched once a session
* runs far enough. With tracking disabled there is no history to read, so
* every episode comes back unwatched rather than the call failing.
*
* A closure rather than a method, so `setWatched` can reuse it without
* depending on how the runtime object was called.
*/
async function getWatchState(
request: AnimeBrowserWatchStateRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> {
const episodeUrls = request.episodeUrls.filter((url) => url.length > 0);
if (episodeUrls.length === 0 || !deps.getWatchState) return [];
const statsPaths = new Map(
episodeUrls.map((episodeUrl) => [
episodeUrl,
buildAnimeStreamStatsPath(request.sourceId, request.animeUrl, episodeUrl),
]),
);
try {
const state = await deps.getWatchState([...statsPaths.values()]);
const watchState: AnimeBrowserEpisodeWatchState[] = [];
for (const [episodeUrl, statsPath] of statsPaths) {
const entry = state.get(statsPath);
if (!entry) continue;
watchState.push({
episodeUrl,
watched: entry.watched,
lastWatchedMs: entry.lastWatchedMs,
sessionCount: entry.sessionCount,
});
}
return watchState;
} catch (error) {
// Watch marks are decoration on a list that is already usable; a stats
// read that fails must not take the episode list down with it.
deps.log(`[anime-browser] watch state lookup failed: ${describeError(error)}`);
return [];
}
}
const playback = createAnimeBrowserPlayback({
deps,
bridge,
@@ -475,6 +528,50 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}));
},
getWatchState,
/**
* Set or clear the watch mark on the given episodes, then report the state
* that write left behind so the browser paints from the store rather than
* from what it hoped happened.
*/
async setWatched(
request: AnimeBrowserSetWatchedRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> {
const episodes = request.episodes.filter((episode) => episode.episodeUrl.length > 0);
if (episodes.length === 0 || !deps.setWatchState) return [];
// The same metadata playback records, so an episode marked before it is
// ever played still lands under the right series, season and episode.
const marks = episodes.map((episode) => {
const metadata = buildAnimeStreamMetadata({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
animeTitle: request.animeTitle,
episodeUrl: episode.episodeUrl,
episodeName: episode.episodeName,
episodeNumber: episode.episodeNumber,
// No stream was resolved: there is no media path to alias.
mediaPath: '',
});
return {
mediaPath: '',
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
};
});
await deps.setWatchState(marks, request.watched);
return getWatchState({
sourceId: request.sourceId,
animeUrl: request.animeUrl,
episodeUrls: episodes.map((episode) => episode.episodeUrl),
});
},
playEpisode: playback.playEpisode,
async dispose(): Promise<void> {
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { buildAnimeStreamStatsPath } from '../../anime-bridge/episode-metadata';
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
async function setupRuntime(overrides: Partial<AnimeBrowserRuntimeDeps> = {}) {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-watch-'));
await writeFile(path.join(dir, 'pkg.one.apk'), 'one');
const client = {
listAnimeSources: async () => [{ id: 'shared', name: 'One', lang: 'en' }],
};
const runtime = createAnimeBrowserRuntime({
extensionsDir: () => dir,
repos: () => [],
setRepos: () => undefined,
preferencesFile: path.join(dir, 'preferences.json'),
ensureBinaries: async () => ({}) as never,
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
log: () => undefined,
startSidecar: async () => ({
client: client as unknown as AnimeBridgeClient,
baseUrl: 'http://127.0.0.1:12345',
port: 12345,
stop: async () => undefined,
onExit: () => undefined,
}),
startStreamStripProxy: async () => ({
origin: 'http://127.0.0.1:12346',
port: 12346,
close: async () => undefined,
}),
...overrides,
});
await runtime.ensureBridge();
return runtime;
}
test('getWatchState asks the stats store for the derived per-episode paths', async () => {
let asked: string[] = [];
const watched = buildAnimeStreamStatsPath('pkg.one:shared', '/anime/1', '/ep/1');
const runtime = await setupRuntime({
getWatchState: async (statsPaths) => {
asked = statsPaths;
return new Map([[watched, { watched: true, lastWatchedMs: 42, sessionCount: 2 }]]);
},
});
const state = await runtime.getWatchState({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
// The empty url is what a source with a malformed entry hands over.
episodeUrls: ['/ep/1', '/ep/2', ''],
});
assert.deepEqual(asked, [
watched,
buildAnimeStreamStatsPath('pkg.one:shared', '/anime/1', '/ep/2'),
]);
// Episodes with no history are absent rather than reported unwatched.
assert.deepEqual(state, [
{ episodeUrl: '/ep/1', watched: true, lastWatchedMs: 42, sessionCount: 2 },
]);
await runtime.dispose();
});
test('setWatched records the series metadata a never-played episode needs', async () => {
const store = new Map<string, { watched: boolean; lastWatchedMs: number | null }>();
let marked: Array<Record<string, unknown>> = [];
const runtime = await setupRuntime({
getWatchState: async (statsPaths) =>
new Map(
statsPaths
.filter((statsPath) => store.has(statsPath))
.map((statsPath) => [statsPath, { ...store.get(statsPath)!, sessionCount: 0 }]),
),
setWatchState: async (episodes, watched) => {
marked = episodes as unknown as Array<Record<string, unknown>>;
for (const episode of episodes) {
store.set(episode.statsPath, { watched, lastWatchedMs: null });
}
return episodes.length;
},
});
const state = await runtime.setWatched({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
animeTitle: 'Test Series Season 3',
watched: true,
episodes: [
{ episodeUrl: '/ep/4', episodeName: 'Episode 4 - Homecoming', episodeNumber: 4 },
{ episodeUrl: '/ep/3', episodeName: 'Episode 3', episodeNumber: null },
{ episodeUrl: '', episodeName: 'Broken', episodeNumber: null },
],
});
assert.equal(marked.length, 2, 'the entry with no url is dropped before the write');
assert.deepEqual(marked[0], {
mediaPath: '',
statsPath: buildAnimeStreamStatsPath('pkg.one:shared', '/anime/1', '/ep/4'),
displayTitle: 'Test Series S03E04 - Homecoming',
seriesTitle: 'Test Series',
seasonNumber: 3,
episodeNumber: 4,
});
// The number is read off the label when the source reported none.
assert.equal(marked[1]?.episodeNumber, 3);
assert.deepEqual(
state.map((entry) => entry.episodeUrl),
['/ep/4', '/ep/3'],
);
assert.ok(state.every((entry) => entry.watched));
await runtime.dispose();
});
test('setWatched clears marks and reports the state the write left behind', async () => {
const store = new Map([
[
buildAnimeStreamStatsPath('pkg.one:shared', '/anime/1', '/ep/4'),
{ watched: true, lastWatchedMs: 10, sessionCount: 1 },
],
]);
const runtime = await setupRuntime({
getWatchState: async (statsPaths) =>
new Map(
statsPaths
.filter((statsPath) => store.has(statsPath))
.map((statsPath) => [statsPath, store.get(statsPath)!]),
),
setWatchState: async (episodes, watched) => {
for (const episode of episodes) {
const current = store.get(episode.statsPath);
if (current) store.set(episode.statsPath, { ...current, watched });
}
return episodes.length;
},
});
const state = await runtime.setWatched({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
animeTitle: 'Test Series',
watched: false,
episodes: [{ episodeUrl: '/ep/4', episodeName: 'Episode 4', episodeNumber: 4 }],
});
assert.deepEqual(state, [
{ episodeUrl: '/ep/4', watched: false, lastWatchedMs: 10, sessionCount: 1 },
]);
await runtime.dispose();
});
test('setWatched reports nothing when stats tracking supplies no writer', async () => {
const runtime = await setupRuntime();
assert.deepEqual(
await runtime.setWatched({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
animeTitle: 'Test Series',
watched: true,
episodes: [{ episodeUrl: '/ep/1', episodeName: 'Episode 1', episodeNumber: 1 }],
}),
[],
);
await runtime.dispose();
});
test('getWatchState returns nothing when stats tracking supplies no lookup', async () => {
const runtime = await setupRuntime();
assert.deepEqual(
await runtime.getWatchState({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
episodeUrls: ['/ep/1'],
}),
[],
);
await runtime.dispose();
});
test('a failing stats lookup leaves the episode list usable', async () => {
const logged: string[] = [];
const runtime = await setupRuntime({
log: (message) => logged.push(message),
getWatchState: async () => {
throw new Error('database is locked');
},
});
assert.deepEqual(
await runtime.getWatchState({
sourceId: 'pkg.one:shared',
animeUrl: '/anime/1',
episodeUrls: ['/ep/1'],
}),
[],
);
assert.ok(logged.some((message) => message.includes('database is locked')));
await runtime.dispose();
});