Files
seerr/server/lib/availabilitySync.test.ts

1171 lines
32 KiB
TypeScript

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 TheMovieDb from '@server/api/themoviedb';
import type {
TmdbTvDetails,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
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,
});
// --- Mock TheMovieDb ---
let getTvShowImpl: (args: {
tvId: number;
language?: string;
}) => Promise<TmdbTvDetails> = async () => fakeTmdbShow(1);
let getShowByTvdbIdImpl: (args: {
tvdbId: number;
language?: string;
}) => Promise<TmdbTvDetails> = async () => fakeTmdbShow(1);
Object.defineProperty(TheMovieDb.prototype, 'getTvShow', {
get() {
return async (args: { tvId: number; language?: string }) =>
getTvShowImpl(args);
},
set() {},
configurable: true,
});
Object.defineProperty(TheMovieDb.prototype, 'getShowByTvdbId', {
get() {
return async (args: { tvdbId: number; language?: string }) =>
getShowByTvdbIdImpl(args);
},
set() {},
configurable: true,
});
// --- Helpers ---
function fakeTmdbShow(
tmdbId: number,
seasons: TmdbTvSeasonResult[] = [
{
id: 1,
air_date: '2024-01-01',
episode_count: 10,
name: 'Season 1',
overview: '',
season_number: 1,
},
]
): TmdbTvDetails {
return {
id: tmdbId,
content_ratings: { results: [] },
created_by: [],
episode_run_time: [],
first_air_date: '2024-01-01',
genres: [],
homepage: '',
in_production: false,
languages: ['en'],
last_air_date: '2024-01-01',
name: 'Test Show',
networks: [],
number_of_episodes: 10,
number_of_seasons: seasons.length,
origin_country: ['US'],
original_language: 'en',
original_name: 'Test Show',
overview: '',
popularity: 0,
production_companies: [],
production_countries: [],
spoken_languages: [],
seasons,
status: 'Ended',
type: 'Scripted',
vote_average: 0,
vote_count: 0,
aggregate_credits: { cast: [] },
credits: { crew: [] },
external_ids: {},
keywords: { results: [] },
videos: { results: [] },
};
}
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: 10,
episodeCount: 10,
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');
};
getTvShowImpl = async ({ tvId }) =>
fakeTmdbShow(
tvId,
Array.from({ length: 4 }, (_, i) => ({
id: i + 1,
air_date: '2024-01-01',
episode_count: 10,
name: `Season ${i + 1}`,
overview: '',
season_number: i + 1,
}))
);
getShowByTvdbIdImpl = async ({ tvdbId }) =>
fakeTmdbShow(
tvdbId,
Array.from({ length: 4 }, (_, i) => ({
id: i + 1,
air_date: '2024-01-01',
episode_count: 10,
name: `Season ${i + 1}`,
overview: '',
season_number: i + 1,
}))
);
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(10);
}
return [];
};
getSeriesByIdImpl = async (id: number) => {
if (id === 100) {
return {
tvdbId: 73255,
id: 100,
title: 'House',
titleSlug: 'house',
monitored: true,
statistics: {
episodeFileCount: 21,
totalEpisodeCount: 10,
episodeCount: 10,
percentOfEpisodes: 100,
sizeOnDisk: 0,
seasonCount: 8,
},
seasons: fakeSonarrSeasons(8, { 6: 10 }),
} 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'
);
});
it('should mark show as PARTIALLY_AVAILABLE when some seasons are available and some are unknown', async () => {
configureJellyfin();
configureSonarr([{ syncEnabled: true }]);
const mediaRepository = getRepository(Media);
const media = new Media();
media.tmdbId = 1412;
media.mediaType = MediaType.TV;
media.status = MediaStatus.AVAILABLE;
media.jellyfinMediaId = 'jellyfin-partial-id';
media.externalServiceId = 103;
media.seasons = [
new Season({
seasonNumber: 1,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 2,
status: MediaStatus.AVAILABLE,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 3,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}),
new Season({
seasonNumber: 4,
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}),
];
await mediaRepository.save(media);
getItemDataImpl = async (id: string) => {
if (id === 'jellyfin-partial-id') {
return fakeJellyfinShow('jellyfin-partial-id', '1412');
}
return undefined;
};
getSeasonsImpl = async (seriesID: string) => {
if (seriesID === 'jellyfin-partial-id') {
return [
fakeJellyfinSeason(1, 'jellyfin-partial-s1-id'),
fakeJellyfinSeason(2, 'jellyfin-partial-s2-id'),
];
}
return [];
};
getEpisodesImpl = async (_seriesID: string, seasonID: string) => {
if (seasonID === 'jellyfin-partial-s1-id') {
return fakeJellyfinEpisodes(10);
}
if (seasonID === 'jellyfin-partial-s2-id') {
return fakeJellyfinEpisodes(10);
}
return [];
};
getSeriesByIdImpl = async (id: number) => {
if (id === 103) {
return {
tvdbId: 99997,
id: 103,
title: 'Partial Show',
titleSlug: 'partial-show',
monitored: true,
statistics: {
episodeFileCount: 20,
totalEpisodeCount: 40,
episodeCount: 40,
percentOfEpisodes: 50,
sizeOnDisk: 0,
seasonCount: 4,
},
seasons: fakeSonarrSeasons(4, { 1: 10, 2: 10 }),
} as unknown as SonarrSeries;
}
throw new Error('404');
};
await availabilitySync.run();
const updated = await mediaRepository.findOneOrFail({
where: { tmdbId: 1412 },
relations: ['seasons'],
});
assert.strictEqual(
updated.status,
MediaStatus.PARTIALLY_AVAILABLE,
'Show should be PARTIALLY_AVAILABLE when some seasons are available and some are unknown'
);
});
});
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'
);
});
});
});