mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
|
import { collectVideos } from './picker.js';
|
|
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
|
|
|
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
|
|
|
|
export function seasonNumberFromDirName(name: string): number | null {
|
|
const match = name.trim().match(SEASON_DIR_PATTERN);
|
|
if (!match) return null;
|
|
const parsed = Number.parseInt(match[1]!, 10);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
export function resolveSeriesRoot(filePath: string): string {
|
|
const parent = path.dirname(filePath);
|
|
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
|
|
return path.dirname(parent);
|
|
}
|
|
return parent;
|
|
}
|
|
|
|
export function groupHistoryBySeries(
|
|
rows: HistoryVideoRow[],
|
|
existsFn: (candidate: string) => boolean = fs.existsSync,
|
|
): HistorySeriesEntry[] {
|
|
const byRoot = new Map<string, HistorySeriesEntry>();
|
|
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
|
|
|
for (const row of sorted) {
|
|
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
|
const existing = byRoot.get(seriesRoot);
|
|
if (existing) {
|
|
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
|
|
existing.coverBlobHash = row.coverBlobHash;
|
|
}
|
|
continue;
|
|
}
|
|
if (!existsFn(seriesRoot)) continue;
|
|
const displayName =
|
|
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
|
byRoot.set(seriesRoot, {
|
|
seriesRoot,
|
|
displayName,
|
|
lastWatched: row,
|
|
coverBlobHash: row.coverBlobHash,
|
|
});
|
|
}
|
|
|
|
return Array.from(byRoot.values());
|
|
}
|
|
|
|
function compareNatural(a: string, b: string): number {
|
|
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
}
|
|
|
|
export function sortVideosByEpisode(videos: string[]): string[] {
|
|
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
|
|
parsed.sort((a, b) => {
|
|
if (a.info.episode !== null && b.info.episode !== null) {
|
|
const seasonA = a.info.season ?? 0;
|
|
const seasonB = b.info.season ?? 0;
|
|
if (seasonA !== seasonB) return seasonA - seasonB;
|
|
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
|
|
}
|
|
return compareNatural(a.video, b.video);
|
|
});
|
|
return parsed.map((entry) => entry.video);
|
|
}
|
|
|
|
function dirContainsVideo(dir: string): boolean {
|
|
return collectVideos(dir, true).length > 0;
|
|
}
|
|
|
|
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const dirs = entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => ({
|
|
name: entry.name,
|
|
path: path.join(seriesRoot, entry.name),
|
|
season: seasonNumberFromDirName(entry.name),
|
|
}))
|
|
.filter((entry) => dirContainsVideo(entry.path));
|
|
|
|
dirs.sort((a, b) => {
|
|
if (a.season !== null && b.season !== null && a.season !== b.season) {
|
|
return a.season - b.season;
|
|
}
|
|
return compareNatural(a.name, b.name);
|
|
});
|
|
return dirs;
|
|
}
|
|
|
|
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
|
|
const seriesRoot = resolveSeriesRoot(resolvedLast);
|
|
if (seriesRoot === dir) return null;
|
|
const seasons = listSeasonDirs(seriesRoot);
|
|
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
|
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
|
|
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
|
|
return nextSeason[0] ?? null;
|
|
}
|
|
|
|
export function findNextEpisode(lastPath: string): string | null {
|
|
const resolvedLast = path.resolve(lastPath);
|
|
const dir = path.dirname(resolvedLast);
|
|
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
|
const idx = episodes.indexOf(resolvedLast);
|
|
|
|
if (idx >= 0) {
|
|
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
|
|
} else {
|
|
const lastInfo = parseMediaInfo(resolvedLast);
|
|
if (lastInfo.episode !== null) {
|
|
const candidate = episodes.find((episode) => {
|
|
const info = parseMediaInfo(episode);
|
|
return info.episode !== null && info.episode > lastInfo.episode!;
|
|
});
|
|
if (candidate) return candidate;
|
|
}
|
|
}
|
|
|
|
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
|
}
|