mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 07:21:35 -07:00
36cd19794e
- 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
206 lines
7.3 KiB
TypeScript
206 lines
7.3 KiB
TypeScript
/**
|
|
* Structured metadata for a streamed episode.
|
|
*
|
|
* Extensions hand us two free-form strings — an anime title that usually
|
|
* carries the season ("… Season 3") and an episode label that usually carries
|
|
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
|
|
* the subtitle modals) wants those as separate fields, so they are split once
|
|
* here rather than re-parsed out of the mpv title by each consumer.
|
|
*/
|
|
|
|
/** Where a stream came from, resolved into the fields consumers actually want. */
|
|
export interface AnimeStreamMetadata {
|
|
/** The URL handed to mpv. Matches what mpv reports as `path`. */
|
|
mediaPath: string;
|
|
/**
|
|
* Stable identity for this episode. The stream URL carries a per-playback
|
|
* proxy port and token, so it cannot be the key stats stores.
|
|
*/
|
|
statsPath: string;
|
|
/** Series name with the season suffix removed. */
|
|
seriesTitle: string;
|
|
seasonNumber: number | null;
|
|
episodeNumber: number | null;
|
|
/** The episode's own name, or null when the label was only a number. */
|
|
episodeTitle: string | null;
|
|
/** Shown by mpv, and the fallback every string parser sees. */
|
|
displayTitle: string;
|
|
}
|
|
|
|
export interface AnimeStreamMetadataInput {
|
|
sourceId: string;
|
|
animeUrl: string;
|
|
animeTitle: string;
|
|
episodeUrl: string;
|
|
episodeName: string;
|
|
/** Extension-reported number; trusted over anything parsed from the label. */
|
|
episodeNumber: number | null;
|
|
/** The URL playback actually uses, after proxy rewriting. */
|
|
mediaPath: string;
|
|
}
|
|
|
|
/**
|
|
* Season suffixes, anchored to the end of the title so a "Season" that is part
|
|
* of the name ("A Season of Snow") cannot be mistaken for one.
|
|
*/
|
|
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
|
|
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
|
|
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
|
|
/[\s:_-]+s(\d{1,2})\s*$/i,
|
|
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
|
|
/[\s:_-]+(\d{1,2})\s*期\s*$/,
|
|
];
|
|
|
|
/**
|
|
* Episode labels, most specific first. The trailing group is the episode's own
|
|
* name when the label carries one.
|
|
*/
|
|
const EPISODE_LABEL_PATTERNS: RegExp[] = [
|
|
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
|
|
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
|
|
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
|
|
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
|
|
];
|
|
|
|
function collapseWhitespace(value: string): string {
|
|
return value.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
/**
|
|
* Trims separators a split left dangling on either end. `.` is deliberately not
|
|
* one of them: an episode name often ends in a full stop that belongs to it.
|
|
*/
|
|
function trimSeparators(value: string): string {
|
|
return collapseWhitespace(value)
|
|
.replace(/^[\s:_\-–—]+/, '')
|
|
.replace(/[\s:_\-–—]+$/, '')
|
|
.trim();
|
|
}
|
|
|
|
function toEpisodeNumber(value: unknown): number | null {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Split a trailing season marker off an anime title.
|
|
*
|
|
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
|
|
* season 3, which is what both AniList and the stats grouping key want. A title
|
|
* with no marker is returned unchanged with a null season — season 1 is *not*
|
|
* assumed, because "unknown" and "one" behave differently when grouping.
|
|
*/
|
|
export function splitSeasonFromTitle(animeTitle: string): {
|
|
title: string;
|
|
season: number | null;
|
|
} {
|
|
const normalized = collapseWhitespace(animeTitle);
|
|
for (const pattern of SEASON_SUFFIX_PATTERNS) {
|
|
const match = normalized.match(pattern);
|
|
if (!match || match.index === undefined) continue;
|
|
const season = Number.parseInt(match[1]!, 10);
|
|
if (!Number.isInteger(season) || season <= 0) continue;
|
|
const title = trimSeparators(normalized.slice(0, match.index));
|
|
// A title that is *only* a season marker is not a title; keep the original.
|
|
if (!title) continue;
|
|
return { title, season };
|
|
}
|
|
return { title: normalized, season: null };
|
|
}
|
|
|
|
/**
|
|
* Split an episode label into its number and its own name.
|
|
*
|
|
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
|
|
* bare "4" all show up. A label that matches nothing is treated as a pure
|
|
* episode name, which is right for movies and specials.
|
|
*/
|
|
export function splitEpisodeLabel(episodeName: string): {
|
|
number: number | null;
|
|
title: string | null;
|
|
} {
|
|
const normalized = collapseWhitespace(episodeName);
|
|
if (!normalized) return { number: null, title: null };
|
|
|
|
for (const pattern of EPISODE_LABEL_PATTERNS) {
|
|
const match = normalized.match(pattern);
|
|
if (!match) continue;
|
|
const parsed = Number.parseFloat(match[1]!);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) continue;
|
|
const title = trimSeparators(match[2] ?? '');
|
|
return { number: parsed, title: title || null };
|
|
}
|
|
|
|
return { number: null, title: normalized };
|
|
}
|
|
|
|
function formatEpisodePart(value: number): string {
|
|
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
|
|
}
|
|
|
|
/**
|
|
* The title mpv shows.
|
|
*
|
|
* `SxxEyy` is not just for looks: it is the one form both guessit and
|
|
* SubMiner's own filename parser read reliably, so any consumer that only ever
|
|
* sees the title string still lands on the right series and episode.
|
|
*/
|
|
export function buildStreamDisplayTitle(
|
|
seriesTitle: string,
|
|
season: number | null,
|
|
episode: number | null,
|
|
episodeTitle: string | null,
|
|
): string {
|
|
const parts: string[] = [seriesTitle];
|
|
if (episode !== null) {
|
|
parts.push(
|
|
season !== null
|
|
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
|
|
: `E${formatEpisodePart(episode)}`,
|
|
);
|
|
} else if (season !== null) {
|
|
parts.push(`S${String(season).padStart(2, '0')}`);
|
|
}
|
|
|
|
const head = parts.join(' ');
|
|
return episodeTitle ? `${head} - ${episodeTitle}` : head;
|
|
}
|
|
|
|
/**
|
|
* A per-episode identity that survives across playbacks.
|
|
*
|
|
* The stream URL points at the strip proxy, whose port and token are minted per
|
|
* playback, so keying stats on it makes every rewatch a new video. The source's
|
|
* own episode url is stable, so that is what stats records instead — with the
|
|
* real URL kept as an alias so mpv's path change still finds the row.
|
|
*/
|
|
export function buildAnimeStreamStatsPath(
|
|
sourceId: string,
|
|
animeUrl: string,
|
|
episodeUrl: string,
|
|
): string {
|
|
const source = encodeURIComponent(sourceId || 'unknown');
|
|
const anime = encodeURIComponent(animeUrl || 'unknown');
|
|
const episode = encodeURIComponent(episodeUrl || 'unknown');
|
|
return `animebrowser://${source}/${anime}/${episode}`;
|
|
}
|
|
|
|
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
|
|
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
|
|
const label = splitEpisodeLabel(input.episodeName);
|
|
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
|
|
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
|
|
|
|
return {
|
|
mediaPath: input.mediaPath,
|
|
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
|
|
seriesTitle,
|
|
seasonNumber: season,
|
|
episodeNumber,
|
|
episodeTitle: label.title,
|
|
// A source that gave us neither a number nor a name leaves the series title
|
|
// alone rather than showing an empty suffix.
|
|
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
|
|
};
|
|
}
|