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 c510cd8d90
commit 0889910687
23 changed files with 1601 additions and 111 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* A small right-click menu.
*
* Electron's native menus are a main-process affair and the browser window is
* plain HTML, so the menu is built in the page. It is a singleton: opening one
* closes whatever was open before, and any click, scroll, resize or Escape
* dismisses it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Shown greyed out and not selectable. */
disabled?: boolean;
/** Draws a divider above this item. */
separated?: boolean;
}
let open: HTMLDivElement | null = null;
export function closeContextMenu(): void {
open?.remove();
open = null;
}
/**
* Show `items` at the pointer. The menu is placed inside the viewport rather
* than at the raw coordinates, so a right-click near an edge is still readable.
*/
export function showContextMenu(x: number, y: number, items: ContextMenuItem[]): void {
closeContextMenu();
if (items.length === 0) return;
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.setAttribute('role', 'menu');
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'context-menu-item';
button.setAttribute('role', 'menuitem');
button.textContent = item.label;
if (item.separated) button.dataset.separated = 'true';
if (item.disabled) {
button.disabled = true;
} else {
button.addEventListener('click', () => {
closeContextMenu();
item.onSelect();
});
}
menu.append(button);
}
document.body.append(menu);
open = menu;
const { width, height } = menu.getBoundingClientRect();
const left = Math.max(4, Math.min(x, window.innerWidth - width - 4));
const top = Math.max(4, Math.min(y, window.innerHeight - height - 4));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.querySelector<HTMLButtonElement>('.context-menu-item:not(:disabled)')?.focus();
}
// Registered once: a menu that outlives the click that dismissed it is worse
// than no menu at all.
document.addEventListener('pointerdown', (event) => {
if (open && !open.contains(event.target as Node)) closeContextMenu();
});
document.addEventListener('keydown', (event) => {
if (!open || event.key !== 'Escape') return;
// The detail page also closes on Escape, from a listener on this same node,
// so stopping propagation alone would not spare it.
event.stopImmediatePropagation();
closeContextMenu();
});
window.addEventListener('blur', closeContextMenu);
window.addEventListener('resize', closeContextMenu);
document.addEventListener('scroll', closeContextMenu, true);
+14 -103
View File
@@ -1,10 +1,7 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { LatestRequest } from './browse-state';
import { describe, el } from './dom';
import type {
AnimeBrowserAPI,
AnimeBrowserEntry,
AnimeBrowserEpisode,
} from '../types/anime-browser';
import { createEpisodeList, type SelectedAnime } from './episode-list';
import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser';
interface DetailPanelOptions {
api: AnimeBrowserAPI;
@@ -19,100 +16,15 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description');
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
let selectedAnime: { url: string; title: string; sourceId: string } | null = null;
let selectedAnime: SelectedAnime | null = null;
let resultsScrollTop = 0;
const requests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string {
const value = episode.number ?? fallbackIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
async function playEpisode(
button: HTMLButtonElement,
episode: AnimeBrowserEpisode,
): Promise<void> {
const anime = selectedAnime;
if (!anime) return;
// Only the newest click owns the button states and the status line; an
// earlier episode resolving late must not overwrite them.
const playback = playbacks.begin();
for (const other of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
other.removeAttribute('data-state');
}
button.dataset.state = 'loading';
setStatus(`Resolving ${episode.name}`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) {
button.removeAttribute('data-state');
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
button.dataset.state = 'playing';
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
button.removeAttribute('data-state');
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function renderEpisodes(list: AnimeBrowserEpisode[]): void {
episodesCount.textContent = list.length === 0 ? '' : `${list.length}`;
episodes.replaceChildren(
...list.map((episode, index) => {
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(episode, list.length - index);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(button, episode));
item.append(button);
return item;
}),
);
}
const episodeList = createEpisodeList({
api,
setStatus,
selectedAnime: () => selectedAnime,
});
async function open(entry: AnimeBrowserEntry): Promise<void> {
const request = requests.begin();
@@ -124,12 +36,11 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…';
detailChips.replaceChildren();
episodes.replaceChildren();
episodesCount.textContent = '';
episodeList.clear();
detailCover.src = entry.thumbnailUrl ?? '';
try {
const [details, episodeList] = await Promise.all([
const [details, episodes] = await Promise.all([
api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId),
]);
@@ -158,8 +69,8 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
}
detailChips.replaceChildren(...chips);
renderEpisodes(episodeList);
setStatus(`${details.title} · ${episodeList.length} episodes`);
episodeList.render(episodes);
setStatus(`${details.title} · ${episodes.length} episodes`);
} catch (error) {
if (!requests.isCurrent(request)) return;
detailDescription.textContent = '';
@@ -169,7 +80,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
function close(): void {
requests.cancel();
playbacks.cancel();
episodeList.clear();
detail.classList.add('hidden');
results.classList.remove('hidden');
results.scrollTop = resultsScrollTop;
+88 -1
View File
@@ -96,7 +96,7 @@
.episodes-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
@@ -117,6 +117,21 @@
color: var(--faint);
}
/* How much of the series the stats history already has a watch mark for. */
.episodes-watched {
font-family: var(--mono);
font-size: 11px;
color: var(--ok);
}
/* Pushed to the far end so the counters stay next to the heading. */
.episodes-filter {
margin-left: auto;
width: clamp(160px, 24vw, 260px);
padding: 5px 10px;
font-size: 12px;
}
/*
* The cue rail: episodes read as subtitle cues on a timeline, because that is
* what they are about to become. The rail is the spine, the index is the cue
@@ -205,6 +220,29 @@
margin-top: 2px;
}
/*
* Watched episodes stay readable rather than being hidden: the mark says "you
* have been here", it does not remove the episode from the list.
*/
.cue[data-watched='true'] .cue-name,
.cue[data-watched='true'] .cue-index {
color: var(--faint);
}
.cue[data-watched='true']::after {
background: var(--ok);
border-color: var(--ok);
}
.cue-watched {
margin-left: 10px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ok);
white-space: nowrap;
}
.cue[data-state='loading'] {
background: var(--panel-elevated);
}
@@ -220,6 +258,55 @@
border-color: var(--mine);
}
/* ---------- right-click menu ---------- */
.context-menu {
position: fixed;
z-index: 40;
min-width: 210px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 10px;
/* Opaque on purpose: the panel colours are translucent, and episode rows
showing through a menu makes both unreadable. */
background: var(--ctp-mantle);
box-shadow: 0 20px 44px -22px var(--shadow);
animation: detail-in 0.12s ease both;
}
.context-menu-item {
display: block;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
font-size: 12px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.context-menu-item[data-separated='true'] {
margin-top: 4px;
border-top: 1px solid var(--line);
padding-top: 9px;
border-radius: 0 0 6px 6px;
}
.context-menu-item:hover:not(:disabled),
.context-menu-item:focus-visible:not(:disabled) {
background: var(--ctp-surface0);
outline: none;
}
.context-menu-item:disabled {
color: var(--faint);
cursor: default;
}
/* ---------- status bar ---------- */
.statusbar {
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterEpisodes, parseEpisodeFilter } from './episode-filter';
const episode = (number: number | null, name: string) => ({ number, name });
const SEASON = [
episode(1, 'Episode 1 - Departure'),
episode(2, 'Episode 2 - The Long Road'),
episode(12, 'Episode 12 - Homecoming'),
episode(12.5, 'Episode 12.5 - Recap'),
episode(18, 'Episode 18 - Finale'),
episode(null, 'OVA: Beach Special'),
];
test('an empty query keeps the whole list', () => {
assert.deepEqual(filterEpisodes(SEASON, ' '), SEASON);
assert.equal(parseEpisodeFilter(''), null);
});
test('a number matches that episode, not every episode containing the digits', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12').map((item) => item.number),
[12, 12.5],
);
});
test('a number still matches names when the source reported no numbers', () => {
const unnumbered = [episode(null, 'Episode 3'), episode(null, 'Episode 4')];
assert.deepEqual(
filterEpisodes(unnumbered, '3').map((item) => item.name),
['Episode 3'],
);
});
test('a range keeps the episodes inside it, in either order', () => {
assert.deepEqual(
filterEpisodes(SEASON, '12-18').map((item) => item.number),
[12, 12.5, 18],
);
assert.deepEqual(
filterEpisodes(SEASON, '18 12').map((item) => item.number),
[12, 12.5, 18],
);
});
test('a range skips episodes the source gave no number', () => {
assert.deepEqual(filterEpisodes([episode(null, 'OVA')], '1-99'), []);
});
test('anything else is a case-insensitive substring of the name', () => {
assert.deepEqual(
filterEpisodes(SEASON, 'home').map((item) => item.number),
[12],
);
assert.deepEqual(
filterEpisodes(SEASON, 'BEACH').map((item) => item.name),
['OVA: Beach Special'],
);
assert.deepEqual(filterEpisodes(SEASON, 'nothing here'), []);
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Filtering for the episode list.
*
* A season's worth of episodes does not fit on screen, and sources hand them
* over in whatever order they please, so the list needs a way to jump straight
* to one. The query is deliberately forgiving: a number finds that episode, a
* range finds a span of them, and anything else is a substring of the name.
*/
export interface FilterableEpisode {
/** Effective episode number, after the list's fallback numbering. */
number: number | null;
name: string;
}
export type EpisodeFilter =
| { kind: 'number'; value: number; text: string }
| { kind: 'range'; from: number; to: number }
| { kind: 'text'; text: string };
const NUMBER = String.raw`\d{1,4}(?:\.\d+)?`;
const RANGE_PATTERN = new RegExp(`^(${NUMBER})\\s*[-–—~]\\s*(${NUMBER})$`);
const NUMBER_PATTERN = new RegExp(`^(${NUMBER})$`);
function normalize(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/**
* Read a query into what it asks for, or null when it asks for nothing.
*
* A bare number stays a text match as well as a numeric one: sources put the
* number in the name often enough ("Episode 12 - …") that a source reporting no
* numbers at all would otherwise filter to nothing.
*/
export function parseEpisodeFilter(query: string): EpisodeFilter | null {
const text = normalize(query);
if (!text) return null;
const range = text.match(RANGE_PATTERN);
if (range) {
const first = Number.parseFloat(range[1]!);
const second = Number.parseFloat(range[2]!);
// "18-12" is a typo, not an empty range.
return { kind: 'range', from: Math.min(first, second), to: Math.max(first, second) };
}
const number = text.match(NUMBER_PATTERN);
if (number) {
return { kind: 'number', value: Number.parseFloat(number[1]!), text: text.toLowerCase() };
}
return { kind: 'text', text: text.toLowerCase() };
}
export function matchesEpisodeFilter(episode: FilterableEpisode, filter: EpisodeFilter): boolean {
const name = episode.name.toLowerCase();
switch (filter.kind) {
case 'number':
return episode.number === filter.value || name.includes(filter.text);
case 'range':
return (
episode.number !== null && episode.number >= filter.from && episode.number <= filter.to
);
case 'text':
return name.includes(filter.text);
}
}
export function filterEpisodes<T extends FilterableEpisode>(episodes: T[], query: string): T[] {
const filter = parseEpisodeFilter(query);
if (!filter) return episodes;
return episodes.filter((episode) => matchesEpisodeFilter(episode, filter));
}
+330
View File
@@ -0,0 +1,330 @@
import { capture, LatestRequest, safeUploadDate } from './browse-state';
import { describe, el } from './dom';
import { closeContextMenu, showContextMenu, type ContextMenuItem } from './context-menu';
import { filterEpisodes } from './episode-filter';
import { describeMarkCount, episodesInScope } from './episode-marks';
import type { AnimeBrowserAPI, AnimeBrowserEpisode } from '../types/anime-browser';
export interface SelectedAnime {
url: string;
title: string;
sourceId: string;
}
interface EpisodeListOptions {
api: AnimeBrowserAPI;
setStatus: (message: string, tone?: 'info' | 'ok' | 'error') => void;
/** The anime the list belongs to, or null once the detail page is closed. */
selectedAnime: () => SelectedAnime | null;
}
/**
* An episode, the number the filter matches on, and the number the rail shows.
*
* They are separate on purpose: the rail falls back to the listing position so
* every row has an index, but a special the source gave no number to must not
* become findable under a number it does not have.
*/
interface ListedEpisode {
episode: AnimeBrowserEpisode;
number: number | null;
displayIndex: number;
name: string;
}
export function createEpisodeList({ api, setStatus, selectedAnime }: EpisodeListOptions) {
const episodes = el<HTMLOListElement>('episodes');
const episodesCount = el<HTMLSpanElement>('episodes-count');
const episodesWatched = el<HTMLSpanElement>('episodes-watched');
const filterInput = el<HTMLInputElement>('episodes-filter');
let listed: ListedEpisode[] = [];
/** Episode urls the stats history reports as already watched. */
let watched = new Set<string>();
/**
* Which episode is resolving or playing. Kept here rather than only on the
* button, so filtering mid-playback repaints the cue instead of dropping it.
*/
let cueState: { url: string; state: 'loading' | 'playing' } | null = null;
const watchStateRequests = new LatestRequest();
const playbacks = new LatestRequest();
function formatEpisodeIndex(item: ListedEpisode): string {
const value = item.number ?? item.displayIndex;
return Number.isInteger(value) ? String(value).padStart(2, '0') : value.toFixed(1);
}
/** Push `cueState` onto whichever rows are on screen right now. */
function applyCueState(): void {
for (const row of episodes.querySelectorAll<HTMLButtonElement>('.cue')) {
if (cueState && row.dataset.episodeUrl === cueState.url) row.dataset.state = cueState.state;
else row.removeAttribute('data-state');
}
}
async function playEpisode(episode: AnimeBrowserEpisode) {
const anime = selectedAnime();
if (!anime) return;
// Only the newest click owns the cue states and the status line; an earlier
// episode resolving late must not overwrite them.
const playback = playbacks.begin();
cueState = { url: episode.url, state: 'loading' };
applyCueState();
setStatus(`Resolving ${episode.name}`);
const attempt = await capture(() =>
api.playEpisode({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
episodeUrl: episode.url,
episodeName: episode.name,
episodeNumber: episode.number,
}),
);
if (!playbacks.isCurrent(playback)) return;
if (!attempt.ok) {
cueState = null;
applyCueState();
setStatus(describe(attempt.error), 'error');
return;
}
const result = attempt.value;
if (result.ok) {
cueState = { url: episode.url, state: 'playing' };
applyCueState();
setStatus(
result.quality ? `Playing ${episode.name} · ${result.quality}` : `Playing ${episode.name}`,
'ok',
);
} else {
cueState = null;
applyCueState();
setStatus(result.error ?? 'Could not play that episode.', 'error');
}
}
function createRow(item: ListedEpisode): HTMLLIElement {
const { episode } = item;
const row = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.className = 'cue';
button.dataset.episodeUrl = episode.url;
if (watched.has(episode.url)) button.dataset.watched = 'true';
if (cueState?.url === episode.url) button.dataset.state = cueState.state;
const cueIndex = document.createElement('span');
cueIndex.className = 'cue-index';
cueIndex.textContent = formatEpisodeIndex(item);
const name = document.createElement('span');
name.className = 'cue-name';
name.textContent = episode.name;
// Inline after the title rather than off at the row's far edge, where it
// reads as belonging to no episode in particular.
if (watched.has(episode.url)) {
const mark = document.createElement('span');
mark.className = 'cue-watched';
mark.textContent = '✓ watched';
name.append(mark);
}
if (episode.uploadedAt !== null) {
const uploaded = safeUploadDate(episode.uploadedAt);
if (uploaded) {
const sub = document.createElement('span');
sub.className = 'cue-sub';
sub.textContent = uploaded;
name.append(sub);
}
}
button.append(cueIndex, name);
button.addEventListener('click', () => void playEpisode(episode));
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
openRowMenu(event, item);
});
row.append(button);
return row;
}
/**
* The right-click menu on one episode: this episode, or this one and every
* episode listed below it, which for a newest-first source is everything
* older.
*/
function openRowMenu(event: MouseEvent, item: ListedEpisode): void {
const index = listed.indexOf(item);
if (index < 0) return;
const isWatched = watched.has(item.episode.url);
const below = episodesInScope(listed, index, 'below');
const items: ContextMenuItem[] = [
{
label: isWatched ? 'Mark unwatched' : 'Mark watched',
onSelect: () => void applyMark([item], !isWatched),
},
];
// The oldest episode has nothing below it, so the span entries would only
// repeat the single one above them.
if (below.length > 1) {
const count = below.length - 1;
items.push(
{
label: `Mark this and ${count} below watched`,
separated: true,
onSelect: () => void applyMark(below, true),
},
{
label: `Mark this and ${count} below unwatched`,
onSelect: () => void applyMark(below, false),
},
);
}
showContextMenu(event.clientX, event.clientY, items);
}
/**
* Write the mark, then repaint from what the store reports rather than from
* what was asked for: an episode the write could not record must not show a
* mark that is not there.
*/
async function applyMark(items: ListedEpisode[], mark: boolean): Promise<void> {
const anime = selectedAnime();
if (!anime || items.length === 0) return;
const request = watchStateRequests.begin();
const attempt = await capture(() =>
api.setWatched({
sourceId: anime.sourceId,
animeUrl: anime.url,
animeTitle: anime.title,
watched: mark,
episodes: items.map((item) => ({
episodeUrl: item.episode.url,
episodeName: item.episode.name,
episodeNumber: item.episode.number,
})),
}),
);
if (!watchStateRequests.isCurrent(request)) return;
if (!attempt.ok) {
setStatus(describe(attempt.error), 'error');
return;
}
const marked = new Set(
attempt.value.filter((state) => state.watched).map((state) => state.episodeUrl),
);
// Counted from what came back, not from what was asked for, so the status
// line cannot claim more than the store actually recorded.
let changed = 0;
for (const item of items) {
const isMarked = marked.has(item.episode.url);
if (isMarked) watched.add(item.episode.url);
else watched.delete(item.episode.url);
if (isMarked === mark) changed += 1;
}
paint();
// Nothing came back marked when marking is what was asked for: the write
// had nowhere to land, which is what a disabled stats history looks like.
if (mark && marked.size === 0) {
setStatus('Watch marks need immersion tracking enabled.', 'error');
return;
}
setStatus(describeMarkCount(changed, mark), 'ok');
}
/** Repaints the rows the current filter leaves, and the two counters. */
function paint(): void {
const query = filterInput.value;
const visible = filterEpisodes(listed, query);
episodes.replaceChildren(...visible.map(createRow));
if (listed.length === 0) {
episodesCount.textContent = '';
} else {
episodesCount.textContent =
visible.length === listed.length
? `${listed.length}`
: `${visible.length} of ${listed.length}`;
}
const watchedCount = listed.filter((item) => watched.has(item.episode.url)).length;
episodesWatched.textContent = watchedCount > 0 ? `${watchedCount} watched` : '';
episodesWatched.classList.toggle('hidden', watchedCount === 0);
filterInput.classList.toggle('hidden', listed.length === 0);
}
/**
* Ask the stats history which of these episodes are already watched.
*
* Playback marks an episode watched partway through a session, so this also
* runs when the window comes back to the front: an episode finished in mpv
* while the browser sat behind it shows its mark on the way back.
*/
async function refreshWatchState(): Promise<void> {
const anime = selectedAnime();
if (!anime || listed.length === 0) return;
const request = watchStateRequests.begin();
const attempt = await capture(() =>
api.getWatchState({
sourceId: anime.sourceId,
animeUrl: anime.url,
episodeUrls: listed.map((item) => item.episode.url),
}),
);
if (!watchStateRequests.isCurrent(request)) return;
// Watch marks are decoration; a failed lookup leaves the list as it is.
if (!attempt.ok) return;
watched = new Set(
attempt.value.filter((state) => state.watched).map((state) => state.episodeUrl),
);
paint();
}
function render(list: AnimeBrowserEpisode[]): void {
listed = list.map((episode, index) => ({
episode,
number: episode.number,
displayIndex: list.length - index,
name: episode.name,
}));
paint();
void refreshWatchState();
}
function clear(): void {
// A menu opened against the list that is going away has nothing left to act on.
closeContextMenu();
watchStateRequests.cancel();
playbacks.cancel();
listed = [];
watched = new Set();
cueState = null;
filterInput.value = '';
paint();
}
filterInput.addEventListener('input', paint);
// Escape inside the filter clears it instead of closing the detail page.
filterInput.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || filterInput.value === '') return;
event.stopPropagation();
filterInput.value = '';
paint();
});
window.addEventListener('focus', () => void refreshWatchState());
return { render, clear, refreshWatchState };
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeMarkCount, episodesInScope } from './episode-marks';
const LIST = ['e12', 'e11', 'e10', 'e9'];
test('the one scope takes only the episode itself', () => {
assert.deepEqual(episodesInScope(LIST, 1, 'one'), ['e11']);
});
test('the below scope takes the episode and everything listed after it', () => {
assert.deepEqual(episodesInScope(LIST, 1, 'below'), ['e11', 'e10', 'e9']);
assert.deepEqual(episodesInScope(LIST, 0, 'below'), LIST);
assert.deepEqual(episodesInScope(LIST, 3, 'below'), ['e9']);
});
test('an index outside the list marks nothing', () => {
assert.deepEqual(episodesInScope(LIST, -1, 'below'), []);
assert.deepEqual(episodesInScope(LIST, 4, 'one'), []);
});
test('describeMarkCount agrees with itself about plurals and direction', () => {
assert.equal(describeMarkCount(1, true), 'Marked 1 episode watched');
assert.equal(describeMarkCount(3, true), 'Marked 3 episodes watched');
assert.equal(describeMarkCount(1, false), 'Cleared the watch mark on 1 episode');
assert.equal(describeMarkCount(12, false), 'Cleared the watch mark on 12 episodes');
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Which episodes a manual watch mark applies to.
*
* Sources list newest first, so "everything below" the row you right-clicked is
* the back catalogue: the natural shape of "I have watched up to here". The
* span is taken from the full list rather than from what the filter leaves,
* because a filter narrows what you are looking at, not what you have watched.
*/
export type MarkScope = 'one' | 'below';
export function episodesInScope<T>(episodes: T[], index: number, scope: MarkScope): T[] {
if (index < 0 || index >= episodes.length) return [];
return scope === 'one' ? [episodes[index]!] : episodes.slice(index);
}
/** "3 episodes" / "1 episode", for the status line after a bulk mark. */
export function describeMarkCount(count: number, watched: boolean): string {
const noun = count === 1 ? 'episode' : 'episodes';
return watched ? `Marked ${count} ${noun} watched` : `Cleared the watch mark on ${count} ${noun}`;
}
+9
View File
@@ -151,6 +151,15 @@
<div class="episodes-head">
<h3 class="episodes-title">Episodes</h3>
<span class="episodes-count" id="episodes-count"></span>
<span class="episodes-watched hidden" id="episodes-watched"></span>
<input
class="text-input episodes-filter hidden"
id="episodes-filter"
type="search"
placeholder="Filter: 12, 12-18, or a name"
autocomplete="off"
aria-label="Filter episodes"
/>
</div>
<ol class="cue-rail" id="episodes"></ol>
</div>
+105 -6
View File
@@ -91,6 +91,10 @@ import {
markVideoWatched,
upsertCoverArt,
} from './immersion-tracker/query-maintenance';
import {
getVideoIdByVideoKey,
getWatchStateByVideoKeys,
} from './immersion-tracker/query-watch-state';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import {
repairLegacySeasonlessAnimeRows,
@@ -338,6 +342,15 @@ export interface StreamPlaybackMetadataInput {
episodeNumber: number | null;
}
/** What a caller needs to show "already watched" against a streamed episode. */
export interface StreamWatchState {
/** Set once a session passed the completion threshold, or marked by hand. */
watched: boolean;
/** Start of the most recent session, or null when it was never played. */
lastWatchedMs: number | null;
sessionCount: number;
}
/**
* Parser sources that are recorded before playback starts. A video carrying one
* already has better metadata than filename guessing could produce, so the
@@ -722,6 +735,77 @@ export class ImmersionTrackerService {
markVideoWatched(this.db, videoId, watched);
}
/**
* Set the watch mark on streamed episodes by hand.
*
* Marking watched creates the video row when the episode was never played, so
* a series watched elsewhere can be caught up on; the row carries the same
* series/season/episode metadata playback would have recorded. Both library
* views join the lifetime tables, so a row created this way stays out of the
* stats lists until it is actually watched.
*
* Clearing a mark never creates anything: with no row there is nothing to
* clear.
*/
async setStreamWatchState(
episodes: StreamPlaybackMetadataInput[],
watched: boolean,
): Promise<number> {
let changed = 0;
// Every row this creates would otherwise rebuild the lifetime summaries on
// its own, and a season's worth of episodes arrives in one call.
let needsLifetimeRebuild = false;
for (const episode of episodes) {
const statsPath = normalizeMediaPath(episode.statsPath);
if (!statsPath) continue;
if (watched) {
needsLifetimeRebuild =
this.recordStreamPlaybackMetadata(episode, { deferLifetimeRebuild: true }) ||
needsLifetimeRebuild;
}
const videoId = getVideoIdByVideoKey(this.db, buildVideoKey(statsPath, SOURCE_TYPE_REMOTE));
if (videoId === null) continue;
markVideoWatched(this.db, videoId, watched);
changed += 1;
// Clearing the mark on what is playing right now would otherwise be undone
// the moment the session passes the completion threshold again.
if (!watched && this.sessionState?.videoId === videoId) {
this.sessionState.markedWatched = true;
}
}
if (needsLifetimeRebuild) rebuildLifetimeSummaryTables(this.db);
return changed;
}
/**
* Watch state for streamed episodes, keyed by the stats path the anime
* browser derives for each one. Paths never played are absent from the map,
* so a caller can treat "missing" as unwatched without a probe per episode.
*/
async getStreamWatchState(statsPaths: string[]): Promise<Map<string, StreamWatchState>> {
const byKey = new Map<string, string>();
for (const path of statsPaths) {
const normalized = normalizeMediaPath(path);
if (!normalized) continue;
byKey.set(buildVideoKey(normalized, SOURCE_TYPE_REMOTE), normalized);
}
const state = new Map<string, StreamWatchState>();
for (const row of getWatchStateByVideoKeys(this.db, [...byKey.keys()])) {
const statsPath = byKey.get(row.videoKey);
if (!statsPath) continue;
state.set(statsPath, {
watched: row.watched,
lastWatchedMs: row.lastWatchedMs,
sessionCount: row.sessionCount,
});
}
return state;
}
async markActiveVideoWatched(): Promise<boolean> {
if (!this.sessionState) return false;
markVideoWatched(this.db, this.sessionState.videoId, true);
@@ -1310,23 +1394,29 @@ export class ImmersionTrackerService {
});
}
recordStreamPlaybackMetadata(metadata: StreamPlaybackMetadataInput): void {
/** Returns whether the lifetime summaries still need rebuilding; see
* `recordPrePlaybackMetadata` for why a batch defers that. */
recordStreamPlaybackMetadata(
metadata: StreamPlaybackMetadataInput,
options: { deferLifetimeRebuild?: boolean } = {},
): boolean {
const rawPath = normalizeMediaPath(metadata.mediaPath);
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
if (!statsPath) {
return;
return false;
}
const seriesTitle = normalizeText(metadata.seriesTitle);
const displayTitle =
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
const libraryTitle = seriesTitle || displayTitle;
if (!libraryTitle) {
return;
return false;
}
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
this.recordPrePlaybackMetadata({
return this.recordPrePlaybackMetadata({
deferLifetimeRebuild: options.deferLifetimeRebuild,
statsPath,
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
displayTitle,
@@ -1347,6 +1437,10 @@ export class ImmersionTrackerService {
/**
* Creates the video row and its series link ahead of playback, so the session
* mpv's path change starts already belongs to the right anime.
*
* Returns whether the lifetime summaries need rebuilding. A new row always
* does, so a caller recording a batch passes `deferLifetimeRebuild` and
* rebuilds once at the end rather than once per episode.
*/
private recordPrePlaybackMetadata(params: {
statsPath: string;
@@ -1357,7 +1451,8 @@ export class ImmersionTrackerService {
episodeNumber: number | null;
parserSource: string;
metadataJson: string;
}): void {
deferLifetimeRebuild?: boolean;
}): boolean {
for (const alias of params.aliases) {
this.mediaPathAliases.set(alias, params.statsPath);
}
@@ -1399,9 +1494,13 @@ export class ImmersionTrackerService {
const hasLifetimeMedia = Boolean(
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
);
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
const needsRebuild = Boolean(
hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId),
);
if (needsRebuild && !params.deferLifetimeRebuild) {
rebuildLifetimeSummaryTables(this.db);
}
return needsRebuild;
}
private hasPrePlaybackMetadata(videoId: number): boolean {
@@ -0,0 +1,127 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
type ImmersionTrackerService = import('./immersion-tracker-service').ImmersionTrackerService;
const POLICY = {
batchSize: 10,
flushIntervalMs: 5_000,
queueCap: 100,
payloadCapBytes: 512,
maintenanceIntervalMs: 60 * 60 * 1000,
retention: {
eventsDays: 14,
telemetryDays: 45,
sessionsDays: 60,
dailyRollupsDays: 730,
monthlyRollupsDays: 3650,
vacuumIntervalDays: 14,
},
};
async function createTracker(): Promise<{ tracker: ImmersionTrackerService; dir: string }> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-watch-marks-'));
const { ImmersionTrackerService: Ctor } = await import('./immersion-tracker-service');
return { tracker: new Ctor({ dbPath: path.join(dir, 'immersion.sqlite'), policy: POLICY }), dir };
}
function episode(statsPath: string, episodeNumber: number) {
return {
mediaPath: '',
statsPath,
displayTitle: `Test Series S03E0${episodeNumber}`,
seriesTitle: 'Test Series',
seasonNumber: 3,
episodeNumber,
};
}
const EP1 = 'animebrowser://src/anime/ep1';
const EP2 = 'animebrowser://src/anime/ep2';
test('marking an episode nobody played records it, and clearing it takes the mark away', async () => {
const { tracker, dir } = await createTracker();
try {
assert.equal(await tracker.setStreamWatchState([episode(EP1, 1), episode(EP2, 2)], true), 2);
const marked = await tracker.getStreamWatchState([EP1, EP2]);
assert.equal(marked.get(EP1)?.watched, true);
assert.equal(marked.get(EP2)?.watched, true);
// Nothing was played, so there is no session behind the mark.
assert.equal(marked.get(EP1)?.sessionCount, 0);
assert.equal(marked.get(EP1)?.lastWatchedMs, null);
assert.equal(await tracker.setStreamWatchState([episode(EP1, 1)], false), 1);
const cleared = await tracker.getStreamWatchState([EP1, EP2]);
assert.equal(cleared.get(EP1)?.watched, false);
assert.equal(cleared.get(EP2)?.watched, true);
} finally {
tracker.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('clearing a mark on an episode with no history creates nothing', async () => {
const { tracker, dir } = await createTracker();
try {
assert.equal(await tracker.setStreamWatchState([episode(EP1, 1)], false), 0);
assert.equal((await tracker.getStreamWatchState([EP1])).size, 0);
} finally {
tracker.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a manual mark stays out of the stats library until the episode is watched', async () => {
const { tracker, dir } = await createTracker();
try {
await tracker.setStreamWatchState([episode(EP1, 1)], true);
// Both library views join the lifetime tables, which only playback fills.
assert.deepEqual(await tracker.getMediaLibrary(), []);
assert.deepEqual(await tracker.getAnimeLibrary(), []);
} finally {
tracker.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('a batch rebuilds the lifetime summaries once, not once per episode', async () => {
const { tracker, dir } = await createTracker();
const privateApi = tracker as unknown as {
recordPrePlaybackMetadata: (params: { deferLifetimeRebuild?: boolean }) => boolean;
};
const original = privateApi.recordPrePlaybackMetadata.bind(tracker);
let deferred = 0;
let immediate = 0;
privateApi.recordPrePlaybackMetadata = (params) => {
if (params.deferLifetimeRebuild) deferred += 1;
else immediate += 1;
return original(params);
};
try {
const season = Array.from({ length: 12 }, (_, index) =>
episode(`animebrowser://src/anime/batch-${index}`, index + 1),
);
await tracker.setStreamWatchState(season, true);
assert.equal(deferred, 12);
assert.equal(immediate, 0, 'every row in the batch defers its rebuild to the end of the call');
} finally {
tracker.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an episode with no stats path is skipped rather than recorded as unknown', async () => {
const { tracker, dir } = await createTracker();
try {
assert.equal(await tracker.setStreamWatchState([episode(' ', 1)], true), 0);
} finally {
tracker.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import { ensureSchema, getOrCreateVideoRecord } from '../storage.js';
import { startSessionRecord } from '../session.js';
import { markVideoWatched } from '../query-maintenance.js';
import { getWatchStateByVideoKeys } from '../query-watch-state.js';
import { SOURCE_TYPE_REMOTE } from '../types.js';
function createDb() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-watch-state-test-'));
const dbPath = path.join(dir, 'immersion.sqlite');
const db = new Database(dbPath);
ensureSchema(db);
return { db, dir };
}
function addStreamVideo(db: ReturnType<typeof createDb>['db'], statsPath: string): number {
return getOrCreateVideoRecord(db, `remote:${statsPath}`, {
canonicalTitle: statsPath,
sourcePath: null,
sourceUrl: statsPath,
sourceType: SOURCE_TYPE_REMOTE,
});
}
test('getWatchStateByVideoKeys reports watched marks and the newest session', () => {
const { db, dir } = createDb();
try {
const watchedPath = 'animebrowser://src/anime/ep1';
const startedPath = 'animebrowser://src/anime/ep2';
const watchedId = addStreamVideo(db, watchedPath);
const startedId = addStreamVideo(db, startedPath);
startSessionRecord(db, watchedId, 1_000_000);
startSessionRecord(db, watchedId, 3_000_000);
startSessionRecord(db, startedId, 2_000_000);
markVideoWatched(db, watchedId, true);
const rows = getWatchStateByVideoKeys(db, [
`remote:${watchedPath}`,
`remote:${startedPath}`,
'remote:animebrowser://src/anime/never-played',
]);
const byKey = new Map(rows.map((row) => [row.videoKey, row]));
assert.equal(rows.length, 2, 'a key with no video row comes back absent, not unwatched');
assert.deepEqual(byKey.get(`remote:${watchedPath}`), {
videoKey: `remote:${watchedPath}`,
watched: true,
lastWatchedMs: 3_000_000,
sessionCount: 2,
});
// Started but never finished: a row exists, the watch mark does not.
assert.equal(byKey.get(`remote:${startedPath}`)?.watched, false);
assert.equal(byKey.get(`remote:${startedPath}`)?.lastWatchedMs, 2_000_000);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('getWatchStateByVideoKeys handles a video that was never played', () => {
const { db, dir } = createDb();
try {
const statsPath = 'animebrowser://src/anime/ep3';
addStreamVideo(db, statsPath);
const [row] = getWatchStateByVideoKeys(db, [`remote:${statsPath}`]);
assert.equal(row?.lastWatchedMs, null);
assert.equal(row?.sessionCount, 0);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('getWatchStateByVideoKeys ignores empty keys and dedupes the rest', () => {
const { db, dir } = createDb();
try {
const statsPath = 'animebrowser://src/anime/ep4';
addStreamVideo(db, statsPath);
const rows = getWatchStateByVideoKeys(db, ['', `remote:${statsPath}`, `remote:${statsPath}`]);
assert.equal(rows.length, 1);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,79 @@
import type { DatabaseSync } from './sqlite';
import { fromDbTimestamp } from './query-shared';
/** Watch state of one video row, addressed by the key playback recorded it under. */
export interface VideoWatchStateRow {
videoKey: string;
watched: boolean;
/** Start of the most recent session on this video, or null when never played. */
lastWatchedMs: number | null;
sessionCount: number;
}
/** The video row a key belongs to, or null when nothing has recorded it yet. */
export function getVideoIdByVideoKey(db: DatabaseSync, videoKey: string): number | null {
const row = db.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?').get(videoKey) as {
video_id: number;
} | null;
return row?.video_id ?? null;
}
/**
* How many keys one statement binds. SQLite caps parameters per statement, and
* an episode list can be long, so the lookup runs in chunks.
*/
const CHUNK_SIZE = 400;
/**
* Look up watch state for a set of video keys.
*
* Keys that have never been played simply do not come back — the caller treats
* a missing key as unwatched rather than needing a row for it.
*
* `last_watched_ms` on the lifetime tables is rebuilt in batches and can lag, so
* the timestamp comes from the sessions themselves. Timestamps are epoch
* milliseconds stored as text, hence the cast before `MAX`.
*/
export function getWatchStateByVideoKeys(
db: DatabaseSync,
videoKeys: string[],
): VideoWatchStateRow[] {
const unique = [...new Set(videoKeys.filter((key) => key.length > 0))];
const rows: VideoWatchStateRow[] = [];
for (let start = 0; start < unique.length; start += CHUNK_SIZE) {
const chunk = unique.slice(start, start + CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(', ');
const chunkRows = db
.prepare(
`
SELECT
v.video_key AS videoKey,
v.watched AS watched,
MAX(CAST(s.started_at_ms AS INTEGER)) AS lastWatchedMs,
COUNT(s.session_id) AS sessionCount
FROM imm_videos v
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
WHERE v.video_key IN (${placeholders})
GROUP BY v.video_id
`,
)
.all(...chunk) as Array<{
videoKey: string;
watched: number;
lastWatchedMs: number | string | null;
sessionCount: number;
}>;
for (const row of chunkRows) {
rows.push({
videoKey: row.videoKey,
watched: row.watched === 1,
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs),
sessionCount: Number(row.sessionCount) || 0,
});
}
}
return rows;
}
+10
View File
@@ -3289,6 +3289,16 @@ const animeBrowserRuntime = createAnimeBrowserRuntime({
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
getWatchState: async (statsPaths) => {
// Browsing can precede any playback, so the tracker may not be up yet; it
// is the same instance playback records into once it is.
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.getStreamWatchState(statsPaths)) ?? new Map();
},
setWatchState: async (episodes, watched) => {
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.setStreamWatchState(episodes, watched)) ?? 0;
},
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Set before mpv reports the path change, so the session that change starts
+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();
});
+11
View File
@@ -5,6 +5,9 @@ import type {
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserWatchStateRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserPlayRequest,
AnimeBrowserPlayResult,
AnimeBrowserSearchResult,
@@ -31,6 +34,14 @@ const animeBrowserAPI: AnimeBrowserAPI = {
ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId),
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId),
getWatchState: (
watchRequest: AnimeBrowserWatchStateRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserGetWatchState, watchRequest),
setWatched: (
watchedRequest: AnimeBrowserSetWatchedRequest,
): Promise<AnimeBrowserEpisodeWatchState[]> =>
ipcRenderer.invoke(request.animeBrowserSetWatched, watchedRequest),
playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
+2
View File
@@ -130,6 +130,8 @@ export const IPC_CHANNELS = {
animeBrowserGetPopular: 'anime-browser:get-popular',
animeBrowserGetDetails: 'anime-browser:get-details',
animeBrowserGetEpisodes: 'anime-browser:get-episodes',
animeBrowserGetWatchState: 'anime-browser:get-watch-state',
animeBrowserSetWatched: 'anime-browser:set-watched',
animeBrowserPlayEpisode: 'anime-browser:play-episode',
animeBrowserGetPreferences: 'anime-browser:get-preferences',
animeBrowserSetPreference: 'anime-browser:set-preference',
+46
View File
@@ -46,6 +46,46 @@ export interface AnimeBrowserEpisode {
scanlator: string | null;
}
/**
* Whether an episode has already been watched, as the stats database records
* it. Playback marks an episode watched once a session passes the completion
* threshold, so this is history the app already keeps rather than a second
* list maintained by the browser.
*/
export interface AnimeBrowserEpisodeWatchState {
/** The episode's own url, matching the entry it belongs to. */
episodeUrl: string;
watched: boolean;
/** Start of the most recent session, or null when it was never played. */
lastWatchedMs: number | null;
sessionCount: number;
}
export interface AnimeBrowserWatchStateRequest {
sourceId: string;
animeUrl: string;
episodeUrls: string[];
}
/**
* One episode a manual mark applies to. The name and number ride along because
* marking an episode nobody has played yet has to create its stats row, and
* that row wants the same series/season/episode fields playback would record.
*/
export interface AnimeBrowserEpisodeMark {
episodeUrl: string;
episodeName: string;
episodeNumber: number | null;
}
export interface AnimeBrowserSetWatchedRequest {
sourceId: string;
animeUrl: string;
animeTitle: string;
episodes: AnimeBrowserEpisodeMark[];
watched: boolean;
}
/** One source that errored while the others answered. */
export interface SourceSearchFailure {
sourceId: string;
@@ -189,6 +229,12 @@ export interface AnimeBrowserAPI {
/** `sourceId` is required after an all-sources search; pass the entry's own. */
getDetails: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserDetails>;
getEpisodes: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserEpisode[]>;
/** Watch marks for the listed episodes; empty when stats tracking is off. */
getWatchState: (
request: AnimeBrowserWatchStateRequest,
) => Promise<AnimeBrowserEpisodeWatchState[]>;
/** Set or clear the mark by hand; resolves to the state after the write. */
setWatched: (request: AnimeBrowserSetWatchedRequest) => Promise<AnimeBrowserEpisodeWatchState[]>;
playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
setPreference: (