Files
SubMiner/src/types/anime-browser.ts
T
sudacode 3ca7dcd664 feat(anime): queue episodes to play next across anime
- Episode rows gain Play/Queue actions (and matching context-menu items); queued rows show their place in line, with a queue count and Clear queue in the episode header
- Queue lives in the main process (`anime-browser-queue.ts`) so it survives the browser window closing and advances on mpv's end-file even when nobody is watching; streams resolve at play time so a signed URL cannot expire while queued
- Holds mpv's keep-open off while the queue waits and restores it once empty; queueing with nothing playing just plays immediately
- Adds anime-browser-queue and episode-queue unit tests, IPC channels/contracts, and doc updates
2026-08-15 21:44:51 -07:00

300 lines
10 KiB
TypeScript

import type { AnimeStatus } from '../anime-bridge/media-url';
import type { SourcePreferenceView } from '../anime-bridge/preferences';
/**
* Stands in for a source id when every installed source should answer. Not a
* real source: `getPreferences` and the per-anime calls still need a single one.
*/
export const ALL_SOURCES_ID = '__all__';
/** An installed anime extension the browser can search. */
export interface AnimeBrowserSource {
id: string;
name: string;
lang: string;
/** Package name, e.g. `eu.kanade.tachiyomi.animeextension.all.jellyfin`. */
pkg: string;
}
export interface AnimeBrowserEntry {
/** Source-relative url; the handle for every later call. */
url: string;
title: string;
thumbnailUrl: string | null;
/**
* Which source produced this entry. Carried on the entry rather than read
* from the current selection, so an all-sources result stays usable.
*/
sourceId: string;
sourceName: string;
}
export interface AnimeBrowserDetails extends AnimeBrowserEntry {
description: string | null;
author: string | null;
genres: string[];
status: AnimeStatus;
}
export interface AnimeBrowserEpisode {
url: string;
name: string;
/** Extension-reported episode number; may be fractional for specials. */
number: number | null;
/** Epoch milliseconds, or null when the source reports no date. */
uploadedAt: number | 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. */
export interface SourceSearchFailure {
sourceId: string;
sourceName: string;
error: string;
}
export interface AnimeBrowserSearchResult {
entries: AnimeBrowserEntry[];
hasNextPage: boolean;
/**
* Sources that failed during an all-sources search. A single-source search
* rejects instead, so this is empty there.
*/
failures: SourceSearchFailure[];
}
/**
* Incremental progress of one search, pushed while the search invoke is still
* pending so a fast source is visible before a slow one answers.
*
* `token` orders searches: the renderer keeps the highest `start` token it has
* seen and drops events from any other search, so a stale search that is still
* resolving cannot paint over the one the user just typed.
*/
export type AnimeBrowserSearchUpdate =
| { kind: 'start'; token: number; sourceCount: number }
| {
kind: 'result';
token: number;
sourceId: string;
sourceName: string;
entries: AnimeBrowserEntry[];
}
| { kind: 'failure'; token: number; failure: SourceSearchFailure }
| { kind: 'done'; token: number };
/** Progress of the one long-running operation: bringing the bridge up. */
export type AnimeBrowserBridgeStage =
| 'idle'
| 'locating'
| 'downloading'
| 'verifying'
| 'extracting'
| 'starting'
| 'ready'
| 'failed';
export interface AnimeBrowserBridgeState {
stage: AnimeBrowserBridgeStage;
/** 0-1 while downloading, otherwise null. */
progress: number | null;
message: string | null;
}
/** An extension APK that failed to load, surfaced instead of silently vanishing. */
export interface ExtensionLoadFailure {
/** Package (file) name of the APK that failed. */
pkg: string;
error: string;
}
/** An extension offered by a configured repository. */
export interface AvailableExtension {
pkg: string;
name: string;
lang: string;
version: string;
nsfw: boolean;
repoUrl: string;
/** Where the repository publishes the extension's icon; may 404. */
iconUrl: string;
sourceNames: string[];
installed: boolean;
}
export interface RepoFailure {
repoUrl: string;
error: string;
}
export interface AvailableExtensionsResult {
extensions: AvailableExtension[];
failures: RepoFailure[];
}
/**
* An extension present in the extensions directory. Listed from disk rather
* than from a repository, so an APK dropped in by hand — or one whose
* repository has since been removed — can still be seen and removed.
*/
export interface InstalledExtensionView {
pkg: string;
/** The sources it provides, or the file name when it provided none. */
name: string;
/** Languages its sources cover, deduplicated. */
langs: string[];
/** How many sources it provides; 0 when it failed to load. */
sourceCount: number;
/** Why it failed to load, or null when it loaded. */
error: string | null;
}
export interface AnimeBrowserSnapshot {
bridge: AnimeBrowserBridgeState;
sources: AnimeBrowserSource[];
selectedSourceId: string | null;
loadFailures: ExtensionLoadFailure[];
/** Every extension on disk, whether or not it loaded. */
installed: InstalledExtensionView[];
/** Where APKs are read from, shown so the user knows where to drop files. */
extensionsDir: string;
/** Configured repository index URLs. Empty until the user adds one. */
repos: string[];
}
export interface AnimeBrowserPlayRequest {
sourceId: string;
animeUrl: string;
animeTitle: string;
episodeUrl: string;
episodeName: string;
/**
* The episode's number as the source reported it. Carried rather than
* re-parsed out of `episodeName`, which is free-form and often lacks one.
*/
episodeNumber: number | null;
}
export interface AnimeBrowserPlayResult {
ok: boolean;
/** Populated when ok is false, phrased for display. */
error: string | null;
quality: string | null;
}
/**
* One episode waiting for its turn.
*
* It is the play request itself rather than a resolved stream: extension stream
* URLs are signed and short-lived, so a queued episode is resolved when it
* reaches the front, not when it was queued half an hour earlier.
*/
export type AnimeBrowserQueueEntry = AnimeBrowserPlayRequest;
export interface AnimeBrowserQueueState {
/** In play order; the first entry starts when the current episode ends. */
entries: AnimeBrowserQueueEntry[];
/**
* Why the last automatic advance failed, or null. Cleared by the next queue
* change, so it reports the failure the user has not seen yet rather than
* accumulating a history.
*/
lastError: string | null;
/**
* How many times the queue has started an episode by itself. A counter
* rather than a flag: it tells a browser window that just repainted whether
* an advance happened since the state it last saw, including one that
* started the same episode twice.
*/
advances: number;
/** The episode the last advance started, or null before the first one. */
lastStarted: AnimeBrowserQueueEntry | null;
}
export interface AnimeBrowserAPI {
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
selectSource: (sourceId: string) => Promise<void>;
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
/** `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[]>;
/** Plays now, replacing whatever mpv is playing. */
playEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserPlayResult>;
/** Adds to the end of the queue; queueing an episode twice is a no-op. */
queueEpisode: (request: AnimeBrowserPlayRequest) => Promise<AnimeBrowserQueueState>;
dequeueEpisode: (sourceId: string, episodeUrl: string) => Promise<AnimeBrowserQueueState>;
clearQueue: () => Promise<AnimeBrowserQueueState>;
getQueue: () => Promise<AnimeBrowserQueueState>;
/**
* Whether mpv has a file open. False when it is idle or not running at all,
* which is when queueing has no end to wait for.
*/
isPlaying: () => Promise<boolean>;
getPreferences: (sourceId: string) => Promise<SourcePreferenceView[]>;
setPreference: (
sourceId: string,
key: string,
value: string | string[] | boolean,
) => Promise<SourcePreferenceView[]>;
listAvailableExtensions: () => Promise<AvailableExtensionsResult>;
installExtension: (pkg: string) => Promise<void>;
removeExtension: (pkg: string) => Promise<void>;
rescanExtensions: () => Promise<void>;
addRepo: (url: string) => Promise<void>;
removeRepo: (url: string) => Promise<void>;
onBridgeState: (listener: (state: AnimeBrowserBridgeState) => void) => () => void;
onSearchUpdate: (listener: (update: AnimeBrowserSearchUpdate) => void) => () => void;
/** Pushed whenever the queue changes, including when it advances by itself. */
onQueueState: (listener: (state: AnimeBrowserQueueState) => void) => () => void;
}
export type { SourcePreferenceView } from '../anime-bridge/preferences';