mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user