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
+4
View File
@@ -24,3 +24,7 @@ area: anime
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits. - A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`. - An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream. - Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
- The episode list has a filter box: type an episode number (`12`), a range (`12-18`), or part of an episode name to narrow a long list, with a `6 of 25` counter while it is applied. Escape inside the box clears the filter instead of leaving the detail page.
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
+56
View File
@@ -118,6 +118,62 @@ the live result stream. A failed next-page request remains available to retry.
Source settings belong to a single extension, so the **Source settings** tab Source settings belong to a single extension, so the **Source settings** tab
asks you to pick one while **All sources** is selected. asks you to pick one while **All sources** is selected.
## Finding an episode, and what you have watched
An episode list can run to hundreds of entries, so the episode header carries a
filter box:
- A number, `12`, keeps that episode. Sources that report no numbers at all
are still searched by name, so `12` also matches `Episode 12` in a title.
- A range, `12-18`, keeps the episodes between the two, in either order
(`18-12` reads the same). Episodes the source gave no number are left out of
a range.
- Anything else is a case-insensitive substring of the episode name, so `beach`
finds `OVA: Beach Special`.
The counter next to **Episodes** reads `6 of 25` while a filter is applied.
Pressing Escape in the filter box clears it; pressing it anywhere else goes back
to the results grid.
Episodes you have already watched are dimmed and marked `✓ watched`, with a
count in the header. This is not a separate list the browser keeps: it reads the
same stats history the rest of SubMiner writes to, where an episode is marked
watched once a session runs past the completion threshold. Streams are recorded
under a stable per-episode identity, so the mark survives the stream URL
changing between playbacks, and it is the same mark the stats window and
`--mark-watched` use.
Because playback marks an episode partway through the session, the marks
refresh when the browser window comes back to the front: finish an episode in
mpv, switch back, and it is marked. With
[immersion tracking](configuration.md) disabled there is no history to read, so
no episode is marked.
### Marking by hand, and catching up
Right-click an episode for:
- **Mark watched** / **Mark unwatched**: the single episode, whichever way it
is not already.
- **Mark this and N below watched** / **... unwatched**: that episode and every
episode listed below it. Sources list newest first, so "below" is the back
catalogue: right-click the last episode you saw and mark everything down to
the start, which is how you catch up a series you watched somewhere else.
A filter narrows what you are looking at, not what you mark: a span always
covers the full episode list, and the status bar says how many episodes it
touched. The oldest episode has nothing below it, so it only offers the single
entry. Escape closes the menu.
Marking an episode you have never played creates its stats row so the mark has
somewhere to live. That row carries the same series, season and episode fields
playback would have recorded, and both stats library views join the lifetime
tables, so a manually marked episode does not appear there as watch time you
never spent. Clearing a mark never creates anything.
Marks are written to the stats history, so with immersion tracking disabled
there is nowhere to write them and the status bar says so.
## Settings ## Settings
| Key | Purpose | | Key | Purpose |
+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 { describe, el } from './dom';
import type { import { createEpisodeList, type SelectedAnime } from './episode-list';
AnimeBrowserAPI, import type { AnimeBrowserAPI, AnimeBrowserEntry } from '../types/anime-browser';
AnimeBrowserEntry,
AnimeBrowserEpisode,
} from '../types/anime-browser';
interface DetailPanelOptions { interface DetailPanelOptions {
api: AnimeBrowserAPI; api: AnimeBrowserAPI;
@@ -19,100 +16,15 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
const detailTitle = el<HTMLHeadingElement>('detail-title'); const detailTitle = el<HTMLHeadingElement>('detail-title');
const detailChips = el<HTMLDivElement>('detail-chips'); const detailChips = el<HTMLDivElement>('detail-chips');
const detailDescription = el<HTMLParagraphElement>('detail-description'); 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; let resultsScrollTop = 0;
const requests = new LatestRequest(); const requests = new LatestRequest();
const playbacks = new LatestRequest(); const episodeList = createEpisodeList({
api,
function formatEpisodeIndex(episode: AnimeBrowserEpisode, fallbackIndex: number): string { setStatus,
const value = episode.number ?? fallbackIndex; selectedAnime: () => selectedAnime,
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;
}),
);
}
async function open(entry: AnimeBrowserEntry): Promise<void> { async function open(entry: AnimeBrowserEntry): Promise<void> {
const request = requests.begin(); const request = requests.begin();
@@ -124,12 +36,11 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
detailTitle.textContent = entry.title; detailTitle.textContent = entry.title;
detailDescription.textContent = 'Loading…'; detailDescription.textContent = 'Loading…';
detailChips.replaceChildren(); detailChips.replaceChildren();
episodes.replaceChildren(); episodeList.clear();
episodesCount.textContent = '';
detailCover.src = entry.thumbnailUrl ?? ''; detailCover.src = entry.thumbnailUrl ?? '';
try { try {
const [details, episodeList] = await Promise.all([ const [details, episodes] = await Promise.all([
api.getDetails(entry.url, entry.sourceId), api.getDetails(entry.url, entry.sourceId),
api.getEpisodes(entry.url, entry.sourceId), api.getEpisodes(entry.url, entry.sourceId),
]); ]);
@@ -158,8 +69,8 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
} }
detailChips.replaceChildren(...chips); detailChips.replaceChildren(...chips);
renderEpisodes(episodeList); episodeList.render(episodes);
setStatus(`${details.title} · ${episodeList.length} episodes`); setStatus(`${details.title} · ${episodes.length} episodes`);
} catch (error) { } catch (error) {
if (!requests.isCurrent(request)) return; if (!requests.isCurrent(request)) return;
detailDescription.textContent = ''; detailDescription.textContent = '';
@@ -169,7 +80,7 @@ export function createDetailPanel({ api, setStatus }: DetailPanelOptions) {
function close(): void { function close(): void {
requests.cancel(); requests.cancel();
playbacks.cancel(); episodeList.clear();
detail.classList.add('hidden'); detail.classList.add('hidden');
results.classList.remove('hidden'); results.classList.remove('hidden');
results.scrollTop = resultsScrollTop; results.scrollTop = resultsScrollTop;
+88 -1
View File
@@ -96,7 +96,7 @@
.episodes-head { .episodes-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
@@ -117,6 +117,21 @@
color: var(--faint); 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 * 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 * what they are about to become. The rail is the spine, the index is the cue
@@ -205,6 +220,29 @@
margin-top: 2px; 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'] { .cue[data-state='loading'] {
background: var(--panel-elevated); background: var(--panel-elevated);
} }
@@ -220,6 +258,55 @@
border-color: var(--mine); 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 ---------- */ /* ---------- status bar ---------- */
.statusbar { .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"> <div class="episodes-head">
<h3 class="episodes-title">Episodes</h3> <h3 class="episodes-title">Episodes</h3>
<span class="episodes-count" id="episodes-count"></span> <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> </div>
<ol class="cue-rail" id="episodes"></ol> <ol class="cue-rail" id="episodes"></ol>
</div> </div>
+105 -6
View File
@@ -91,6 +91,10 @@ import {
markVideoWatched, markVideoWatched,
upsertCoverArt, upsertCoverArt,
} from './immersion-tracker/query-maintenance'; } from './immersion-tracker/query-maintenance';
import {
getVideoIdByVideoKey,
getWatchStateByVideoKeys,
} from './immersion-tracker/query-watch-state';
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair'; import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
import { import {
repairLegacySeasonlessAnimeRows, repairLegacySeasonlessAnimeRows,
@@ -338,6 +342,15 @@ export interface StreamPlaybackMetadataInput {
episodeNumber: number | null; 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 * Parser sources that are recorded before playback starts. A video carrying one
* already has better metadata than filename guessing could produce, so the * already has better metadata than filename guessing could produce, so the
@@ -722,6 +735,77 @@ export class ImmersionTrackerService {
markVideoWatched(this.db, videoId, watched); 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> { async markActiveVideoWatched(): Promise<boolean> {
if (!this.sessionState) return false; if (!this.sessionState) return false;
markVideoWatched(this.db, this.sessionState.videoId, true); 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 rawPath = normalizeMediaPath(metadata.mediaPath);
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath; const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
if (!statsPath) { if (!statsPath) {
return; return false;
} }
const seriesTitle = normalizeText(metadata.seriesTitle); const seriesTitle = normalizeText(metadata.seriesTitle);
const displayTitle = const displayTitle =
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath); normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
const libraryTitle = seriesTitle || displayTitle; const libraryTitle = seriesTitle || displayTitle;
if (!libraryTitle) { if (!libraryTitle) {
return; return false;
} }
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber); const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber); const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
this.recordPrePlaybackMetadata({ return this.recordPrePlaybackMetadata({
deferLifetimeRebuild: options.deferLifetimeRebuild,
statsPath, statsPath,
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [], aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
displayTitle, displayTitle,
@@ -1347,6 +1437,10 @@ export class ImmersionTrackerService {
/** /**
* Creates the video row and its series link ahead of playback, so the session * 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. * 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: { private recordPrePlaybackMetadata(params: {
statsPath: string; statsPath: string;
@@ -1357,7 +1451,8 @@ export class ImmersionTrackerService {
episodeNumber: number | null; episodeNumber: number | null;
parserSource: string; parserSource: string;
metadataJson: string; metadataJson: string;
}): void { deferLifetimeRebuild?: boolean;
}): boolean {
for (const alias of params.aliases) { for (const alias of params.aliases) {
this.mediaPathAliases.set(alias, params.statsPath); this.mediaPathAliases.set(alias, params.statsPath);
} }
@@ -1399,9 +1494,13 @@ export class ImmersionTrackerService {
const hasLifetimeMedia = Boolean( const hasLifetimeMedia = Boolean(
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId), 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); rebuildLifetimeSummaryTables(this.db);
} }
return needsRebuild;
} }
private hasPrePlaybackMetadata(videoId: number): boolean { 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) => showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }), 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) => { onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata); streamPlaybackMetadata.set(metadata);
// Set before mpv reports the path change, so the session that change starts // 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 { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { AnimeBrowserRuntime } from './anime-browser-runtime'; 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 { export interface AnimeBrowserIpcDeps {
// Structurally typed so tests can pass a fake without importing Electron. // 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) => handle(channels.animeBrowserGetEpisodes, (_event, animeUrl, sourceId) =>
runtime.getEpisodes(String(animeUrl), toOptionalId(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.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
handle(channels.animeBrowserInstallExtension, (_event, pkg) => handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(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", * 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 "". * 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 { 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 { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache'; import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle'; import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
@@ -29,6 +36,18 @@ export interface AnimeBrowserRuntimeDeps {
showVisibleOverlay?: () => void; showVisibleOverlay?: () => void;
/** Publishes stream identity before loadfile starts the stats session. */ /** Publishes stream identity before loadfile starts the stats session. */
onPlaybackMetadata?: (metadata: AnimeStreamMetadata) => void; 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. */ /** Lets tests drive the pause between loadfile and track attachment. */
wait?: (ms: number) => Promise<void>; wait?: (ms: number) => Promise<void>;
/** Overrides the filesystem/network the subtitle cache uses. Tests only. */ /** Overrides the filesystem/network the subtitle cache uses. Tests only. */
+97
View File
@@ -23,6 +23,10 @@ import {
installExtension, installExtension,
removeExtension as removeExtensionFile, removeExtension as removeExtensionFile,
} from '../../anime-bridge/extension-installer'; } from '../../anime-bridge/extension-installer';
import {
buildAnimeStreamMetadata,
buildAnimeStreamStatsPath,
} from '../../anime-bridge/episode-metadata';
import { PreferenceStore } from '../../anime-bridge/preference-store'; import { PreferenceStore } from '../../anime-bridge/preference-store';
import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences'; import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/preferences';
import type { SourcePreferenceView } from '../../anime-bridge/preferences'; import type { SourcePreferenceView } from '../../anime-bridge/preferences';
@@ -32,6 +36,9 @@ import type {
AnimeBrowserDetails, AnimeBrowserDetails,
AnimeBrowserEntry, AnimeBrowserEntry,
AnimeBrowserEpisode, AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserSetWatchedRequest,
AnimeBrowserWatchStateRequest,
AnimeBrowserSearchResult, AnimeBrowserSearchResult,
AnimeBrowserSearchUpdate, AnimeBrowserSearchUpdate,
AnimeBrowserSnapshot, 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({ const playback = createAnimeBrowserPlayback({
deps, deps,
bridge, 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, playEpisode: playback.playEpisode,
async dispose(): Promise<void> { 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, AnimeBrowserBridgeState,
AnimeBrowserDetails, AnimeBrowserDetails,
AnimeBrowserEpisode, AnimeBrowserEpisode,
AnimeBrowserEpisodeWatchState,
AnimeBrowserWatchStateRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserPlayRequest, AnimeBrowserPlayRequest,
AnimeBrowserPlayResult, AnimeBrowserPlayResult,
AnimeBrowserSearchResult, AnimeBrowserSearchResult,
@@ -31,6 +34,14 @@ const animeBrowserAPI: AnimeBrowserAPI = {
ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId), ipcRenderer.invoke(request.animeBrowserGetDetails, animeUrl, sourceId),
getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> => getEpisodes: (animeUrl: string, sourceId?: string): Promise<AnimeBrowserEpisode[]> =>
ipcRenderer.invoke(request.animeBrowserGetEpisodes, animeUrl, sourceId), 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> => playEpisode: (playRequest: AnimeBrowserPlayRequest): Promise<AnimeBrowserPlayResult> =>
ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest), ipcRenderer.invoke(request.animeBrowserPlayEpisode, playRequest),
getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> => getPreferences: (sourceId: string): Promise<SourcePreferenceView[]> =>
+2
View File
@@ -130,6 +130,8 @@ export const IPC_CHANNELS = {
animeBrowserGetPopular: 'anime-browser:get-popular', animeBrowserGetPopular: 'anime-browser:get-popular',
animeBrowserGetDetails: 'anime-browser:get-details', animeBrowserGetDetails: 'anime-browser:get-details',
animeBrowserGetEpisodes: 'anime-browser:get-episodes', animeBrowserGetEpisodes: 'anime-browser:get-episodes',
animeBrowserGetWatchState: 'anime-browser:get-watch-state',
animeBrowserSetWatched: 'anime-browser:set-watched',
animeBrowserPlayEpisode: 'anime-browser:play-episode', animeBrowserPlayEpisode: 'anime-browser:play-episode',
animeBrowserGetPreferences: 'anime-browser:get-preferences', animeBrowserGetPreferences: 'anime-browser:get-preferences',
animeBrowserSetPreference: 'anime-browser:set-preference', animeBrowserSetPreference: 'anime-browser:set-preference',
+46
View File
@@ -46,6 +46,46 @@ export interface AnimeBrowserEpisode {
scanlator: string | null; 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. */ /** One source that errored while the others answered. */
export interface SourceSearchFailure { export interface SourceSearchFailure {
sourceId: string; sourceId: string;
@@ -189,6 +229,12 @@ export interface AnimeBrowserAPI {
/** `sourceId` is required after an all-sources search; pass the entry's own. */ /** `sourceId` is required after an all-sources search; pass the entry's own. */
getDetails: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserDetails>; getDetails: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserDetails>;
getEpisodes: (animeUrl: string, sourceId?: string) => Promise<AnimeBrowserEpisode[]>; 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>; playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>; getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
setPreference: ( setPreference: (