mirror of
https://github.com/seerr-team/seerr.git
synced 2026-05-25 01:05:18 -04:00
fix(availability-sync): detect deleted seasons when media server retains empty season metadata (#2850)
This commit is contained in:
962
server/lib/availabilitySync.test.ts
Normal file
962
server/lib/availabilitySync.test.ts
Normal file
@@ -0,0 +1,962 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { beforeEach, describe, it } from 'node:test';
|
||||
|
||||
import type {
|
||||
JellyfinLibraryItem,
|
||||
JellyfinLibraryItemExtended,
|
||||
} from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import type { PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
|
||||
// --- Mock JellyfinAPI ---
|
||||
let getSystemInfoImpl: () => Promise<Record<string, unknown>> = async () => ({
|
||||
ServerName: 'Test',
|
||||
});
|
||||
let getItemDataImpl: (
|
||||
id: string
|
||||
) => Promise<JellyfinLibraryItemExtended | undefined> = async () => undefined;
|
||||
let getSeasonsImpl: (
|
||||
seriesID: string
|
||||
) => Promise<JellyfinLibraryItem[]> = async () => [];
|
||||
let getEpisodesImpl: (
|
||||
seriesID: string,
|
||||
seasonID: string
|
||||
) => Promise<JellyfinLibraryItem[]> = async () => [];
|
||||
|
||||
Object.defineProperty(JellyfinAPI.prototype, 'getSystemInfo', {
|
||||
get() {
|
||||
return async () => getSystemInfoImpl();
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(JellyfinAPI.prototype, 'getItemData', {
|
||||
get() {
|
||||
return async (id: string) => getItemDataImpl(id);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(JellyfinAPI.prototype, 'getSeasons', {
|
||||
get() {
|
||||
return async (seriesID: string) => getSeasonsImpl(seriesID);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(JellyfinAPI.prototype, 'getEpisodes', {
|
||||
get() {
|
||||
return async (seriesID: string, seasonID: string) =>
|
||||
getEpisodesImpl(seriesID, seasonID);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(JellyfinAPI.prototype, 'setUserId', {
|
||||
get() {
|
||||
return () => {};
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// --- Mock PlexAPI ---
|
||||
let getMetadataImpl: (
|
||||
key: string,
|
||||
options?: { includeChildren?: boolean }
|
||||
) => Promise<PlexMetadata> = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
let getChildrenMetadataImpl: (
|
||||
key: string
|
||||
) => Promise<PlexMetadata[]> = async () => [];
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
get() {
|
||||
return async (key: string, options?: { includeChildren?: boolean }) =>
|
||||
getMetadataImpl(key, options);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getChildrenMetadataImpl(key);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// --- Mock SonarrAPI ---
|
||||
let getSeriesByIdImpl: (id: number) => Promise<SonarrSeries> = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
Object.defineProperty(SonarrAPI.prototype, 'getSeriesById', {
|
||||
get() {
|
||||
return async (id: number) => getSeriesByIdImpl(id);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
|
||||
setupTestDb();
|
||||
|
||||
function configureSonarr(overrides: Partial<SonarrSettings>[] = [{}]): void {
|
||||
const settings = getSettings();
|
||||
settings.sonarr = overrides.map((o, i) => ({
|
||||
id: i,
|
||||
name: `Sonarr ${i}`,
|
||||
hostname: 'localhost',
|
||||
port: 8989,
|
||||
apiKey: 'test-key',
|
||||
baseUrl: '',
|
||||
useSsl: false,
|
||||
activeProfileId: 1,
|
||||
activeDirectory: '/tv',
|
||||
activeLanguageProfileId: 1,
|
||||
activeAnimeProfileId: undefined,
|
||||
activeAnimeDirectory: '',
|
||||
activeAnimeLanguageProfileId: undefined,
|
||||
animeTags: [],
|
||||
is4k: false,
|
||||
enableSeasonFolders: true,
|
||||
tags: [],
|
||||
isDefault: i === 0,
|
||||
syncEnabled: true,
|
||||
preventSearch: false,
|
||||
externalUrl: '',
|
||||
...o,
|
||||
})) as SonarrSettings[];
|
||||
settings.radarr = [];
|
||||
}
|
||||
|
||||
function configureJellyfin(): void {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||
settings.jellyfin = {
|
||||
...settings.jellyfin,
|
||||
apiKey: 'test-api-key',
|
||||
};
|
||||
}
|
||||
|
||||
function configurePlex(): void {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||
}
|
||||
|
||||
// --- Jellyfin helpers ---
|
||||
function fakeJellyfinSeason(
|
||||
seasonNumber: number,
|
||||
id?: string
|
||||
): JellyfinLibraryItem {
|
||||
return {
|
||||
Name: `Season ${seasonNumber}`,
|
||||
Id: id ?? `jellyfin-season-${seasonNumber}-id`,
|
||||
IndexNumber: seasonNumber,
|
||||
Type: 'Season' as const,
|
||||
HasSubtitles: false,
|
||||
LocationType: 'FileSystem' as const,
|
||||
MediaType: 'Video',
|
||||
};
|
||||
}
|
||||
|
||||
function fakeJellyfinEpisodes(count: number): JellyfinLibraryItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
Name: `Episode ${i + 1}`,
|
||||
Id: `ep-${i}`,
|
||||
IndexNumber: i + 1,
|
||||
Type: 'Episode' as const,
|
||||
HasSubtitles: false,
|
||||
LocationType: 'FileSystem' as const,
|
||||
MediaType: 'Video',
|
||||
}));
|
||||
}
|
||||
|
||||
function fakeJellyfinShow(
|
||||
id: string,
|
||||
tmdbId: string
|
||||
): JellyfinLibraryItemExtended {
|
||||
return {
|
||||
Name: 'Test Show',
|
||||
Id: id,
|
||||
Type: 'Series',
|
||||
HasSubtitles: false,
|
||||
LocationType: 'FileSystem',
|
||||
MediaType: 'Video',
|
||||
ProviderIds: { Tmdb: tmdbId },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Plex helpers ---
|
||||
function fakePlexSeason(seasonNumber: number, ratingKey: string): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
guid: `plex://season/${ratingKey}`,
|
||||
type: 'season',
|
||||
title: `Season ${seasonNumber}`,
|
||||
Guid: [],
|
||||
index: seasonNumber,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 0,
|
||||
updatedAt: 0,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexEpisodes(count: number): PlexMetadata[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
ratingKey: `ep-${i}`,
|
||||
guid: `plex://episode/ep-${i}`,
|
||||
type: 'movie' as const,
|
||||
title: `Episode ${i + 1}`,
|
||||
Guid: [],
|
||||
index: i + 1,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 0,
|
||||
updatedAt: 0,
|
||||
Media: [
|
||||
{
|
||||
id: i,
|
||||
duration: 2400,
|
||||
bitrate: 4000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
aspectRatio: 1.78,
|
||||
audioChannels: 2,
|
||||
audioCodec: 'aac',
|
||||
videoCodec: 'h264',
|
||||
videoResolution: '1080',
|
||||
container: 'mkv',
|
||||
videoFrameRate: '24p',
|
||||
videoProfile: 'high',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function fakePlexShow(ratingKey: string): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
guid: `plex://show/${ratingKey}`,
|
||||
type: 'show',
|
||||
title: 'Test Show',
|
||||
Guid: [],
|
||||
index: 1,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 0,
|
||||
updatedAt: 0,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
// --- Sonarr helpers ---
|
||||
function fakeSonarrSeasons(
|
||||
totalSeasons: number,
|
||||
seasonsWithFiles: Record<number, number>
|
||||
): SonarrSeason[] {
|
||||
return Array.from({ length: totalSeasons }, (_, i) => ({
|
||||
seasonNumber: i + 1,
|
||||
monitored: true,
|
||||
statistics: {
|
||||
episodeFileCount: seasonsWithFiles[i + 1] ?? 0,
|
||||
totalEpisodeCount: 22,
|
||||
episodeCount: 22,
|
||||
percentOfEpisodes: seasonsWithFiles[i + 1] ? 100 : 0,
|
||||
sizeOnDisk: seasonsWithFiles[i + 1] ? 7516192768 : 0,
|
||||
previousAiring: undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('AvailabilitySync', () => {
|
||||
beforeEach(async () => {
|
||||
getSystemInfoImpl = async () => ({ ServerName: 'Test' });
|
||||
getItemDataImpl = async () => undefined;
|
||||
getSeasonsImpl = async () => [];
|
||||
getEpisodesImpl = async () => [];
|
||||
getMetadataImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
getChildrenMetadataImpl = async () => [];
|
||||
getSeriesByIdImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const existingAdmin = await userRepository.findOne({ where: { id: 1 } });
|
||||
if (!existingAdmin) {
|
||||
const admin = new User();
|
||||
admin.id = 1;
|
||||
admin.plexToken = 'test-plex-token';
|
||||
admin.jellyfinUserId = 'admin-user-id';
|
||||
admin.jellyfinDeviceId = 'admin-device-id';
|
||||
admin.email = 'admin@test.com';
|
||||
admin.permissions = 2;
|
||||
admin.username = 'admin';
|
||||
await userRepository.save(admin);
|
||||
}
|
||||
});
|
||||
|
||||
describe('TV season availability - Jellyfin', () => {
|
||||
it('should mark deleted seasons as DELETED when only some seasons exist in Jellyfin and Sonarr', async () => {
|
||||
configureJellyfin();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 1408;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.jellyfinMediaId = 'jellyfin-house-id';
|
||||
media.externalServiceId = 100;
|
||||
media.seasons = [];
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
media.seasons.push(
|
||||
new Season({
|
||||
seasonNumber: i,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getItemDataImpl = async (id: string) => {
|
||||
if (id === 'jellyfin-house-id') {
|
||||
return fakeJellyfinShow('jellyfin-house-id', '1408');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
getSeasonsImpl = async (seriesID: string) => {
|
||||
if (seriesID === 'jellyfin-house-id') {
|
||||
return [fakeJellyfinSeason(6)];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
|
||||
if (seasonID === 'jellyfin-season-6-id') {
|
||||
return fakeJellyfinEpisodes(21);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async (id: number) => {
|
||||
if (id === 100) {
|
||||
return {
|
||||
tvdbId: 73255,
|
||||
id: 100,
|
||||
title: 'House',
|
||||
titleSlug: 'house',
|
||||
monitored: true,
|
||||
statistics: {
|
||||
episodeFileCount: 21,
|
||||
totalEpisodeCount: 177,
|
||||
episodeCount: 177,
|
||||
percentOfEpisodes: 11.86,
|
||||
sizeOnDisk: 0,
|
||||
seasonCount: 8,
|
||||
},
|
||||
seasons: fakeSonarrSeasons(8, { 6: 21 }),
|
||||
} as unknown as SonarrSeries;
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 1408 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s6 = updated.seasons.find((s) => s.seasonNumber === 6);
|
||||
assert.strictEqual(
|
||||
s6?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 6 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
if (season.seasonNumber !== 6) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should be DELETED but was ${season.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should still mark deleted seasons when externalServiceId is null (no Sonarr link)', async () => {
|
||||
configureJellyfin();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 1409;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.jellyfinMediaId = 'jellyfin-house2-id';
|
||||
media.externalServiceId = undefined as unknown as number;
|
||||
media.seasons = [];
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
media.seasons.push(
|
||||
new Season({
|
||||
seasonNumber: i,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getItemDataImpl = async (id: string) => {
|
||||
if (id === 'jellyfin-house2-id') {
|
||||
return fakeJellyfinShow('jellyfin-house2-id', '1409');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
getSeasonsImpl = async (seriesID: string) => {
|
||||
if (seriesID === 'jellyfin-house2-id') {
|
||||
return [fakeJellyfinSeason(6, 'jellyfin-house2-s6-id')];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
|
||||
if (seasonID === 'jellyfin-house2-s6-id') {
|
||||
return fakeJellyfinEpisodes(21);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 1409 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s6 = updated.seasons.find((s) => s.seasonNumber === 6);
|
||||
assert.strictEqual(
|
||||
s6?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 6 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
if (season.seasonNumber !== 6) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should be DELETED but was ${season.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark deleted seasons even when Jellyfin returns empty season metadata entries (real-world behavior)', async () => {
|
||||
configureJellyfin();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 1410;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.jellyfinMediaId = 'jellyfin-house3-id';
|
||||
media.externalServiceId = 101;
|
||||
media.seasons = [];
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
media.seasons.push(
|
||||
new Season({
|
||||
seasonNumber: i,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getItemDataImpl = async (id: string) => {
|
||||
if (id === 'jellyfin-house3-id') {
|
||||
return fakeJellyfinShow('jellyfin-house3-id', '1410');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// MOCK REAL BEHAVIOR: Jellyfin returns ALL 8 season metadata entries
|
||||
// even though only season 6 has actual episode files.
|
||||
getSeasonsImpl = async (seriesID: string) => {
|
||||
if (seriesID === 'jellyfin-house3-id') {
|
||||
return Array.from({ length: 8 }, (_, i) =>
|
||||
fakeJellyfinSeason(i + 1, `jellyfin-house3-s${i + 1}-id`)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Only season 6 has actual episodes
|
||||
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
|
||||
if (seasonID === 'jellyfin-house3-s6-id') {
|
||||
return fakeJellyfinEpisodes(21);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async (id: number) => {
|
||||
if (id === 101) {
|
||||
return {
|
||||
tvdbId: 73255,
|
||||
id: 101,
|
||||
title: 'House',
|
||||
titleSlug: 'house',
|
||||
monitored: true,
|
||||
statistics: {
|
||||
episodeFileCount: 21,
|
||||
totalEpisodeCount: 177,
|
||||
episodeCount: 177,
|
||||
percentOfEpisodes: 11.86,
|
||||
sizeOnDisk: 0,
|
||||
seasonCount: 8,
|
||||
},
|
||||
seasons: fakeSonarrSeasons(8, { 6: 21 }),
|
||||
} as unknown as SonarrSeries;
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 1410 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s6 = updated.seasons.find((s) => s.seasonNumber === 6);
|
||||
assert.strictEqual(
|
||||
s6?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 6 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
if (season.seasonNumber !== 6) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should be DELETED but was ${season.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should assume season exists when getEpisodes fails (safe fallback)', async () => {
|
||||
configureJellyfin();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 1411;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.jellyfinMediaId = 'jellyfin-house4-id';
|
||||
media.externalServiceId = 102;
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getItemDataImpl = async (id: string) => {
|
||||
if (id === 'jellyfin-house4-id') {
|
||||
return fakeJellyfinShow('jellyfin-house4-id', '1411');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
getSeasonsImpl = async (seriesID: string) => {
|
||||
if (seriesID === 'jellyfin-house4-id') {
|
||||
return [fakeJellyfinSeason(1, 'jellyfin-house4-s1-id')];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getEpisodesImpl = async () => {
|
||||
throw new Error('Connection refused');
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async (id: number) => {
|
||||
if (id === 102) {
|
||||
return {
|
||||
tvdbId: 99999,
|
||||
id: 102,
|
||||
title: 'House 4',
|
||||
titleSlug: 'house-4',
|
||||
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: 1411 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
updated.seasons[0].status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season should remain AVAILABLE when getEpisodes fails'
|
||||
);
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Show should remain AVAILABLE when getEpisodes fails'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TV season availability - Plex', () => {
|
||||
it('should mark deleted seasons when Plex returns empty season metadata entries', async () => {
|
||||
configurePlex();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 2000;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-house-rk';
|
||||
media.externalServiceId = 200;
|
||||
media.seasons = [];
|
||||
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
media.seasons.push(
|
||||
new Season({
|
||||
seasonNumber: i,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house-rk') {
|
||||
return fakePlexShow('plex-house-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
// Plex returns ALL 8 season metadata entries,
|
||||
// but only season 6 has episode files
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house-rk') {
|
||||
return Array.from({ length: 8 }, (_, i) =>
|
||||
fakePlexSeason(i + 1, `plex-house-s${i + 1}-rk`)
|
||||
);
|
||||
}
|
||||
if (key === 'plex-house-s6-rk') {
|
||||
return fakePlexEpisodes(21);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Sonarr: only season 6 has files
|
||||
getSeriesByIdImpl = async (id: number) => {
|
||||
if (id === 200) {
|
||||
return {
|
||||
tvdbId: 73255,
|
||||
id: 200,
|
||||
title: 'House',
|
||||
titleSlug: 'house',
|
||||
monitored: true,
|
||||
statistics: {
|
||||
episodeFileCount: 21,
|
||||
totalEpisodeCount: 177,
|
||||
episodeCount: 177,
|
||||
percentOfEpisodes: 11.86,
|
||||
sizeOnDisk: 0,
|
||||
seasonCount: 8,
|
||||
},
|
||||
seasons: fakeSonarrSeasons(8, { 6: 21 }),
|
||||
} as unknown as SonarrSeries;
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2000 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s6 = updated.seasons.find((s) => s.seasonNumber === 6);
|
||||
assert.strictEqual(
|
||||
s6?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 6 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
if (season.seasonNumber !== 6) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should be DELETED but was ${season.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should assume season exists when getChildrenMetadata fails for episodes (safe fallback)', async () => {
|
||||
configurePlex();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 2001;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-house2-rk';
|
||||
media.externalServiceId = 201;
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house2-rk') {
|
||||
return fakePlexShow('plex-house2-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house2-rk') {
|
||||
return [fakePlexSeason(1, 'plex-house2-s1-rk')];
|
||||
}
|
||||
throw new Error('Connection refused');
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async (id: number) => {
|
||||
if (id === 201) {
|
||||
return {
|
||||
tvdbId: 99999,
|
||||
id: 201,
|
||||
title: 'House 2',
|
||||
titleSlug: 'house-2',
|
||||
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: 2001 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
updated.seasons[0].status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season should remain AVAILABLE when getChildrenMetadata fails'
|
||||
);
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Show should remain AVAILABLE when getChildrenMetadata fails'
|
||||
);
|
||||
});
|
||||
|
||||
it('should mark deleted seasons when only some seasons have episodes in Plex (no Sonarr link)', async () => {
|
||||
configurePlex();
|
||||
configureSonarr([{ syncEnabled: true }]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 2002;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-house3-rk';
|
||||
media.externalServiceId = undefined as unknown as number;
|
||||
media.seasons = [];
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
media.seasons.push(
|
||||
new Season({
|
||||
seasonNumber: i,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house3-rk') {
|
||||
return fakePlexShow('plex-house3-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-house3-rk') {
|
||||
return Array.from({ length: 4 }, (_, i) =>
|
||||
fakePlexSeason(i + 1, `plex-house3-s${i + 1}-rk`)
|
||||
);
|
||||
}
|
||||
// Only seasons 2 and 4 have episodes
|
||||
if (key === 'plex-house3-s2-rk' || key === 'plex-house3-s4-rk') {
|
||||
return fakePlexEpisodes(10);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
getSeriesByIdImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2002 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
|
||||
const s4 = updated.seasons.find((s) => s.seasonNumber === 4);
|
||||
assert.strictEqual(
|
||||
s2?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 2 should remain AVAILABLE'
|
||||
);
|
||||
assert.strictEqual(
|
||||
s4?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 4 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
|
||||
const s3 = updated.seasons.find((s) => s.seasonNumber === 3);
|
||||
assert.strictEqual(
|
||||
s1?.status,
|
||||
MediaStatus.DELETED,
|
||||
'Season 1 should be DELETED'
|
||||
);
|
||||
assert.strictEqual(
|
||||
s3?.status,
|
||||
MediaStatus.DELETED,
|
||||
'Season 3 should be DELETED'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,11 @@ class AvailabilitySync {
|
||||
public running = false;
|
||||
private plexClient: PlexAPI;
|
||||
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
||||
private plexEpisodeExistsCache: Record<string, boolean>;
|
||||
|
||||
private jellyfinClient: JellyfinAPI;
|
||||
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
|
||||
private jellyfinEpisodeExistsCache: Record<string, boolean>;
|
||||
|
||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
||||
private radarrServers: RadarrSettings[];
|
||||
@@ -34,7 +36,9 @@ class AvailabilitySync {
|
||||
const mediaServerType = getSettings().main.mediaServerType;
|
||||
this.running = true;
|
||||
this.plexSeasonsCache = {};
|
||||
this.plexEpisodeExistsCache = {};
|
||||
this.jellyfinSeasonsCache = {};
|
||||
this.jellyfinEpisodeExistsCache = {};
|
||||
this.sonarrSeasonsCache = {};
|
||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||
@@ -939,7 +943,6 @@ class AvailabilitySync {
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
let seasonExistsInPlex = false;
|
||||
|
||||
// Check each plex instance to see if the season exists
|
||||
let plexSeasons: PlexMetadata[] | undefined;
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
@@ -950,12 +953,33 @@ class AvailabilitySync {
|
||||
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
}
|
||||
|
||||
const seasonIsAvailable = plexSeasons?.find(
|
||||
const seasonMeta = plexSeasons?.find(
|
||||
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonIsAvailable) {
|
||||
seasonExistsInPlex = true;
|
||||
if (seasonMeta) {
|
||||
const cacheKey = seasonMeta.ratingKey;
|
||||
|
||||
if (cacheKey in this.plexEpisodeExistsCache) {
|
||||
seasonExistsInPlex = this.plexEpisodeExistsCache[cacheKey];
|
||||
} else {
|
||||
try {
|
||||
// Season metadata exists, but we need to verify it has actual
|
||||
// episode files. Plex can keep empty season entries.
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
seasonMeta.ratingKey
|
||||
);
|
||||
|
||||
seasonExistsInPlex =
|
||||
episodes?.some((episode) => episode.Media?.length > 0) ?? false;
|
||||
} catch {
|
||||
// If we can't fetch episodes, assume the season exists
|
||||
// to avoid false removal
|
||||
seasonExistsInPlex = true;
|
||||
}
|
||||
|
||||
this.plexEpisodeExistsCache[cacheKey] = seasonExistsInPlex;
|
||||
}
|
||||
}
|
||||
|
||||
return seasonExistsInPlex;
|
||||
@@ -1056,7 +1080,6 @@ class AvailabilitySync {
|
||||
const ratingKey4k = media.jellyfinMediaId4k;
|
||||
let seasonExistsInJellyfin = false;
|
||||
|
||||
// Check each jellyfin instance to see if the season exists
|
||||
let jellyfinSeasons: JellyfinLibraryItem[] | undefined;
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
@@ -1067,12 +1090,39 @@ class AvailabilitySync {
|
||||
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k];
|
||||
}
|
||||
|
||||
const seasonIsAvailable = jellyfinSeasons?.find(
|
||||
const seasonMeta = jellyfinSeasons?.find(
|
||||
(jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonIsAvailable) {
|
||||
seasonExistsInJellyfin = true;
|
||||
if (seasonMeta) {
|
||||
const seriesId = is4k ? ratingKey4k : ratingKey;
|
||||
|
||||
if (seriesId) {
|
||||
const cacheKey = `${seriesId}-${seasonMeta.Id}`;
|
||||
|
||||
if (cacheKey in this.jellyfinEpisodeExistsCache) {
|
||||
seasonExistsInJellyfin = this.jellyfinEpisodeExistsCache[cacheKey];
|
||||
} else {
|
||||
try {
|
||||
// Season metadata exists, but we need to verify it has actual
|
||||
// episode files. Jellyfin keeps season entries even after all
|
||||
// episodes are deleted. getEpisodes already filters out
|
||||
// virtual episodes.
|
||||
const episodes = await this.jellyfinClient.getEpisodes(
|
||||
seriesId,
|
||||
seasonMeta.Id
|
||||
);
|
||||
|
||||
seasonExistsInJellyfin = episodes.length > 0;
|
||||
} catch {
|
||||
// If we can't fetch episodes, assume the season exists
|
||||
// to avoid false removal
|
||||
seasonExistsInJellyfin = true;
|
||||
}
|
||||
|
||||
this.jellyfinEpisodeExistsCache[cacheKey] = seasonExistsInJellyfin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seasonExistsInJellyfin;
|
||||
|
||||
Reference in New Issue
Block a user