From e464f7d3e437476ffd44ac010e5b728be3131f59 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Sat, 1 Nov 2025 22:36:49 +0000 Subject: [PATCH] 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 --- packages/core/src/db/schemas.ts | 4 +- packages/core/src/formatters/base.ts | 30 ++++++-- packages/core/src/parser/file.ts | 38 --------- packages/core/src/parser/streamExpression.ts | 4 +- packages/core/src/parser/streams.ts | 18 ++--- packages/core/src/streams/filterer.ts | 81 ++++++++++++++------ 6 files changed, 92 insertions(+), 83 deletions(-) diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 20035d42..c2411a62 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -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(), diff --git a/packages/core/src/formatters/base.ts b/packages/core/src/formatters/base.ts index 789759f0..6db0e663 100644 --- a/packages/core/src/formatters/base.ts +++ b/packages/core/src/formatters/base.ts @@ -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, diff --git a/packages/core/src/parser/file.ts b/packages/core/src/parser/file.ts index 7710e41f..6f96dd51 100644 --- a/packages/core/src/parser/file.ts +++ b/packages/core/src/parser/file.ts @@ -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, }; } } diff --git a/packages/core/src/parser/streamExpression.ts b/packages/core/src/parser/streamExpression.ts index dfa7a1d9..51c3c233 100644 --- a/packages/core/src/parser/streamExpression.ts +++ b/packages/core/src/parser/streamExpression.ts @@ -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'], diff --git a/packages/core/src/parser/streams.ts b/packages/core/src/parser/streams.ts index b48c27ef..bef5db55 100644 --- a/packages/core/src/parser/streams.ts +++ b/packages/core/src/parser/streams.ts @@ -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 ?? []), diff --git a/packages/core/src/streams/filterer.ts b/packages/core/src/streams/filterer.ts index 793559b4..b90303b9 100644 --- a/packages/core/src/streams/filterer.ts +++ b/packages/core/src/streams/filterer.ts @@ -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;