mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
fix(anime): thread episode metadata through stream playback and fix seas
- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8" - Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess - Add a Season field to the TsukiHime modal and include later seasons in its search query - Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT - Update CHANGELOG and troubleshooting docs
This commit is contained in:
@@ -320,6 +320,31 @@ export interface JellyfinPlaybackMetadataInput {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An episode the anime browser is about to stream.
|
||||
*
|
||||
* Reported up front for the same reason the Jellyfin variant is: the stream URL
|
||||
* mpv sees is a per-playback proxy address ending in `.m3u8`, so a session
|
||||
* started from it alone lands in a series named after the file extension.
|
||||
*/
|
||||
export interface StreamPlaybackMetadataInput {
|
||||
/** The URL handed to mpv; recorded as an alias of `statsPath`. */
|
||||
mediaPath: string;
|
||||
/** Stable per-episode identity. Survives the proxy port and token changing. */
|
||||
statsPath: string;
|
||||
displayTitle: string;
|
||||
seriesTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser sources that are recorded before playback starts. A video carrying one
|
||||
* already has better metadata than filename guessing could produce, so the
|
||||
* guess is skipped rather than allowed to overwrite it.
|
||||
*/
|
||||
const PREPLAYBACK_PARSER_SOURCES = new Set(['jellyfin', 'anime-browser']);
|
||||
|
||||
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
@@ -353,7 +378,7 @@ function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names:
|
||||
}
|
||||
}
|
||||
|
||||
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
|
||||
function buildMediaPathAliasCandidates(mediaPath: string): string[] {
|
||||
const candidates = new Set<string>([mediaPath]);
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
@@ -1251,15 +1276,11 @@ export class ImmersionTrackerService {
|
||||
if (!rawPath) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
|
||||
this.mediaPathAliases.set(alias, normalizedPath);
|
||||
}
|
||||
|
||||
const statsPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) ||
|
||||
normalizeText(metadata.itemTitle) ||
|
||||
deriveCanonicalTitle(normalizedPath);
|
||||
deriveCanonicalTitle(statsPath);
|
||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const libraryTitle = seriesTitle || itemTitle;
|
||||
@@ -1269,47 +1290,110 @@ export class ImmersionTrackerService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordPrePlaybackMetadata({
|
||||
statsPath,
|
||||
aliases: buildMediaPathAliasCandidates(rawPath),
|
||||
displayTitle,
|
||||
libraryTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
parserSource: 'jellyfin',
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
recordStreamPlaybackMetadata(metadata: StreamPlaybackMetadataInput): void {
|
||||
const rawPath = normalizeMediaPath(metadata.mediaPath);
|
||||
const statsPath = normalizeMediaPath(metadata.statsPath) || rawPath;
|
||||
if (!statsPath) {
|
||||
return;
|
||||
}
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) || seriesTitle || deriveCanonicalTitle(statsPath);
|
||||
const libraryTitle = seriesTitle || displayTitle;
|
||||
if (!libraryTitle) {
|
||||
return;
|
||||
}
|
||||
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
|
||||
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
|
||||
|
||||
this.recordPrePlaybackMetadata({
|
||||
statsPath,
|
||||
aliases: rawPath ? buildMediaPathAliasCandidates(rawPath) : [],
|
||||
displayTitle,
|
||||
libraryTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
parserSource: 'anime-browser',
|
||||
metadataJson: JSON.stringify({
|
||||
source: 'anime-browser',
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private recordPrePlaybackMetadata(params: {
|
||||
statsPath: string;
|
||||
aliases: string[];
|
||||
displayTitle: string;
|
||||
libraryTitle: string;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
parserSource: string;
|
||||
metadataJson: string;
|
||||
}): void {
|
||||
for (const alias of params.aliases) {
|
||||
this.mediaPathAliases.set(alias, params.statsPath);
|
||||
}
|
||||
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
this.db,
|
||||
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
||||
buildVideoKey(params.statsPath, SOURCE_TYPE_REMOTE),
|
||||
{
|
||||
canonicalTitle: displayTitle,
|
||||
canonicalTitle: params.displayTitle,
|
||||
sourcePath: null,
|
||||
sourceUrl: normalizedPath,
|
||||
sourceUrl: params.statsPath,
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const previousLink = this.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
const metadataJson = JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
seasonScope: seasonNumber,
|
||||
parsedTitle: params.libraryTitle,
|
||||
canonicalTitle: params.libraryTitle,
|
||||
seasonScope: params.seasonNumber,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
metadataJson: params.metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: libraryTitle,
|
||||
parsedSeason: seasonNumber,
|
||||
parsedEpisode: episodeNumber,
|
||||
parserSource: 'jellyfin',
|
||||
parsedTitle: params.libraryTitle,
|
||||
parsedSeason: params.seasonNumber,
|
||||
parsedEpisode: params.episodeNumber,
|
||||
parserSource: params.parserSource,
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: metadataJson,
|
||||
parseMetadataJson: params.metadataJson,
|
||||
});
|
||||
|
||||
const hasLifetimeMedia = Boolean(
|
||||
@@ -1320,17 +1404,17 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
}
|
||||
|
||||
private hasJellyfinMetadata(videoId: number): boolean {
|
||||
private hasPrePlaybackMetadata(videoId: number): boolean {
|
||||
const row = this.db
|
||||
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { parserSource: string | null } | null;
|
||||
return row?.parserSource === 'jellyfin';
|
||||
return row?.parserSource !== null && PREPLAYBACK_PARSER_SOURCES.has(row?.parserSource ?? '');
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath =
|
||||
buildJellyfinMediaPathAliasCandidates(rawPath)
|
||||
buildMediaPathAliasCandidates(rawPath)
|
||||
.map((alias) => this.mediaPathAliases.get(alias))
|
||||
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
@@ -1380,7 +1464,7 @@ export class ImmersionTrackerService {
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
} else if (!this.hasPrePlaybackMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
if (!youtubeVideoId) {
|
||||
|
||||
Reference in New Issue
Block a user