fix: availability sync demotion and orphan season rollup edge cases (#3148)

This commit is contained in:
fallenbagel
2026-06-12 06:04:36 +08:00
committed by GitHub
parent 0438710761
commit 784faa9f84
4 changed files with 478 additions and 23 deletions

View File

@@ -1167,4 +1167,286 @@ describe('AvailabilitySync', () => {
);
});
});
describe('specials season handling', () => {
const tmdbSeasonsWithSpecials = [
{
id: 100,
air_date: '2024-01-01',
episode_count: 3,
name: 'Specials',
overview: '',
season_number: 0,
},
{
id: 101,
air_date: '2024-01-01',
episode_count: 10,
name: 'Season 1',
overview: '',
season_number: 1,
},
];
it('should not demote an available show when only the specials season is missing (Jellyfin)', async () => {
configureJellyfin();
configureSonarr([{ syncEnabled: true }]);
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 13862;
media.mediaType = MediaType.TV;
media.status = MediaStatus.AVAILABLE;
media.jellyfinMediaId = 'jellyfin-shogun-id';
media.externalServiceId = 300;
media.seasons = [
new Season({
seasonNumber: 0,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getTvShowImpl = async () => fakeTmdbShow(13862, tmdbSeasonsWithSpecials);
getItemDataImpl = async (id: string) => {
if (id === 'jellyfin-shogun-id') {
return fakeJellyfinShow('jellyfin-shogun-id', '13862');
}
return undefined;
};
getSeasonsImpl = async (seriesID: string) => {
if (seriesID === 'jellyfin-shogun-id') {
return [fakeJellyfinSeason(1, 'jellyfin-shogun-s1-id')];
}
return [];
};
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jellyfin-shogun-s1-id') {
return fakeJellyfinEpisodes(10);
}
return [];
};
getSeriesByIdImpl = async (id: number) => {
if (id === 300) {
return {
tvdbId: 70814,
id: 300,
title: 'Shogun',
titleSlug: 'shogun',
monitored: true,
statistics: {
episodeFileCount: 10,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 100,
sizeOnDisk: 0,
seasonCount: 1,
},
seasons: fakeSonarrSeasons(1, { 1: 10 }),
} as unknown as SonarrSeries;
}
throw new Error('404');
};
await availabilitySync.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 13862 },
relations: ['seasons'],
});
assert.strictEqual(
updated.status,
MediaStatus.AVAILABLE,
'Show should stay AVAILABLE when only the specials season is missing'
);
});
it('should not demote an available show when only the specials season is missing (Plex)', async () => {
configurePlex();
configureSonarr([{ syncEnabled: true }]);
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 13863;
media.mediaType = MediaType.TV;
media.status = MediaStatus.AVAILABLE;
media.ratingKey = 'plex-shogun-rk';
media.externalServiceId = 301;
media.seasons = [
new Season({
seasonNumber: 0,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getTvShowImpl = async () => fakeTmdbShow(13863, tmdbSeasonsWithSpecials);
getMetadataImpl = async (key: string) => {
if (key === 'plex-shogun-rk') {
return fakePlexShow('plex-shogun-rk');
}
throw new Error('404');
};
getChildrenMetadataImpl = async (key: string) => {
if (key === 'plex-shogun-rk') {
return [fakePlexSeason(1, 'plex-shogun-s1-rk')];
}
if (key === 'plex-shogun-s1-rk') {
return fakePlexEpisodes(10);
}
return [];
};
getSeriesByIdImpl = async (id: number) => {
if (id === 301) {
return {
tvdbId: 70814,
id: 301,
title: 'Shogun',
titleSlug: 'shogun',
monitored: true,
statistics: {
episodeFileCount: 10,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 100,
sizeOnDisk: 0,
seasonCount: 1,
},
seasons: fakeSonarrSeasons(1, { 1: 10 }),
} as unknown as SonarrSeries;
}
throw new Error('404');
};
await availabilitySync.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 13863 },
relations: ['seasons'],
});
assert.strictEqual(
updated.status,
MediaStatus.AVAILABLE,
'Show should stay AVAILABLE when only the specials season is missing'
);
});
it('should mark a removed specials season as DELETED without demoting the show (Jellyfin)', async () => {
configureJellyfin();
configureSonarr([{ syncEnabled: true }]);
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 13864;
media.mediaType = MediaType.TV;
media.status = MediaStatus.AVAILABLE;
media.jellyfinMediaId = 'jellyfin-specials-id';
media.externalServiceId = 302;
media.seasons = [
new Season({
seasonNumber: 0,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getTvShowImpl = async () => fakeTmdbShow(13864, tmdbSeasonsWithSpecials);
getItemDataImpl = async (id: string) => {
if (id === 'jellyfin-specials-id') {
return fakeJellyfinShow('jellyfin-specials-id', '13864');
}
return undefined;
};
getSeasonsImpl = async (seriesID: string) => {
if (seriesID === 'jellyfin-specials-id') {
return [fakeJellyfinSeason(1, 'jellyfin-specials-s1-id')];
}
return [];
};
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jellyfin-specials-s1-id') {
return fakeJellyfinEpisodes(10);
}
return [];
};
getSeriesByIdImpl = async (id: number) => {
if (id === 302) {
return {
tvdbId: 70814,
id: 302,
title: 'Shogun',
titleSlug: 'shogun',
monitored: true,
statistics: {
episodeFileCount: 10,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 100,
sizeOnDisk: 0,
seasonCount: 1,
},
seasons: fakeSonarrSeasons(1, { 1: 10 }),
} as unknown as SonarrSeries;
}
throw new Error('404');
};
await availabilitySync.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 13864 },
relations: ['seasons'],
});
const specials = updated.seasons.find((s) => s.seasonNumber === 0);
assert.strictEqual(
specials?.status,
MediaStatus.DELETED,
'Removed specials season should be marked DELETED'
);
assert.strictEqual(
updated.status,
MediaStatus.AVAILABLE,
'Show should stay AVAILABLE when only specials were removed'
);
});
});
});

View File

@@ -379,6 +379,11 @@ class AvailabilitySync {
if (tvShow) {
// fill the finalSeasons and finalSeasons4k maps with false for missing seasons
media.seasons.forEach((season) => {
// Specials don't count towards availability (baseScanner skips them too)
// TODO: doesn't respect enableSpecialEpisodes; needs a shared predicate with baseScanner.ts
if (season.seasonNumber === 0) {
return;
}
if (
!finalSeasons.has(season.seasonNumber) &&
tvShow.seasons.find(
@@ -599,6 +604,8 @@ class AvailabilitySync {
);
// Retrieve the season keys to pass into our log
const seasonKeys = [...seasonsPendingRemoval.keys()];
// Specials can still be marked DELETED below, but shouldn't demote the show
const nonSpecialSeasonKeys = seasonKeys.filter((key) => key !== 0);
try {
for (const mediaSeason of media.seasons) {
@@ -613,12 +620,15 @@ class AvailabilitySync {
}
}
if (media[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) {
if (
nonSpecialSeasonKeys.length > 0 &&
media[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
media[is4k ? 'status4k' : 'status'] = MediaStatus.PARTIALLY_AVAILABLE;
logger.debug(
`Marking the ${
is4k ? '4K' : 'non-4K'
} show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season(s) [${seasonKeys}] was not found in any ${
} show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season(s) [${nonSpecialSeasonKeys}] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and ${
mediaServerType === MediaServerType.PLEX

View File

@@ -467,10 +467,25 @@ class BaseScanner<T> {
(s) => s.seasonNumber !== 0
);
const standardSeasonsForRollup = nonSpecialSeasons.filter(
(s) =>
(seasons.find((season) => season.seasonNumber === s.seasonNumber)
?.totalEpisodes ?? Infinity) > 0
// DB-only seasons block the rollup unless UNKNOWN (orphan placeholders
// can never be revisited by a scan and would pin the show forever).
const countsTowardsRollup = (
s: Season,
statusKey: 'status' | 'status4k'
): boolean => {
const scannedSeason = seasons.find(
(season) => season.seasonNumber === s.seasonNumber
);
if (scannedSeason) {
return scannedSeason.totalEpisodes > 0;
}
return s[statusKey] !== MediaStatus.UNKNOWN;
};
const standardSeasonsForRollup = nonSpecialSeasons.filter((s) =>
countsTowardsRollup(s, 'status')
);
const isAllStandardSeasonsAvailable =
standardSeasonsForRollup.length > 0 &&
@@ -478,10 +493,8 @@ class BaseScanner<T> {
(s) => s.status === MediaStatus.AVAILABLE
);
const seasons4kForRollup = nonSpecialSeasons.filter(
(s) =>
(seasons.find((season) => season.seasonNumber === s.seasonNumber)
?.totalEpisodes ?? Infinity) > 0
const seasons4kForRollup = nonSpecialSeasons.filter((s) =>
countsTowardsRollup(s, 'status4k')
);
const isAll4kSeasonsAvailable =
seasons4kForRollup.length > 0 &&
@@ -530,25 +543,20 @@ class BaseScanner<T> {
(s) => s.seasonNumber !== 0
);
const standardSeasonsForRollup = nonSpecialNewSeasons.filter(
const newSeasonsForRollup = nonSpecialNewSeasons.filter(
(s) =>
(seasons.find((season) => season.seasonNumber === s.seasonNumber)
?.totalEpisodes ?? Infinity) > 0
?.totalEpisodes ?? 0) > 0
);
const isAllStandardSeasonsAvailable =
standardSeasonsForRollup.length > 0 &&
standardSeasonsForRollup.every(
(s) => s.status === MediaStatus.AVAILABLE
);
newSeasonsForRollup.length > 0 &&
newSeasonsForRollup.every((s) => s.status === MediaStatus.AVAILABLE);
const seasons4kForRollup = nonSpecialNewSeasons.filter(
(s) =>
(seasons.find((season) => season.seasonNumber === s.seasonNumber)
?.totalEpisodes ?? Infinity) > 0
);
const isAll4kSeasonsAvailable =
seasons4kForRollup.length > 0 &&
seasons4kForRollup.every((s) => s.status4k === MediaStatus.AVAILABLE);
newSeasonsForRollup.length > 0 &&
newSeasonsForRollup.every(
(s) => s.status4k === MediaStatus.AVAILABLE
);
const newMedia = new Media({
mediaType: MediaType.TV,

View File

@@ -346,5 +346,160 @@ describe('Jellyfin Scanner', () => {
'Show should be AVAILABLE when all non-empty TMDB seasons are fully scanned, ignoring empty placeholder seasons'
);
});
it('should mark show as available when an orphan UNKNOWN season exists in the DB but not in TMDB', async () => {
configureJellyfinWithLibrary();
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 5001;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PARTIALLY_AVAILABLE;
media.jellyfinMediaId = 'jf-orphan-show-id';
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
// Not present in the TMDB season list below
new Season({
seasonNumber: 2,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getTvShowImpl = async () =>
fakeTmdbShow(5001, [
{
id: 1,
air_date: '2024-01-01',
episode_count: 10,
name: 'Season 1',
overview: '',
season_number: 1,
},
]);
getLibraryContentsImpl = async (id: string) => {
if (id === 'test-library-id') {
return [fakeJellyfinSeriesItem('jf-orphan-show-id')];
}
return [];
};
getItemDataImpl = async (id: string) => {
if (id === 'jf-orphan-show-id') {
return fakeJellyfinShowMetadata('jf-orphan-show-id', '5001');
}
return undefined;
};
getSeasonsImpl = async (seriesID: string) => {
if (seriesID === 'jf-orphan-show-id') {
return [fakeJellyfinSeason(1, 'jf-orphan-s1-id')];
}
return [];
};
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jf-orphan-s1-id') return fakeJellyfinEpisodes(10);
return [];
};
await jellyfinFullScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 5001 },
relations: ['seasons'],
});
assert.strictEqual(
updated.status,
MediaStatus.AVAILABLE,
'Show should be AVAILABLE when the only DB season missing from TMDB is an UNKNOWN orphan placeholder'
);
});
it('should keep show partially available when a season missing from TMDB was previously available', async () => {
configureJellyfinWithLibrary();
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 5002;
media.mediaType = MediaType.TV;
media.status = MediaStatus.PARTIALLY_AVAILABLE;
media.jellyfinMediaId = 'jf-deleted-show-id';
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 2,
status: MediaStatus.DELETED,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getTvShowImpl = async () =>
fakeTmdbShow(5002, [
{
id: 1,
air_date: '2024-01-01',
episode_count: 10,
name: 'Season 1',
overview: '',
season_number: 1,
},
]);
getLibraryContentsImpl = async (id: string) => {
if (id === 'test-library-id') {
return [fakeJellyfinSeriesItem('jf-deleted-show-id')];
}
return [];
};
getItemDataImpl = async (id: string) => {
if (id === 'jf-deleted-show-id') {
return fakeJellyfinShowMetadata('jf-deleted-show-id', '5002');
}
return undefined;
};
getSeasonsImpl = async (seriesID: string) => {
if (seriesID === 'jf-deleted-show-id') {
return [fakeJellyfinSeason(1, 'jf-deleted-s1-id')];
}
return [];
};
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jf-deleted-s1-id') return fakeJellyfinEpisodes(10);
return [];
};
await jellyfinFullScanner.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 5002 },
relations: ['seasons'],
});
assert.strictEqual(
updated.status,
MediaStatus.PARTIALLY_AVAILABLE,
'Show should stay PARTIALLY_AVAILABLE when a DELETED season is missing from the metadata provider'
);
});
});
});