diff --git a/apps/server/src/modules/rules/constants/rules.constants.ts b/apps/server/src/modules/rules/constants/rules.constants.ts index 673040621..3b93a5d62 100644 --- a/apps/server/src/modules/rules/constants/rules.constants.ts +++ b/apps/server/src/modules/rules/constants/rules.constants.ts @@ -967,6 +967,14 @@ export class RuleConstants { type: RuleType.NUMBER, showType: ['show', 'season', 'episode'], }, + { + id: 35, + name: 'seasonFileRank', + humanName: 'Season file rank by air date (newest = 1)', + mediaType: MediaType.SHOW, + type: RuleType.NUMBER, + showType: ['season'], + }, ], }, { diff --git a/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts b/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts index 0f38b4af1..3fa9cb4d5 100644 --- a/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts +++ b/apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts @@ -1766,6 +1766,193 @@ describe('SonarrGetterService', () => { }); }); + describe('seasonFileRank', () => { + const callSeasonRank = async ( + episodes: ReturnType[], + targetSeasonNumber: number, + options: { + arrLookupCache?: ArrLookupCache; + dataType?: MediaItemType; + } = {}, + ) => { + const dataType = options.dataType ?? 'season'; + const collectionMedia = createCollectionMedia(dataType); + collectionMedia.collection.sonarrSettingsId = 1; + + mockMediaServer.getMetadata.mockResolvedValue( + createMediaItem({ type: 'show' }), + ); + + const series = createSonarrSeries({ id: 7, seasons: [] }); + const mockedSonarrApi = mockSonarrApi(series); + jest.spyOn(mockedSonarrApi, 'getEpisodes').mockResolvedValue(episodes); + + const response = await sonarrGetterService.get( + 35, + createMediaItem({ + type: dataType, + index: targetSeasonNumber, + parentId: 'show-1', + }), + dataType, + createRulesDto({ + collection: collectionMedia.collection, + dataType, + }), + undefined, + options.arrLookupCache, + ); + + return { response, mockedSonarrApi }; + }; + + const seasonEpisodes = ( + seasonNumber: number, + airDates: string[], + hasFile = true, + ) => + airDates.map((airDateUtc, i) => + createSonarrEpisode({ + seriesId: 7, + seasonNumber, + episodeNumber: i + 1, + airDateUtc, + hasFile, + }), + ); + + it('ranks seasons by their newest downloaded episode air date (newest = 1)', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + const episodes = [ + ...seasonEpisodes(1, ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']), + ...seasonEpisodes(2, ['2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z']), + ...seasonEpisodes(3, ['2026-01-01T00:00:00Z']), + ]; + + expect((await callSeasonRank(episodes, 3)).response).toBe(1); + expect((await callSeasonRank(episodes, 2)).response).toBe(2); + expect((await callSeasonRank(episodes, 1)).response).toBe(3); + }); + + it('seasons without downloaded episodes take no rank slot', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + // Season 3 aired newest but has no files: season 2 must be rank 1 and + // season 3 must rank null (fail-closed). + const episodes = [ + ...seasonEpisodes(1, ['2024-01-01T00:00:00Z']), + ...seasonEpisodes(2, ['2025-01-01T00:00:00Z']), + ...seasonEpisodes(3, ['2026-01-01T00:00:00Z'], false), + ]; + + expect((await callSeasonRank(episodes, 2)).response).toBe(1); + expect((await callSeasonRank(episodes, 1)).response).toBe(2); + expect((await callSeasonRank(episodes, 3)).response).toBeNull(); + }); + + it('returns null for specials (season 0 excluded from pool)', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + const episodes = [ + ...seasonEpisodes(0, ['2025-06-01T00:00:00Z']), + ...seasonEpisodes(1, ['2025-01-01T00:00:00Z']), + ]; + + expect((await callSeasonRank(episodes, 0)).response).toBeNull(); + expect((await callSeasonRank(episodes, 1)).response).toBe(1); + }); + + it('returns null for a season whose only downloaded episodes are unaired', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + const episodes = [ + ...seasonEpisodes(1, ['2025-01-01T00:00:00Z']), + ...seasonEpisodes(2, ['2026-07-01T00:00:00Z']), + ]; + + expect((await callSeasonRank(episodes, 2)).response).toBeNull(); + expect((await callSeasonRank(episodes, 1)).response).toBe(1); + }); + + it('returns null for non-season data types', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + const episodes = seasonEpisodes(1, ['2025-01-01T00:00:00Z']); + + const { response } = await callSeasonRank(episodes, 1, { + dataType: 'show', + }); + expect(response).toBeNull(); + }); + + it('returns undefined when the episode list fetch fails transiently', async () => { + const collectionMedia = createCollectionMedia('season'); + collectionMedia.collection.sonarrSettingsId = 1; + mockMediaServer.getMetadata.mockResolvedValue( + createMediaItem({ type: 'show' }), + ); + const series = createSonarrSeries({ id: 7, seasons: [] }); + const mockedSonarrApi = mockSonarrApi(series); + jest + .spyOn(mockedSonarrApi, 'getEpisodes') + .mockResolvedValue(undefined as any); + + const response = await sonarrGetterService.get( + 35, + createMediaItem({ type: 'season', index: 1, parentId: 'show-1' }), + 'season', + createRulesDto({ + collection: collectionMedia.collection, + dataType: 'season', + }), + ); + + expect(response).toBeUndefined(); + }); + + it('shares one cached episode fetch with episodeFileRank within a run', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-06-13T12:00:00Z')); + const episodes = [ + ...seasonEpisodes(1, ['2025-01-01T00:00:00Z']), + ...seasonEpisodes(2, ['2026-01-01T00:00:00Z']), + ]; + const cache = new ArrLookupCache(); + + const first = await callSeasonRank(episodes, 2, { + arrLookupCache: cache, + }); + expect(first.response).toBe(1); + + // Same run: an episodeFileRank evaluation reuses the cached maps, so + // the second SonarrApi instance never fetches episodes. + const collectionMedia = createCollectionMedia('episode'); + collectionMedia.collection.sonarrSettingsId = 1; + mockMediaServer.getMetadata.mockResolvedValue( + createMediaItem({ type: 'show' }), + ); + const series = createSonarrSeries({ id: 7, seasons: [] }); + const secondApi = mockSonarrApi(series); + jest.spyOn(secondApi, 'getEpisodes').mockResolvedValue(episodes); + + const response = await sonarrGetterService.get( + 32, + createMediaItem({ + type: 'episode', + index: 1, + parentIndex: 2, + parentId: 'season-2', + grandparentId: 'show-1', + }), + 'episode', + createRulesDto({ + collection: collectionMedia.collection, + dataType: 'episode', + }), + undefined, + cache, + ); + + expect(response).toBe(1); + expect(secondApi.getEpisodes).not.toHaveBeenCalled(); + }); + }); + const mockSonarrApi = (series?: SonarrSeries) => { const mockedSonarrApi = new SonarrApi( { url: 'http://localhost:8989', apiKey: 'test' }, diff --git a/apps/server/src/modules/rules/getter/sonarr-getter.service.ts b/apps/server/src/modules/rules/getter/sonarr-getter.service.ts index f20e74058..62fa2f5cf 100644 --- a/apps/server/src/modules/rules/getter/sonarr-getter.service.ts +++ b/apps/server/src/modules/rules/getter/sonarr-getter.service.ts @@ -282,6 +282,100 @@ export class SonarrGetterService { return showEpisodesPromise; }; + // Rank maps for episodeFileRank / seasonFileRank. Identical for every + // item of the show within a run, so cache them - otherwise a long + // daily series re-sorts the pool once per item. The air-date cutoff is + // captured by the first build per show, so every item of the show + // ranks against one consistent cutoff within a run. The airDate map + // backs the daily-series fallback (Plex items with a date but no + // episode number); the season map ranks seasons by their newest + // downloaded episode. + const buildRankMaps = async (): Promise< + | { + rankByEpisode: Map; + rankByAirDate: Map; + rankBySeason: Map; + } + | undefined + > => { + const episodes = await getShowEpisodes(); + if (episodes === undefined) { + return undefined; + } + + const nowMs = Date.now(); + const pool = episodes + .map((e) => { + // Sonarr emits `'0001-01-01T00:00:00Z'` as the .NET null-date + // sentinel (see the `showResponse.added` checks above). It + // parses to a finite very-negative ms and would otherwise sneak + // into the pool with a bogus year-1 air date. + const airMs = + e.airDateUtc && e.airDateUtc !== '0001-01-01T00:00:00Z' + ? new Date(e.airDateUtc).getTime() + : NaN; + return { + seasonNumber: e.seasonNumber, + episodeNumber: e.episodeNumber, + hasFile: e.hasFile, + airMs, + // Sonarr ships `airDate` as the broadcast-day in the show's local + // calendar (YYYY-MM-DD). Plex's `originallyAvailableAt` parses to the + // same calendar date via ISO date-only semantics, so keying on the + // string aligns both sides without UTC-day math (which would slip a + // day for any primetime broadcast outside UTC). + airDayKey: + e.airDate && e.airDate !== '0001-01-01' ? e.airDate : null, + }; + }) + .filter( + (e) => + e.hasFile === true && + e.seasonNumber > 0 && + Number.isFinite(e.airMs) && + e.airMs <= nowMs, + ); + + pool.sort((a, b) => { + if (a.airMs !== b.airMs) return b.airMs - a.airMs; + if (b.seasonNumber !== a.seasonNumber) { + return b.seasonNumber - a.seasonNumber; + } + return b.episodeNumber - a.episodeNumber; + }); + + const rankByEpisode = new Map(); + const rankByAirDate = new Map(); + const rankBySeason = new Map(); + for (let i = 0; i < pool.length; i++) { + const e = pool[i]; + const rank = i + 1; + rankByEpisode.set(`${e.seasonNumber}:${e.episodeNumber}`, rank); + // First-wins on same-day collisions: the newer episode of a + // same-day double already holds the slot, which is the + // conservative (keep) outcome when a daily-series Plex item + // carries only the date. + if (e.airDayKey !== null && !rankByAirDate.has(e.airDayKey)) { + rankByAirDate.set(e.airDayKey, rank); + } + // Seasons rank in order of first appearance in the newest-first + // pool, i.e. by the air date of their newest downloaded episode. + if (!rankBySeason.has(e.seasonNumber)) { + rankBySeason.set(e.seasonNumber, rankBySeason.size + 1); + } + } + return { rankByEpisode, rankByAirDate, rankBySeason }; + }; + + const getRankMaps = () => + arrLookupCache + ? arrLookupCache.memoize( + `sonarr:${settingsId}:rank-maps:${showResponse.id}`, + buildRankMaps, + (maps) => maps === undefined, + ) + : buildRankMaps(); + switch (prop.name) { case 'addDate': { return showResponse.added && @@ -503,6 +597,24 @@ export class SonarrGetterService { showResponse.statistics.episodeFileCount : null; } + case 'seasonFileRank': { + // Rank a season within its show by the air date of its newest + // downloaded episode (newest season = 1). Seasons without any + // downloaded episode take no rank slot; specials (S00) are + // excluded. Out-of-pool seasons get rank `null` so the comparator + // stays fail-closed. Pair with a scope filter (`Sonarr.seriesId`) + // to avoid library-wide application. + if (dataType !== 'season') { + return null; + } + + const rankMaps = await getRankMaps(); + if (rankMaps === undefined) { + return undefined; + } + + return rankMaps.rankBySeason.get(seasonRatingKey) ?? null; + } case 'episodeFileRank': { // Rank an episode within its show by air date (newest = 1) among // the episodes currently on disk. Pool requires `hasFile === true` @@ -514,94 +626,7 @@ export class SonarrGetterService { return null; } - // Air-date cutoff for the pool. Only the first build per show is - // cached, so every episode of the show ranks against one - // consistent cutoff within a run. - const nowMs = Date.now(); - - // The rank maps are identical for every episode of the show within - // a run, so cache them - otherwise a long daily series re-sorts - // the pool once per episode. The airDate map backs the - // daily-series fallback (Plex items with a date but no episode - // number). - const buildRankMaps = async (): Promise< - | { - rankByEpisode: Map; - rankByAirDate: Map; - } - | undefined - > => { - const episodes = await getShowEpisodes(); - if (episodes === undefined) { - return undefined; - } - - const pool = episodes - .map((e) => { - // Sonarr emits `'0001-01-01T00:00:00Z'` as the .NET - // null-date sentinel (see the `showResponse.added` checks - // above). It parses to a finite very-negative ms and would - // otherwise sneak into the pool with a bogus year-1 air - // date. - const airMs = - e.airDateUtc && e.airDateUtc !== '0001-01-01T00:00:00Z' - ? new Date(e.airDateUtc).getTime() - : NaN; - return { - seasonNumber: e.seasonNumber, - episodeNumber: e.episodeNumber, - hasFile: e.hasFile, - airMs, - // Sonarr ships `airDate` as the broadcast-day in the show's local - // calendar (YYYY-MM-DD). Plex's `originallyAvailableAt` parses to the - // same calendar date via ISO date-only semantics, so keying on the - // string aligns both sides without UTC-day math (which would slip a - // day for any primetime broadcast outside UTC). - airDayKey: - e.airDate && e.airDate !== '0001-01-01' ? e.airDate : null, - }; - }) - .filter( - (e) => - e.hasFile === true && - e.seasonNumber > 0 && - Number.isFinite(e.airMs) && - e.airMs <= nowMs, - ); - - pool.sort((a, b) => { - if (a.airMs !== b.airMs) return b.airMs - a.airMs; - if (b.seasonNumber !== a.seasonNumber) { - return b.seasonNumber - a.seasonNumber; - } - return b.episodeNumber - a.episodeNumber; - }); - - const rankByEpisode = new Map(); - const rankByAirDate = new Map(); - for (let i = 0; i < pool.length; i++) { - const e = pool[i]; - const rank = i + 1; - rankByEpisode.set(`${e.seasonNumber}:${e.episodeNumber}`, rank); - // First-wins on same-day collisions: the newer episode of a - // same-day double already holds the slot, which is the - // conservative (keep) outcome when a daily-series Plex item - // carries only the date. - if (e.airDayKey !== null && !rankByAirDate.has(e.airDayKey)) { - rankByAirDate.set(e.airDayKey, rank); - } - } - return { rankByEpisode, rankByAirDate }; - }; - - const rankMaps = await (arrLookupCache - ? arrLookupCache.memoize( - `sonarr:${settingsId}:episode-rank-map:${showResponse.id}`, - buildRankMaps, - (maps) => maps === undefined, - ) - : buildRankMaps()); - + const rankMaps = await getRankMaps(); if (rankMaps === undefined) { return undefined; }