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