mirror of
https://github.com/seerr-team/seerr.git
synced 2026-06-15 11:59:11 -04:00
fix: availability sync demotion and orphan season rollup edge cases (#3148)
This commit is contained in:
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user