mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-03 04:19:27 -07:00
130 lines
3.5 KiB
TypeScript
130 lines
3.5 KiB
TypeScript
import path from 'node:path';
|
|
|
|
type ParsedEpisodeKey = {
|
|
season: number | null;
|
|
episode: number;
|
|
};
|
|
|
|
type SortToken = string | number;
|
|
|
|
export type PlaylistBrowserSortedDirectoryItem = {
|
|
path: string;
|
|
basename: string;
|
|
episodeLabel: string | null;
|
|
};
|
|
|
|
const COLLATOR = new Intl.Collator(undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
});
|
|
|
|
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
|
|
const name = basename.replace(/\.[^.]+$/, '');
|
|
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
|
|
if (seasonEpisode) {
|
|
return {
|
|
season: Number(seasonEpisode[1]),
|
|
episode: Number(seasonEpisode[2]),
|
|
};
|
|
}
|
|
|
|
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
|
|
if (seasonByX) {
|
|
return {
|
|
season: Number(seasonByX[1]),
|
|
episode: Number(seasonByX[2]),
|
|
};
|
|
}
|
|
|
|
const namedEpisode = name.match(
|
|
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
|
|
);
|
|
if (namedEpisode) {
|
|
return {
|
|
season: null,
|
|
episode: Number(namedEpisode[1]),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
|
|
if (!parsed) return null;
|
|
if (parsed.season !== null) {
|
|
return `S${parsed.season}E${parsed.episode}`;
|
|
}
|
|
return `E${parsed.episode}`;
|
|
}
|
|
|
|
function tokenizeNaturalSort(basename: string): SortToken[] {
|
|
return basename
|
|
.toLowerCase()
|
|
.split(/(\d+)/)
|
|
.filter((token) => token.length > 0)
|
|
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
|
|
}
|
|
|
|
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
|
|
const maxLength = Math.max(left.length, right.length);
|
|
for (let index = 0; index < maxLength; index += 1) {
|
|
const a = left[index];
|
|
const b = right[index];
|
|
if (a === undefined) return -1;
|
|
if (b === undefined) return 1;
|
|
if (typeof a === 'number' && typeof b === 'number') {
|
|
if (a !== b) return a - b;
|
|
continue;
|
|
}
|
|
const comparison = COLLATOR.compare(String(a), String(b));
|
|
if (comparison !== 0) return comparison;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function sortPlaylistBrowserDirectoryItems(
|
|
paths: string[],
|
|
): PlaylistBrowserSortedDirectoryItem[] {
|
|
return paths
|
|
.map((pathValue) => {
|
|
const basename = path.basename(pathValue);
|
|
const parsed = parseEpisodeKey(basename);
|
|
return {
|
|
path: pathValue,
|
|
basename,
|
|
parsed,
|
|
episodeLabel: buildEpisodeLabel(parsed),
|
|
naturalTokens: tokenizeNaturalSort(basename),
|
|
};
|
|
})
|
|
.sort((left, right) => {
|
|
if (left.parsed && right.parsed) {
|
|
if (
|
|
left.parsed.season !== null &&
|
|
right.parsed.season !== null &&
|
|
left.parsed.season !== right.parsed.season
|
|
) {
|
|
return left.parsed.season - right.parsed.season;
|
|
}
|
|
if (left.parsed.episode !== right.parsed.episode) {
|
|
return left.parsed.episode - right.parsed.episode;
|
|
}
|
|
} else if (left.parsed && !right.parsed) {
|
|
return -1;
|
|
} else if (!left.parsed && right.parsed) {
|
|
return 1;
|
|
}
|
|
|
|
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
|
|
if (naturalComparison !== 0) {
|
|
return naturalComparison;
|
|
}
|
|
return COLLATOR.compare(left.basename, right.basename);
|
|
})
|
|
.map(({ path: itemPath, basename, episodeLabel }) => ({
|
|
path: itemPath,
|
|
basename,
|
|
episodeLabel,
|
|
}));
|
|
}
|