feat(formatter): add formattedSeason and formattedEpisode attributes

feat: handle multi-season/episode files in season/episode matching

feat: assume season is 1 in strict mode for season/episode matching when episode is present
This commit is contained in:
Viren070
2025-11-01 22:36:49 +00:00
parent a3c7288b98
commit e464f7d3e4
6 changed files with 92 additions and 83 deletions
+1 -3
View File
@@ -651,11 +651,9 @@ export const ParsedFileSchema = z.object({
languages: z.array(z.string()),
title: z.string().optional(),
year: z.coerce.string().optional(),
season: z.number().optional(),
seasons: z.array(z.number()).optional(),
episode: z.number().optional(),
episodes: z.array(z.number()).optional(),
seasonEpisode: z.array(z.string()).optional(),
// seasonEpisode: z.array(z.string()).optional(),
edition: z.string().optional(),
remastered: z.boolean().optional(),
repack: z.boolean().optional(),
+25 -5
View File
@@ -85,10 +85,12 @@ export interface ParseValue {
indexer: string | null;
year: string | null;
title: string | null;
season: number | null;
seasons: number[] | null;
episode: number | null;
season: number | null;
formattedSeasons: string | null;
episodes: number[] | null;
episode: number | null;
formattedEpisodes: string | null;
seasonEpisode: string[] | null;
seeders: number | null;
age: string | null;
@@ -185,6 +187,22 @@ export abstract class BaseFormatter {
...(this.userData.includedLanguages || []),
]),
];
const getPaddedNumber = (number: number, length: number) =>
number.toString().padStart(length, '0');
const formattedSeasonString = stream.parsedFile?.seasons?.length
? stream.parsedFile.seasons.length === 1
? `S${getPaddedNumber(stream.parsedFile.seasons[0], 2)}`
: `S${getPaddedNumber(stream.parsedFile.seasons[0], 2)}-${getPaddedNumber(stream.parsedFile.seasons[stream.parsedFile.seasons.length - 1], 2)}`
: undefined;
const formattedEpisodeString = stream.parsedFile?.episodes?.length
? stream.parsedFile.episodes.length === 1
? `E${getPaddedNumber(stream.parsedFile.episodes[0], 2)}`
: `E${getPaddedNumber(stream.parsedFile.episodes[0], 2)}-${getPaddedNumber(stream.parsedFile.episodes[stream.parsedFile.episodes.length - 1], 2)}`
: undefined;
const seasonEpisode = [
formattedSeasonString,
formattedEpisodeString,
].filter((v) => v !== undefined);
const sortedLanguages = languages
? [...languages].sort((a, b) => {
@@ -276,11 +294,13 @@ export abstract class BaseFormatter {
year: stream.parsedFile?.year || null,
type: stream.type || null,
title: stream.parsedFile?.title || null,
season: stream.parsedFile?.season || null,
season: stream.parsedFile?.seasons?.[0] || null,
formattedSeasons: formattedSeasonString || null,
seasons: stream.parsedFile?.seasons || null,
episode: stream.parsedFile?.episode || null,
episode: stream.parsedFile?.episodes?.[0] || null,
formattedEpisodes: formattedEpisodeString || null,
episodes: stream.parsedFile?.episodes || null,
seasonEpisode: stream.parsedFile?.seasonEpisode || null,
seasonEpisode: seasonEpisode || null,
duration: stream.duration || null,
infoHash: stream.torrent?.infoHash || null,
age: stream.age || null,
-38
View File
@@ -60,41 +60,6 @@ class FileParser {
filename.match(PARSE_REGEX.releaseGroup)?.[1] ?? parsed.group;
const title = parsed.title;
const year = parsed.year ? parsed.year.toString() : undefined;
// const season = parsed.season;
// const seasons = parsed.seasons;
// const episode = parsed.episode;
// const formattedSeasonString = seasons?.length
// ? seasons.length === 1
// ? `S${getPaddedNumber(seasons[0], 2)}`
// : `S${getPaddedNumber(seasons[0], 2)}-${getPaddedNumber(
// seasons[seasons.length - 1],
// 2
// )}`
// : season
// ? `S${getPaddedNumber(season, 2)}`
// : undefined;
// const formattedEpisodeString = episode
// ? `E${getPaddedNumber(episode, 2)}`
// : undefined;
// const seasonEpisode = [
// formattedSeasonString,
// formattedEpisodeString,
// ].filter((v) => v !== undefined);
const formattedSeasonString = parsed.seasons?.length
? parsed.seasons.length === 1
? `S${getPaddedNumber(parsed.seasons[0], 2)}`
: `S${getPaddedNumber(parsed.seasons[0], 2)}-${getPaddedNumber(parsed.seasons[parsed.seasons.length - 1], 2)}`
: undefined;
const formattedEpisodeString = parsed.episodes?.length
? parsed.episodes.length === 1
? `E${getPaddedNumber(parsed.episodes[0], 2)}`
: `E${getPaddedNumber(parsed.episodes[0], 2)}-${getPaddedNumber(parsed.episodes[parsed.episodes.length - 1], 2)}`
: undefined;
const seasonEpisode = [
formattedSeasonString,
formattedEpisodeString,
].filter((v) => v !== undefined);
return {
resolution,
@@ -115,11 +80,8 @@ class FileParser {
upscaled: parsed.upscaled ?? false,
network: parsed.network,
container: parsed.container,
season: parsed.seasons?.[0],
seasons: parsed.seasons,
episode: parsed.episodes?.[0],
episodes: parsed.episodes,
seasonEpisode,
};
}
}
+1 -3
View File
@@ -559,14 +559,12 @@ export abstract class StreamExpressionEngine {
parsedFile: {
title: 'Test Title',
year: '2024',
season: 1,
episode: 1,
seasons: [1],
episodes: [1],
resolution: '1080p',
quality: 'BluRay',
encode: 'x264',
releaseGroup: 'TEST',
seasonEpisode: ['S01', 'E01'],
visualTags: ['HDR'],
audioTags: ['AAC'],
audioChannels: ['2.0'],
+8 -10
View File
@@ -220,7 +220,7 @@ class StreamParser {
for (const line of potentialFilenames) {
const parsed = FileParser.parse(line);
if (parsed.year || parsed.season || parsed.episode) {
if (parsed.year || parsed.seasons?.length || parsed.episodes?.length) {
filename = line;
break;
}
@@ -430,8 +430,6 @@ class StreamParser {
return {
title: folderParsed?.title || fileParsed?.title,
year: fileParsed?.year || folderParsed?.year,
season: fileParsed?.season || folderParsed?.season,
episode: fileParsed?.episode || folderParsed?.episode,
seasons: fileParsed?.seasons || folderParsed?.seasons,
episodes: fileParsed?.episodes || folderParsed?.episodes,
resolution: fileParsed?.resolution || folderParsed?.resolution,
@@ -446,13 +444,13 @@ class StreamParser {
upscaled: fileParsed?.upscaled || folderParsed?.upscaled,
network: fileParsed?.network || folderParsed?.network,
container: fileParsed?.container || folderParsed?.container,
seasonEpisode:
fileParsed?.seasonEpisode && fileParsed?.seasonEpisode.length > 0
? fileParsed?.seasonEpisode
: folderParsed?.seasonEpisode &&
folderParsed?.seasonEpisode.length > 0
? folderParsed?.seasonEpisode
: undefined,
// seasonEpisode:
// fileParsed?.seasonEpisode && fileParsed?.seasonEpisode.length > 0
// ? fileParsed?.seasonEpisode
// : folderParsed?.seasonEpisode &&
// folderParsed?.seasonEpisode.length > 0
// ? folderParsed?.seasonEpisode
// : undefined,
visualTags: Array.from(
new Set([
...(folderParsed?.visualTags ?? []),
+57 -24
View File
@@ -652,35 +652,44 @@ class StreamFilterer {
// if the requested content is a movie and season/episode is present, filter out
if (
type === 'movie' &&
(stream.parsedFile?.season || stream.parsedFile?.episode)
(stream.parsedFile?.seasons?.length ||
stream.parsedFile?.episodes?.length)
) {
return false;
}
let seasons = stream.parsedFile?.seasons;
// if the requested content is series and no season or episode info is present, filter out if strict is true
if (
type === 'series' &&
seasonEpisodeMatchingOptions.strict &&
!stream.parsedFile?.season &&
!stream.parsedFile?.episode
) {
return false;
}
if (type === 'series' && seasonEpisodeMatchingOptions.strict) {
if (
!stream.parsedFile?.seasons?.length &&
!stream.parsedFile?.episodes?.length
) {
return false;
}
// is requested season present
if (
!stream.parsedFile.seasons?.length &&
stream.parsedFile.episodes?.length
) {
// assume season is 1 when empty and episode is present in strict mode.
seasons = [1];
}
}
if (
requestedSeason &&
((stream.parsedFile?.season &&
stream.parsedFile.season !== requestedSeason) ||
(stream.parsedFile?.seasons &&
!stream.parsedFile.seasons.includes(requestedSeason)))
seasons &&
seasons.length > 0 &&
!seasons.includes(requestedSeason)
) {
// If absolute episode matches, and parsed season is 1, allow even if season is incorrect
if (
stream.parsedFile?.season === 1 &&
stream.parsedFile?.episode &&
seasons?.[0] === 1 &&
stream.parsedFile?.episodes?.length &&
requestedMetadata?.absoluteEpisode &&
stream.parsedFile.episode === requestedMetadata.absoluteEpisode
stream.parsedFile?.episodes?.includes(
requestedMetadata.absoluteEpisode
)
) {
// allow
} else {
@@ -691,15 +700,19 @@ class StreamFilterer {
// is the present episode incorrect (does not match either the requested episode or absolute episode if present)
if (
requestedEpisode &&
stream.parsedFile?.episode &&
stream.parsedFile.episode !== requestedEpisode &&
stream.parsedFile?.episodes?.length &&
!stream.parsedFile?.episodes?.includes(requestedEpisode) &&
(requestedMetadata?.absoluteEpisode
? stream.parsedFile.episode !== requestedMetadata.absoluteEpisode
? !stream.parsedFile?.episodes?.includes(
requestedMetadata.absoluteEpisode
)
: true)
) {
return false;
}
// if episode is present, but season is not
return true;
};
@@ -1333,12 +1346,19 @@ class StreamFilterer {
if (
this.userData.excludeSeasonPacks &&
type === 'series' &&
stream.parsedFile?.season &&
!stream.parsedFile?.episode
stream.parsedFile?.seasons?.length &&
!stream.parsedFile?.episodes?.length
) {
const seasons = stream.parsedFile?.seasons;
const seasonStr =
seasons?.length === 1
? `S${String(seasons[0]).padStart(2, '0')}`
: seasons?.length
? `S${String(seasons[0]).padStart(2, '0')}-${String(seasons[seasons.length - 1]).padStart(2, '0')}`
: undefined;
this.incrementRemovalReason(
'excludeSeasonPacks',
`${stream.parsedFile.title} - ${stream.parsedFile.season}`
`${stream.parsedFile.title} - ${seasonStr}`
);
return false;
}
@@ -1449,10 +1469,23 @@ class StreamFilterer {
}
if (!performSeasonEpisodeMatch(stream)) {
const pad = (n: number) => n.toString().padStart(2, '0');
const s = stream.parsedFile?.seasons;
const e = stream.parsedFile?.episodes;
const formattedSeasonString = s?.length
? `S${pad(s[0])}${s.length > 1 ? `-${pad(s[s.length - 1])}` : ''}`
: undefined;
const formattedEpisodeString = e?.length
? `E${pad(e[0])}${e.length > 1 ? `-${pad(e[e.length - 1])}` : ''}`
: undefined;
const seasonEpisode = [
formattedSeasonString,
formattedEpisodeString,
].filter(Boolean);
const detail =
stream.parsedFile?.title +
' ' +
(stream.parsedFile?.seasonEpisode?.join(' x ') || 'Unknown');
(seasonEpisode?.join(' • ') || 'Unknown');
this.incrementRemovalReason('seasonEpisodeMatching', detail);
return false;