mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
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:
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user