mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-12 02:22:17 +00:00
fix(plex): handle shows split across 1080p and 4K libraries
This commit is contained in:
@@ -212,6 +212,27 @@ class Media {
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey4k?: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
transformer: {
|
||||
from: (value: string | null): string[] | null => {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
to: (value: string[] | null): string | null =>
|
||||
value == null ? null : JSON.stringify(value),
|
||||
},
|
||||
})
|
||||
public plexRatingKeys?: string[] | null;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public jellyfinMediaId?: string | null;
|
||||
|
||||
@@ -237,6 +258,36 @@ class Media {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public addPlexRatingKey(ratingKey?: string | null): boolean {
|
||||
if (!ratingKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = this.getPlexRatingKeys();
|
||||
if (keys.includes(ratingKey)) {
|
||||
if (this.plexRatingKeys != null) {
|
||||
return false;
|
||||
}
|
||||
this.plexRatingKeys = keys;
|
||||
return true;
|
||||
}
|
||||
|
||||
this.plexRatingKeys = [...keys, ratingKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
public getPlexRatingKeys(): string[] {
|
||||
if (this.plexRatingKeys) {
|
||||
return this.plexRatingKeys;
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
[this.ratingKey, this.ratingKey4k].filter((key): key is string => !!key)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public resetServiceData(is4k?: boolean): void {
|
||||
if (is4k === undefined || !is4k) {
|
||||
this.serviceId = null;
|
||||
@@ -252,6 +303,9 @@ class Media {
|
||||
this.ratingKey4k = null;
|
||||
this.jellyfinMediaId4k = null;
|
||||
}
|
||||
if (is4k === undefined) {
|
||||
this.plexRatingKeys = null;
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
|
||||
@@ -90,11 +90,27 @@ Object.defineProperty(JellyfinAPI.prototype, 'setUserId', {
|
||||
});
|
||||
|
||||
// --- Mock PlexAPI ---
|
||||
function plexNotFoundError(): Error & { response: { status: number } } {
|
||||
return Object.assign(new Error('Request failed with status code 404'), {
|
||||
response: { status: 404 },
|
||||
isAxiosError: true,
|
||||
toJSON: () => ({}),
|
||||
name: 'AxiosError',
|
||||
});
|
||||
}
|
||||
|
||||
function rethrowPlexTestError(error: unknown): never {
|
||||
if (error instanceof Error && error.message === '404') {
|
||||
throw plexNotFoundError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let getMetadataImpl: (
|
||||
key: string,
|
||||
options?: { includeChildren?: boolean }
|
||||
) => Promise<PlexMetadata> = async () => {
|
||||
throw new Error('404');
|
||||
throw plexNotFoundError();
|
||||
};
|
||||
let getChildrenMetadataImpl: (
|
||||
key: string
|
||||
@@ -102,8 +118,13 @@ let getChildrenMetadataImpl: (
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
get() {
|
||||
return async (key: string, options?: { includeChildren?: boolean }) =>
|
||||
getMetadataImpl(key, options);
|
||||
return async (key: string, options?: { includeChildren?: boolean }) => {
|
||||
try {
|
||||
return await getMetadataImpl(key, options);
|
||||
} catch (error) {
|
||||
rethrowPlexTestError(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
@@ -111,7 +132,13 @@ Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getChildrenMetadataImpl(key);
|
||||
return async (key: string) => {
|
||||
try {
|
||||
return await getChildrenMetadataImpl(key);
|
||||
} catch (error) {
|
||||
rethrowPlexTestError(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
@@ -394,6 +421,38 @@ function fakePlexEpisodes(count: number): PlexMetadata[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function fakePlex4kEpisodes(count: number): PlexMetadata[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
ratingKey: `ep4k-${i}`,
|
||||
guid: `plex://episode/ep4k-${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: 20000,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
aspectRatio: 1.78,
|
||||
audioChannels: 6,
|
||||
audioCodec: 'eac3',
|
||||
videoCodec: 'hevc',
|
||||
videoResolution: '4k',
|
||||
container: 'mkv',
|
||||
videoFrameRate: '24p',
|
||||
videoProfile: 'main 10',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function fakePlexShow(ratingKey: string): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
@@ -1303,6 +1362,372 @@ describe('AvailabilitySync', () => {
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark seasons DELETED when a show is split across a 1080p and a 4K Plex library', async () => {
|
||||
configurePlex();
|
||||
// No Sonarr configured for this user: only Plex is the source of truth.
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 2010;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
// Season 1 lives in the 1080p Plex library, S2-S4 live in the 4K
|
||||
// Plex library. Simulate the 4K copy scanning last and taking
|
||||
// ratingKey; both keys must still be checked.
|
||||
media.ratingKey = 'plex-split-4k-rk';
|
||||
media.ratingKey4k = 'plex-split-4k-rk';
|
||||
media.plexRatingKeys = ['plex-split-1080p-rk', 'plex-split-4k-rk'];
|
||||
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-split-1080p-rk') {
|
||||
return fakePlexShow('plex-split-1080p-rk');
|
||||
}
|
||||
if (key === 'plex-split-4k-rk') {
|
||||
return fakePlexShow('plex-split-4k-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-split-1080p-rk') {
|
||||
return [fakePlexSeason(1, 'plex-split-1080p-s1-rk')];
|
||||
}
|
||||
if (key === 'plex-split-4k-rk') {
|
||||
return [
|
||||
fakePlexSeason(2, 'plex-split-4k-s2-rk'),
|
||||
fakePlexSeason(3, 'plex-split-4k-s3-rk'),
|
||||
fakePlexSeason(4, 'plex-split-4k-s4-rk'),
|
||||
];
|
||||
}
|
||||
if (key === 'plex-split-1080p-s1-rk') {
|
||||
return fakePlexEpisodes(10);
|
||||
}
|
||||
if (
|
||||
key === 'plex-split-4k-s2-rk' ||
|
||||
key === 'plex-split-4k-s3-rk' ||
|
||||
key === 'plex-split-4k-s4-rk'
|
||||
) {
|
||||
return fakePlex4kEpisodes(10);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2010 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
assert.notStrictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should not be DELETED`
|
||||
);
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
`Season ${season.seasonNumber} should remain AVAILABLE`
|
||||
);
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Show should remain AVAILABLE'
|
||||
);
|
||||
});
|
||||
|
||||
it('should still mark seasons DELETED when neither rating key contains them (legacy single-ratingKey)', async () => {
|
||||
configurePlex();
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const media = new Media();
|
||||
media.tmdbId = 2011;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
// Only the primary rating key is set (no ratingKey4k), exercising
|
||||
// the secondary-lookup branch when the fallback has nothing to find.
|
||||
media.ratingKey = 'plex-legacy-rk';
|
||||
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-legacy-rk') {
|
||||
return fakePlexShow('plex-legacy-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-legacy-rk') {
|
||||
return [fakePlexSeason(2, 'plex-legacy-s2-rk')];
|
||||
}
|
||||
if (key === 'plex-legacy-s2-rk') {
|
||||
return fakePlexEpisodes(10);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2011 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
|
||||
assert.strictEqual(
|
||||
s2?.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
'Season 2 should remain AVAILABLE'
|
||||
);
|
||||
|
||||
for (const season of updated.seasons) {
|
||||
if (season.seasonNumber !== 2) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.DELETED,
|
||||
`Season ${season.seasonNumber} should be DELETED`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
updated.status,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
'Show should be PARTIALLY_AVAILABLE after season removal'
|
||||
);
|
||||
});
|
||||
|
||||
it('should drop a stored Plex rating key when Plex returns 404 for it', async () => {
|
||||
configurePlex();
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = new Media();
|
||||
media.tmdbId = 2012;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-dead-rk';
|
||||
media.ratingKey4k = 'plex-dead-rk';
|
||||
media.plexRatingKeys = ['plex-dead-rk', 'plex-live-rk'];
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-live-rk') {
|
||||
return fakePlexShow('plex-live-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-live-rk') {
|
||||
return [fakePlexSeason(1, 'plex-live-s1-rk')];
|
||||
}
|
||||
if (key === 'plex-live-s1-rk') {
|
||||
return fakePlexEpisodes(10);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2012 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(updated.plexRatingKeys?.sort(), ['plex-live-rk']);
|
||||
assert.strictEqual(updated.seasons[0].status, MediaStatus.AVAILABLE);
|
||||
assert.strictEqual(updated.ratingKey, null);
|
||||
assert.strictEqual(updated.ratingKey4k, null);
|
||||
});
|
||||
|
||||
it('should clear deep-link keys and plexRatingKeys when the last stored key 404s', async () => {
|
||||
configurePlex();
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = new Media();
|
||||
media.tmdbId = 2014;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-only-dead-rk';
|
||||
media.plexRatingKeys = ['plex-only-dead-rk'];
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2014 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(updated.plexRatingKeys, []);
|
||||
assert.strictEqual(updated.ratingKey, null);
|
||||
assert.strictEqual(updated.seasons[0].status, MediaStatus.DELETED);
|
||||
assert.strictEqual(updated.status, MediaStatus.DELETED);
|
||||
});
|
||||
|
||||
it('should keep seasons when one rating key errors even if another key lacks them', async () => {
|
||||
configurePlex();
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = new Media();
|
||||
media.tmdbId = 2015;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-live-partial-rk';
|
||||
media.plexRatingKeys = ['plex-error-rk', 'plex-live-partial-rk'];
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
new Season({
|
||||
seasonNumber: 2,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-error-rk') {
|
||||
throw new Error('ECONNRESET');
|
||||
}
|
||||
if (key === 'plex-live-partial-rk') {
|
||||
return fakePlexShow('plex-live-partial-rk');
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-live-partial-rk') {
|
||||
return [fakePlexSeason(1, 'plex-live-partial-s1-rk')];
|
||||
}
|
||||
if (key === 'plex-live-partial-s1-rk') {
|
||||
return fakePlexEpisodes(10);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2015 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
const s1 = updated.seasons.find((s) => s.seasonNumber === 1);
|
||||
const s2 = updated.seasons.find((s) => s.seasonNumber === 2);
|
||||
|
||||
assert.strictEqual(s1?.status, MediaStatus.AVAILABLE);
|
||||
assert.strictEqual(s2?.status, MediaStatus.AVAILABLE);
|
||||
assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
|
||||
assert.deepStrictEqual(
|
||||
updated.plexRatingKeys?.slice().sort(),
|
||||
['plex-error-rk', 'plex-live-partial-rk'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail open when Plex returns a non-404 error for a stored rating key', async () => {
|
||||
configurePlex();
|
||||
const settings = getSettings();
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = new Media();
|
||||
media.tmdbId = 2013;
|
||||
media.mediaType = MediaType.TV;
|
||||
media.status = MediaStatus.AVAILABLE;
|
||||
media.ratingKey = 'plex-error-rk';
|
||||
media.plexRatingKeys = ['plex-error-rk'];
|
||||
media.seasons = [
|
||||
new Season({
|
||||
seasonNumber: 1,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}),
|
||||
];
|
||||
await mediaRepository.save(media);
|
||||
|
||||
getMetadataImpl = async () => {
|
||||
throw new Error('ECONNRESET');
|
||||
};
|
||||
|
||||
await availabilitySync.run();
|
||||
|
||||
const updated = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: 2013 },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.seasons[0].status, MediaStatus.AVAILABLE);
|
||||
assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
|
||||
assert.deepStrictEqual(updated.plexRatingKeys, ['plex-error-rk']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movie availability - Radarr', () => {
|
||||
@@ -1642,8 +2067,8 @@ describe('AvailabilitySync', () => {
|
||||
assert.strictEqual(updated.externalServiceSlug4k, 'test-movie');
|
||||
assert.strictEqual(
|
||||
updated.ratingKey4k,
|
||||
'req-in-flight-rk',
|
||||
'ratingKey4k must be kept while an approved 4K request is in flight'
|
||||
null,
|
||||
'A 404 from Plex must clear ratingKey4k even when a request is in flight'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+207
-146
@@ -27,6 +27,11 @@ class AvailabilitySync {
|
||||
private plexClient: PlexAPI;
|
||||
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
||||
private plexEpisodeExistsCache: Record<string, boolean>;
|
||||
private plexMetadataCache: Record<string, PlexMetadata | undefined>;
|
||||
private plexRatingKeyProbeCache: Record<
|
||||
string,
|
||||
'exists' | 'missing' | 'error'
|
||||
>;
|
||||
|
||||
private jellyfinClient: JellyfinAPI;
|
||||
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
|
||||
@@ -46,6 +51,8 @@ class AvailabilitySync {
|
||||
this.running = true;
|
||||
this.plexSeasonsCache = {};
|
||||
this.plexEpisodeExistsCache = {};
|
||||
this.plexMetadataCache = {};
|
||||
this.plexRatingKeyProbeCache = {};
|
||||
this.jellyfinSeasonsCache = {};
|
||||
this.jellyfinEpisodeExistsCache = {};
|
||||
this.sonarrSeasonsCache = {};
|
||||
@@ -868,135 +875,176 @@ class AvailabilitySync {
|
||||
}
|
||||
|
||||
// Plex
|
||||
private isPlexNotFound(ex: unknown): boolean {
|
||||
return (ex as { response?: { status?: number } })?.response?.status === 404;
|
||||
}
|
||||
|
||||
private async dropStoredPlexRatingKey(
|
||||
media: Media,
|
||||
ratingKey: string
|
||||
): Promise<void> {
|
||||
const keys = media.getPlexRatingKeys();
|
||||
if (!keys.includes(ratingKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = keys.filter((stored) => stored !== ratingKey);
|
||||
media.plexRatingKeys = remaining;
|
||||
|
||||
if (media.ratingKey === ratingKey) {
|
||||
media.ratingKey = null;
|
||||
}
|
||||
if (media.ratingKey4k === ratingKey) {
|
||||
media.ratingKey4k = null;
|
||||
}
|
||||
|
||||
await getRepository(Media).save(media);
|
||||
}
|
||||
|
||||
private async probePlexRatingKey(
|
||||
media: Media,
|
||||
ratingKey: string
|
||||
): Promise<'exists' | 'missing' | 'error'> {
|
||||
if (ratingKey in this.plexRatingKeyProbeCache) {
|
||||
return this.plexRatingKeyProbeCache[ratingKey];
|
||||
}
|
||||
|
||||
try {
|
||||
const plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||
this.plexMetadataCache[ratingKey] = plexMedia;
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
this.plexSeasonsCache[ratingKey] =
|
||||
(await this.plexClient?.getChildrenMetadata(ratingKey)) ?? [];
|
||||
}
|
||||
|
||||
const result = plexMedia ? 'exists' : 'missing';
|
||||
this.plexRatingKeyProbeCache[ratingKey] = result;
|
||||
return result;
|
||||
} catch (ex) {
|
||||
if (this.isPlexNotFound(ex)) {
|
||||
this.plexRatingKeyProbeCache[ratingKey] = 'missing';
|
||||
this.plexSeasonsCache[ratingKey] = [];
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Failure retrieving Plex metadata for rating key ${ratingKey} [TMDB ID ${media.tmdbId}].`,
|
||||
{
|
||||
errorMessage: ex instanceof Error ? ex.message : String(ex),
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
this.plexRatingKeyProbeCache[ratingKey] = 'error';
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
private async tvKeyHasTieredEpisodes(
|
||||
ratingKey: string,
|
||||
is4k: boolean
|
||||
): Promise<{
|
||||
hasTier: boolean;
|
||||
verified: boolean;
|
||||
}> {
|
||||
const cachedSeasons = this.plexSeasonsCache[ratingKey];
|
||||
if (!cachedSeasons?.length) {
|
||||
return { hasTier: false, verified: false };
|
||||
}
|
||||
|
||||
let hasTier = false;
|
||||
let verified = false;
|
||||
|
||||
for (const season of cachedSeasons) {
|
||||
try {
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
if (episodes?.some((episode) => episode.Media?.length)) {
|
||||
verified = true;
|
||||
}
|
||||
const episodeVersions =
|
||||
episodes?.flatMap((episode) => episode.Media ?? []) ?? [];
|
||||
if (this.seasonHasTieredEpisodes(episodeVersions, is4k)) {
|
||||
hasTier = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch episodes for a season, continue checking other seasons
|
||||
}
|
||||
}
|
||||
|
||||
return { hasTier, verified };
|
||||
}
|
||||
|
||||
private async mediaExistsInPlex(
|
||||
media: Media,
|
||||
is4k: boolean
|
||||
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
const ratingKeys = media.getPlexRatingKeys();
|
||||
let existsInPlex = false;
|
||||
let preventSeasonSearch = false;
|
||||
|
||||
// Check each plex instance to see if the media still exists
|
||||
// If found, we will assume the media exists and prevent removal
|
||||
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
||||
try {
|
||||
let plexMedia: PlexMetadata | undefined;
|
||||
for (const ratingKey of ratingKeys) {
|
||||
const probe = await this.probePlexRatingKey(media, ratingKey);
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
this.plexSeasonsCache[ratingKey] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey);
|
||||
}
|
||||
|
||||
if (
|
||||
plexMedia &&
|
||||
media.mediaType === 'movie' &&
|
||||
this.enable4kMovie &&
|
||||
plexMedia.Media?.length &&
|
||||
!plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) < 2000)
|
||||
) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
if (probe === 'missing') {
|
||||
await this.dropStoredPlexRatingKey(media, ratingKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ratingKey4k && is4k) {
|
||||
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
|
||||
if (probe === 'error') {
|
||||
existsInPlex = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
this.plexSeasonsCache[ratingKey4k] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||
}
|
||||
const plexMedia = this.plexMetadataCache[ratingKey];
|
||||
|
||||
if (plexMedia) {
|
||||
if (media.mediaType === 'movie') {
|
||||
if (is4k) {
|
||||
if (
|
||||
plexMedia &&
|
||||
media.mediaType === 'movie' &&
|
||||
plexMedia.Media?.length &&
|
||||
!plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) >= 2000)
|
||||
!plexMedia?.Media?.length ||
|
||||
plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) >= 2000)
|
||||
) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
|
||||
if (plexMedia && media.mediaType === 'tv') {
|
||||
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
if (cachedSeasons?.length) {
|
||||
let has4kInAnySeason = false;
|
||||
let verifiedAnySeason = false;
|
||||
for (const season of cachedSeasons) {
|
||||
try {
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
if (episodes?.some((episode) => episode.Media?.length)) {
|
||||
verifiedAnySeason = true;
|
||||
}
|
||||
const has4kEpisode = episodes?.some((episode) =>
|
||||
episode.Media?.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
)
|
||||
);
|
||||
if (has4kEpisode) {
|
||||
has4kInAnySeason = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch episodes for a season, continue checking other seasons
|
||||
}
|
||||
}
|
||||
if (verifiedAnySeason && !has4kInAnySeason) {
|
||||
plexMedia = undefined;
|
||||
}
|
||||
}
|
||||
existsInPlex = true;
|
||||
}
|
||||
} else if (
|
||||
!this.enable4kMovie ||
|
||||
!plexMedia?.Media?.length ||
|
||||
plexMedia.Media.some((mediaItem) => (mediaItem.width ?? 0) < 2000)
|
||||
) {
|
||||
existsInPlex = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
if (!is4k && !this.enable4kShow) {
|
||||
existsInPlex = true;
|
||||
continue;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
|
||||
const { hasTier, verified } = await this.tvKeyHasTieredEpisodes(
|
||||
ratingKey,
|
||||
is4k
|
||||
);
|
||||
if (hasTier || !verified) {
|
||||
existsInPlex = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Here we check each season in plex for availability
|
||||
// If the API returns an error other than a 404,
|
||||
// we will have to prevent the season check from happening
|
||||
if (media.mediaType === 'tv') {
|
||||
const seasonsMap: Map<number, boolean> = new Map();
|
||||
const filteredSeasons = media.seasons.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||
season[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
|
||||
if (!preventSeasonSearch) {
|
||||
const filteredSeasons = media.seasons.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||
season[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
for (const season of filteredSeasons) {
|
||||
const seasonExists = await this.seasonExistsInPlex(media, season, is4k);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const seasonExists = await this.seasonExistsInPlex(
|
||||
media,
|
||||
season,
|
||||
is4k
|
||||
);
|
||||
|
||||
if (seasonExists) {
|
||||
seasonsMap.set(season.seasonNumber, true);
|
||||
}
|
||||
if (seasonExists) {
|
||||
seasonsMap.set(season.seasonNumber, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,67 +1054,80 @@ class AvailabilitySync {
|
||||
return { existsInPlex };
|
||||
}
|
||||
|
||||
private seasonHasTieredEpisodes(
|
||||
episodeVersions: { width?: number }[],
|
||||
is4k: boolean
|
||||
): boolean {
|
||||
if (is4k) {
|
||||
return episodeVersions.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
);
|
||||
}
|
||||
if (this.enable4kShow) {
|
||||
return episodeVersions.some((mediaItem) => (mediaItem.width ?? 0) < 2000);
|
||||
}
|
||||
return episodeVersions.length > 0;
|
||||
}
|
||||
|
||||
private async seasonExistsInPlex(
|
||||
media: Media,
|
||||
season: Season,
|
||||
is4k: boolean
|
||||
): Promise<boolean> {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
let seasonExistsInPlex = false;
|
||||
const ratingKeys = media.getPlexRatingKeys();
|
||||
let sawError = false;
|
||||
|
||||
let plexSeasons: PlexMetadata[] | undefined;
|
||||
for (const ratingKey of ratingKeys) {
|
||||
if (!(ratingKey in this.plexSeasonsCache)) {
|
||||
const probe = await this.probePlexRatingKey(media, ratingKey);
|
||||
if (probe === 'missing') {
|
||||
await this.dropStoredPlexRatingKey(media, ratingKey);
|
||||
continue;
|
||||
}
|
||||
if (probe === 'error') {
|
||||
sawError = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
plexSeasons = this.plexSeasonsCache[ratingKey];
|
||||
}
|
||||
const seasonMeta = this.plexSeasonsCache[ratingKey]?.find(
|
||||
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (ratingKey4k && is4k) {
|
||||
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
}
|
||||
if (!seasonMeta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonMeta = plexSeasons?.find(
|
||||
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonMeta) {
|
||||
const cacheKey = `${is4k ? '4k' : 'std'}-${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
|
||||
);
|
||||
|
||||
const episodeVersions =
|
||||
episodes?.flatMap((episode) => episode.Media ?? []) ?? [];
|
||||
|
||||
if (is4k) {
|
||||
seasonExistsInPlex = episodeVersions.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||
);
|
||||
} else if (this.enable4kShow) {
|
||||
seasonExistsInPlex = episodeVersions.some(
|
||||
(mediaItem) => (mediaItem.width ?? 0) < 2000
|
||||
);
|
||||
} else {
|
||||
seasonExistsInPlex = episodeVersions.length > 0;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch episodes, assume the season exists
|
||||
// to avoid false removal
|
||||
seasonExistsInPlex = true;
|
||||
if (this.plexEpisodeExistsCache[cacheKey]) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.plexEpisodeExistsCache[cacheKey] = seasonExistsInPlex;
|
||||
try {
|
||||
const episodes = await this.plexClient?.getChildrenMetadata(
|
||||
seasonMeta.ratingKey
|
||||
);
|
||||
const episodeVersions =
|
||||
episodes?.flatMap((episode) => episode.Media ?? []) ?? [];
|
||||
const seasonExists = this.seasonHasTieredEpisodes(
|
||||
episodeVersions,
|
||||
is4k
|
||||
);
|
||||
this.plexEpisodeExistsCache[cacheKey] = seasonExists;
|
||||
if (seasonExists) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
this.plexEpisodeExistsCache[cacheKey] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return seasonExistsInPlex;
|
||||
return sawError;
|
||||
}
|
||||
|
||||
// Jellyfin
|
||||
|
||||
@@ -168,10 +168,17 @@ class BaseScanner<T> {
|
||||
ratingKey &&
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
|
||||
) {
|
||||
existing.addPlexRatingKey(
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
);
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (existing.addPlexRatingKey(ratingKey)) {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
@@ -264,6 +271,7 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey = !is4k ? ratingKey : undefined;
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
newMedia.addPlexRatingKey(ratingKey);
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
@@ -336,6 +344,7 @@ class BaseScanner<T> {
|
||||
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.addPlexRatingKey(media.ratingKey);
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
|
||||
@@ -345,6 +354,7 @@ class BaseScanner<T> {
|
||||
this.enable4kShow &&
|
||||
media.ratingKey4k !== ratingKey
|
||||
) {
|
||||
media.addPlexRatingKey(media.ratingKey4k);
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
@@ -433,6 +443,7 @@ class BaseScanner<T> {
|
||||
}
|
||||
|
||||
if (media) {
|
||||
media.addPlexRatingKey(ratingKey);
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonsAvailable = (
|
||||
@@ -676,6 +687,7 @@ class BaseScanner<T> {
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
newMedia.addPlexRatingKey(ratingKey);
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${title}`);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ import MediaRequest from '@server/entity/MediaRequest';
|
||||
import Season from '@server/entity/Season';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import type { Library, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import { runWithMockTimers } from '@server/test/runWithMockTimers';
|
||||
@@ -36,6 +37,19 @@ Object.defineProperty(animeList, 'sync', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
function plexNotFoundError(): Error & { response: { status: number } } {
|
||||
return Object.assign(new Error('Request failed with status code 404'), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
}
|
||||
|
||||
function rethrowPlexTestError(error: unknown): never {
|
||||
if (error instanceof Error && error.message === '404') {
|
||||
throw plexNotFoundError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let getLibrariesImpl: () => Promise<PlexLibrary[]> = async () => [];
|
||||
let getLibraryContentsImpl: (
|
||||
id: string
|
||||
@@ -44,8 +58,11 @@ let getLibraryContentsImpl: (
|
||||
items: [],
|
||||
});
|
||||
let getMetadataImpl: (
|
||||
key: string
|
||||
) => Promise<PlexMetadata | undefined> = async () => undefined;
|
||||
key: string,
|
||||
options?: { includeChildren?: boolean }
|
||||
) => Promise<PlexMetadata> = async () => {
|
||||
throw plexNotFoundError();
|
||||
};
|
||||
let getChildrenMetadataImpl: (
|
||||
key: string
|
||||
) => Promise<PlexMetadata[]> = async () => [];
|
||||
@@ -68,7 +85,13 @@ Object.defineProperty(PlexAPI.prototype, 'getLibraryContents', {
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getMetadataImpl(key);
|
||||
return async (key: string, options?: { includeChildren?: boolean }) => {
|
||||
try {
|
||||
return await getMetadataImpl(key, options);
|
||||
} catch (error) {
|
||||
rethrowPlexTestError(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
@@ -76,7 +99,13 @@ Object.defineProperty(PlexAPI.prototype, 'getMetadata', {
|
||||
|
||||
Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', {
|
||||
get() {
|
||||
return async (key: string) => getChildrenMetadataImpl(key);
|
||||
return async (key: string) => {
|
||||
try {
|
||||
return await getChildrenMetadataImpl(key);
|
||||
} catch (error) {
|
||||
rethrowPlexTestError(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
@@ -84,20 +113,38 @@ Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', {
|
||||
|
||||
let getTvShowImpl: (args: {
|
||||
tvId: 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, 'getTvShowForScan', {
|
||||
get() {
|
||||
return async (args: { tvId: number; language?: string }) =>
|
||||
getTvShowImpl(args);
|
||||
},
|
||||
set() {},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
for (const method of ['getTvShow', 'getTvShowForScan'] as const) {
|
||||
Object.defineProperty(TheMovieDb.prototype, method, {
|
||||
get() {
|
||||
return async (args: { tvId: number }) => getTvShowImpl(args);
|
||||
},
|
||||
set() {},
|
||||
// plexFullScanner / availabilitySync built their own TheMovieDb on import,
|
||||
// before the stubs above.
|
||||
Object.defineProperty(plexFullScanner.tmdb, method, {
|
||||
value: async (args: { tvId: number; language?: string }) =>
|
||||
getTvShowImpl(args),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// plexFullScanner built its own TheMovieDb on import, before the stubs above.
|
||||
Object.defineProperty(plexFullScanner.tmdb, method, {
|
||||
value: async (args: { tvId: number }) => getTvShowImpl(args),
|
||||
Object.defineProperty(availabilitySync.tmdb, method, {
|
||||
value: async (args: { tvId: number; language?: string }) =>
|
||||
getTvShowImpl(args),
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
@@ -106,18 +153,20 @@ mock.method(MediaRequest, 'sendNotification', async () => undefined);
|
||||
|
||||
setupTestDb();
|
||||
|
||||
function tmdbSeasons(count: number): TmdbTvSeasonResult[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
air_date: '2024-01-01',
|
||||
episode_count: 10,
|
||||
name: `Season ${i + 1}`,
|
||||
overview: '',
|
||||
season_number: i + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function fakeTmdbShow(
|
||||
tmdbId: number,
|
||||
seasons: TmdbTvSeasonResult[] = [
|
||||
{
|
||||
id: 1,
|
||||
air_date: '2024-01-01',
|
||||
episode_count: 10,
|
||||
name: 'Season 1',
|
||||
overview: '',
|
||||
season_number: 1,
|
||||
},
|
||||
]
|
||||
seasons: TmdbTvSeasonResult[] = tmdbSeasons(1)
|
||||
): TmdbTvDetails {
|
||||
return {
|
||||
id: tmdbId,
|
||||
@@ -132,7 +181,10 @@ function fakeTmdbShow(
|
||||
last_air_date: '2024-01-01',
|
||||
name: 'Test Show',
|
||||
networks: [],
|
||||
number_of_episodes: 10,
|
||||
number_of_episodes: seasons.reduce(
|
||||
(sum, season) => sum + season.episode_count,
|
||||
0
|
||||
),
|
||||
number_of_seasons: seasons.length,
|
||||
origin_country: ['US'],
|
||||
original_language: 'en',
|
||||
@@ -155,14 +207,71 @@ function fakeTmdbShow(
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexShowItem(ratingKey: string): PlexLibraryItem {
|
||||
function fakePlexSeason(seasonNumber: number, ratingKey: string): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
title: 'Test Show',
|
||||
guid: 'plex://show/test-show',
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
guid: `plex://season/${ratingKey}`,
|
||||
type: 'season',
|
||||
title: `Season ${seasonNumber}`,
|
||||
Guid: [],
|
||||
index: seasonNumber,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1,
|
||||
updatedAt: 1,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexEpisodes(
|
||||
prefix: string,
|
||||
count: number,
|
||||
width: number
|
||||
): PlexMetadata[] {
|
||||
const videoResolution = width >= 2000 ? '4k' : '1080';
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
ratingKey: `${prefix}-ep-${i}`,
|
||||
guid: `plex://episode/${prefix}-ep-${i}`,
|
||||
type: 'movie' as const,
|
||||
title: `Episode ${i + 1}`,
|
||||
Guid: [],
|
||||
index: i + 1,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1,
|
||||
updatedAt: 1,
|
||||
Media: [
|
||||
{
|
||||
id: i,
|
||||
duration: 2400,
|
||||
bitrate: width >= 2000 ? 20000 : 4000,
|
||||
width,
|
||||
height: width >= 2000 ? 2160 : 1080,
|
||||
aspectRatio: 1.78,
|
||||
audioChannels: 2,
|
||||
audioCodec: 'aac',
|
||||
videoCodec: 'h264',
|
||||
videoResolution,
|
||||
container: 'mkv',
|
||||
videoFrameRate: '24p',
|
||||
videoProfile: 'high',
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
function fakePlexLibraryShow(
|
||||
ratingKey: string,
|
||||
tmdbId: number,
|
||||
title = 'Split Show'
|
||||
): PlexLibraryItem {
|
||||
return {
|
||||
ratingKey,
|
||||
title,
|
||||
guid: `tmdb://${tmdbId}`,
|
||||
type: 'show',
|
||||
addedAt: 1,
|
||||
updatedAt: 1,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
@@ -170,50 +279,32 @@ function fakePlexShowItem(ratingKey: string): PlexLibraryItem {
|
||||
function fakePlexShowMetadata(
|
||||
ratingKey: string,
|
||||
tmdbId: number,
|
||||
children: PlexMetadata[]
|
||||
seasons: PlexMetadata[]
|
||||
): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
title: 'Test Show',
|
||||
guid: 'plex://show/test-show',
|
||||
guid: `tmdb://${tmdbId}`,
|
||||
type: 'show',
|
||||
title: 'Test Show',
|
||||
Guid: [{ id: `tmdb://${tmdbId}` }],
|
||||
Children: { size: 12, Metadata: children },
|
||||
Children: {
|
||||
size: 12,
|
||||
Metadata: seasons,
|
||||
},
|
||||
index: 1,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
addedAt: 1,
|
||||
updatedAt: 1,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fakePlexSeasonMetadata(
|
||||
seasonNumber: number,
|
||||
ratingKey: string
|
||||
): PlexMetadata {
|
||||
return {
|
||||
ratingKey,
|
||||
title: `Season ${seasonNumber}`,
|
||||
guid: `plex://season/test-season-${seasonNumber}`,
|
||||
type: 'season',
|
||||
Guid: [],
|
||||
index: seasonNumber,
|
||||
leafCount: 0,
|
||||
viewedLeafCount: 0,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
Media: [],
|
||||
};
|
||||
}
|
||||
|
||||
function configurePlexWithLibrary(
|
||||
libraries: Library[] = [
|
||||
{ id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' },
|
||||
]
|
||||
): void {
|
||||
function configurePlexLibraries(libraries: Library[]): void {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.PLEX;
|
||||
settings.sonarr = [];
|
||||
settings.radarr = [];
|
||||
settings.plex = {
|
||||
...settings.plex,
|
||||
ip: '127.0.0.1',
|
||||
@@ -222,25 +313,77 @@ function configurePlexWithLibrary(
|
||||
};
|
||||
}
|
||||
|
||||
/** Enables BaseScanner.startRun() 4K show detection via settings.sonarr. */
|
||||
function enableSonarr4k(): void {
|
||||
getSettings().sonarr = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Sonarr 4K',
|
||||
hostname: 'localhost',
|
||||
port: 8989,
|
||||
apiKey: 'test-key',
|
||||
baseUrl: '',
|
||||
useSsl: false,
|
||||
activeProfileId: 1,
|
||||
activeProfileName: 'Default',
|
||||
activeDirectory: '/tv',
|
||||
activeLanguageProfileId: 1,
|
||||
seriesType: 'standard',
|
||||
animeSeriesType: 'standard',
|
||||
animeTags: [],
|
||||
is4k: true,
|
||||
enableSeasonFolders: true,
|
||||
monitorNewItems: 'all',
|
||||
tags: [],
|
||||
isDefault: true,
|
||||
syncEnabled: true,
|
||||
preventSearch: false,
|
||||
tagRequests: false,
|
||||
overrideRule: [],
|
||||
externalUrl: '',
|
||||
} satisfies SonarrSettings,
|
||||
];
|
||||
}
|
||||
|
||||
async function assertSeasonsAvailable(tmdbId: number, count: number) {
|
||||
const updated = await getRepository(Media).findOneOrFail({
|
||||
where: { tmdbId, mediaType: MediaType.TV },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.seasons.length, count);
|
||||
for (const season of updated.seasons) {
|
||||
assert.strictEqual(
|
||||
season.status,
|
||||
MediaStatus.AVAILABLE,
|
||||
`Season ${season.seasonNumber} should remain AVAILABLE`
|
||||
);
|
||||
}
|
||||
assert.strictEqual(updated.status, MediaStatus.AVAILABLE);
|
||||
return updated;
|
||||
}
|
||||
|
||||
describe('Plex Scanner', () => {
|
||||
beforeEach(() => {
|
||||
getLibrariesImpl = async () => [
|
||||
{
|
||||
key: 'test-library-id',
|
||||
title: 'TV Shows',
|
||||
type: 'show',
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
];
|
||||
getLibrariesImpl = async () => [];
|
||||
getLibraryContentsImpl = async () => ({ totalSize: 0, items: [] });
|
||||
getMetadataImpl = async () => undefined;
|
||||
getMetadataImpl = async () => {
|
||||
throw new Error('404');
|
||||
};
|
||||
getChildrenMetadataImpl = async () => [];
|
||||
getTvShowImpl = async () => fakeTmdbShow(1);
|
||||
});
|
||||
|
||||
describe('in-flight request handling', () => {
|
||||
it('leaves an approved request alone when the show has no episodes yet', async () => {
|
||||
configurePlexWithLibrary();
|
||||
configurePlexLibraries([
|
||||
{
|
||||
id: 'test-library-id',
|
||||
name: 'TV Shows',
|
||||
enabled: true,
|
||||
type: 'show',
|
||||
},
|
||||
]);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
@@ -290,21 +433,37 @@ describe('Plex Scanner', () => {
|
||||
);
|
||||
|
||||
getTvShowImpl = async () => fakeTmdbShow(6100);
|
||||
getLibrariesImpl = async () => [
|
||||
{
|
||||
key: 'test-library-id',
|
||||
title: 'TV Shows',
|
||||
type: 'show',
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
];
|
||||
|
||||
getLibraryContentsImpl = async (id: string) =>
|
||||
id === 'test-library-id'
|
||||
? {
|
||||
totalSize: 1,
|
||||
items: [fakePlexShowItem('plex-inflight-show-key')],
|
||||
items: [
|
||||
fakePlexLibraryShow(
|
||||
'plex-inflight-show-key',
|
||||
6100,
|
||||
'Test Show'
|
||||
),
|
||||
],
|
||||
}
|
||||
: { totalSize: 0, items: [] };
|
||||
|
||||
getMetadataImpl = async (key: string) =>
|
||||
key === 'plex-inflight-show-key'
|
||||
? fakePlexShowMetadata('plex-inflight-show-key', 6100, [
|
||||
fakePlexSeasonMetadata(1, 'plex-inflight-s1-key'),
|
||||
])
|
||||
: undefined;
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === 'plex-inflight-show-key') {
|
||||
return fakePlexShowMetadata('plex-inflight-show-key', 6100, [
|
||||
fakePlexSeason(1, 'plex-inflight-s1-key'),
|
||||
]);
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
|
||||
getChildrenMetadataImpl = async () => [];
|
||||
|
||||
@@ -321,4 +480,279 @@ describe('Plex Scanner', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('split-library availability', () => {
|
||||
const split1080p: Library = {
|
||||
id: 'lib-1080',
|
||||
name: 'TV 1080p',
|
||||
enabled: true,
|
||||
type: 'show',
|
||||
};
|
||||
const split4k: Library = {
|
||||
id: 'lib-4k',
|
||||
name: 'TV 4K',
|
||||
enabled: true,
|
||||
type: 'show',
|
||||
};
|
||||
const libA: Library = {
|
||||
id: 'lib-a',
|
||||
name: 'TV A',
|
||||
enabled: true,
|
||||
type: 'show',
|
||||
};
|
||||
const libB: Library = {
|
||||
id: 'lib-b',
|
||||
name: 'TV B',
|
||||
enabled: true,
|
||||
type: 'show',
|
||||
};
|
||||
|
||||
function mockSplitShow(tmdbId: number) {
|
||||
const rk1080 = `split-${tmdbId}-1080`;
|
||||
const rk4k = `split-${tmdbId}-4k`;
|
||||
|
||||
getTvShowImpl = async () => fakeTmdbShow(tmdbId, tmdbSeasons(4));
|
||||
getLibrariesImpl = async () => [
|
||||
{
|
||||
type: 'show',
|
||||
key: split1080p.id,
|
||||
title: split1080p.name,
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
{
|
||||
type: 'show',
|
||||
key: split4k.id,
|
||||
title: split4k.name,
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
];
|
||||
getLibraryContentsImpl = async (id: string) => {
|
||||
if (id === split1080p.id) {
|
||||
return {
|
||||
totalSize: 1,
|
||||
items: [fakePlexLibraryShow(rk1080, tmdbId)],
|
||||
};
|
||||
}
|
||||
if (id === split4k.id) {
|
||||
return {
|
||||
totalSize: 1,
|
||||
items: [fakePlexLibraryShow(rk4k, tmdbId)],
|
||||
};
|
||||
}
|
||||
return { totalSize: 0, items: [] };
|
||||
};
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === rk1080) {
|
||||
return fakePlexShowMetadata(rk1080, tmdbId, [
|
||||
fakePlexSeason(1, `${rk1080}-s1`),
|
||||
]);
|
||||
}
|
||||
if (key === rk4k) {
|
||||
return fakePlexShowMetadata(rk4k, tmdbId, [
|
||||
fakePlexSeason(2, `${rk4k}-s2`),
|
||||
fakePlexSeason(3, `${rk4k}-s3`),
|
||||
fakePlexSeason(4, `${rk4k}-s4`),
|
||||
]);
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === rk1080) {
|
||||
return [fakePlexSeason(1, `${rk1080}-s1`)];
|
||||
}
|
||||
if (key === rk4k) {
|
||||
return [
|
||||
fakePlexSeason(2, `${rk4k}-s2`),
|
||||
fakePlexSeason(3, `${rk4k}-s3`),
|
||||
fakePlexSeason(4, `${rk4k}-s4`),
|
||||
];
|
||||
}
|
||||
if (key === `${rk1080}-s1`) {
|
||||
return fakePlexEpisodes(key, 10, 1920);
|
||||
}
|
||||
if (key === `${rk4k}-s2`) {
|
||||
// One 1080p episode in the 4K library copy, which used to steal ratingKey.
|
||||
return [
|
||||
...fakePlexEpisodes(`${key}-4k`, 9, 3840),
|
||||
...fakePlexEpisodes(`${key}-1080`, 1, 1920),
|
||||
];
|
||||
}
|
||||
if (key === `${rk4k}-s3` || key === `${rk4k}-s4`) {
|
||||
return fakePlexEpisodes(key, 10, 3840);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return { rk1080, rk4k };
|
||||
}
|
||||
|
||||
function mockTwoStandardLibraries(tmdbId: number) {
|
||||
const rkA = `std-${tmdbId}-a`;
|
||||
const rkB = `std-${tmdbId}-b`;
|
||||
|
||||
getTvShowImpl = async () => fakeTmdbShow(tmdbId, tmdbSeasons(4));
|
||||
getLibrariesImpl = async () => [
|
||||
{
|
||||
type: 'show',
|
||||
key: libA.id,
|
||||
title: libA.name,
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
{
|
||||
type: 'show',
|
||||
key: libB.id,
|
||||
title: libB.name,
|
||||
agent: 'tv.plex.agents.series',
|
||||
},
|
||||
];
|
||||
getLibraryContentsImpl = async (id: string) => {
|
||||
if (id === libA.id) {
|
||||
return { totalSize: 1, items: [fakePlexLibraryShow(rkA, tmdbId)] };
|
||||
}
|
||||
if (id === libB.id) {
|
||||
return { totalSize: 1, items: [fakePlexLibraryShow(rkB, tmdbId)] };
|
||||
}
|
||||
return { totalSize: 0, items: [] };
|
||||
};
|
||||
getMetadataImpl = async (key: string) => {
|
||||
if (key === rkA) {
|
||||
return fakePlexShowMetadata(rkA, tmdbId, [
|
||||
fakePlexSeason(1, `${rkA}-s1`),
|
||||
]);
|
||||
}
|
||||
if (key === rkB) {
|
||||
return fakePlexShowMetadata(rkB, tmdbId, [
|
||||
fakePlexSeason(2, `${rkB}-s2`),
|
||||
fakePlexSeason(3, `${rkB}-s3`),
|
||||
fakePlexSeason(4, `${rkB}-s4`),
|
||||
]);
|
||||
}
|
||||
throw new Error('404');
|
||||
};
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === rkA) {
|
||||
return [fakePlexSeason(1, `${rkA}-s1`)];
|
||||
}
|
||||
if (key === rkB) {
|
||||
return [
|
||||
fakePlexSeason(2, `${rkB}-s2`),
|
||||
fakePlexSeason(3, `${rkB}-s3`),
|
||||
fakePlexSeason(4, `${rkB}-s4`),
|
||||
];
|
||||
}
|
||||
if (
|
||||
key === `${rkA}-s1` ||
|
||||
key === `${rkB}-s2` ||
|
||||
key === `${rkB}-s3` ||
|
||||
key === `${rkB}-s4`
|
||||
) {
|
||||
return fakePlexEpisodes(key, 10, 1920);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return { rkA, rkB };
|
||||
}
|
||||
|
||||
for (const order of [
|
||||
[split1080p, split4k],
|
||||
[split4k, split1080p],
|
||||
] as const) {
|
||||
it(`keeps every season after scanning ${order.map((library) => library.name).join(' then ')} then running availability sync`, async () => {
|
||||
const tmdbId = order[0].id === split1080p.id ? 9010 : 9011;
|
||||
const { rk1080, rk4k } = mockSplitShow(tmdbId);
|
||||
configurePlexLibraries([...order]);
|
||||
|
||||
await runWithMockTimers(() => plexFullScanner.run());
|
||||
|
||||
const scanned = await getRepository(Media).findOneOrFail({
|
||||
where: { tmdbId, mediaType: MediaType.TV },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
scanned.plexRatingKeys?.slice().sort(),
|
||||
[rk1080, rk4k].sort()
|
||||
);
|
||||
|
||||
await availabilitySync.run();
|
||||
await assertSeasonsAvailable(tmdbId, 4);
|
||||
});
|
||||
}
|
||||
|
||||
for (const order of [
|
||||
[libA, libB],
|
||||
[libB, libA],
|
||||
] as const) {
|
||||
it(`keeps every season after scanning two non-4K libraries ${order.map((library) => library.name).join(' then ')} then running availability sync`, async () => {
|
||||
const tmdbId = order[0].id === libA.id ? 9020 : 9021;
|
||||
const { rkA, rkB } = mockTwoStandardLibraries(tmdbId);
|
||||
configurePlexLibraries([...order]);
|
||||
|
||||
await runWithMockTimers(() => plexFullScanner.run());
|
||||
|
||||
const scanned = await getRepository(Media).findOneOrFail({
|
||||
where: { tmdbId, mediaType: MediaType.TV },
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
scanned.plexRatingKeys?.slice().sort(),
|
||||
[rkA, rkB].sort()
|
||||
);
|
||||
|
||||
await availabilitySync.run();
|
||||
await assertSeasonsAvailable(tmdbId, 4);
|
||||
});
|
||||
}
|
||||
|
||||
it('records ratingKey4k and 4K season/show availability when Sonarr enables 4K detection', async () => {
|
||||
const tmdbId = 9030;
|
||||
const { rk1080, rk4k } = mockSplitShow(tmdbId);
|
||||
|
||||
// Pure 4K season 2 so the 4K library copy does not also claim ratingKey.
|
||||
const baseChildren = getChildrenMetadataImpl;
|
||||
getChildrenMetadataImpl = async (key: string) => {
|
||||
if (key === `${rk4k}-s2`) {
|
||||
return fakePlexEpisodes(key, 10, 3840);
|
||||
}
|
||||
return baseChildren(key);
|
||||
};
|
||||
|
||||
configurePlexLibraries([split1080p, split4k]);
|
||||
enableSonarr4k();
|
||||
|
||||
await runWithMockTimers(() => plexFullScanner.run());
|
||||
|
||||
const scanned = await getRepository(Media).findOneOrFail({
|
||||
where: { tmdbId, mediaType: MediaType.TV },
|
||||
relations: ['seasons'],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(
|
||||
scanned.plexRatingKeys?.slice().sort(),
|
||||
[rk1080, rk4k].sort()
|
||||
);
|
||||
assert.strictEqual(scanned.ratingKey, rk1080);
|
||||
assert.strictEqual(scanned.ratingKey4k, rk4k);
|
||||
|
||||
const seasons = Object.fromEntries(
|
||||
scanned.seasons.map((season) => [season.seasonNumber, season])
|
||||
);
|
||||
|
||||
assert.strictEqual(seasons[1].status, MediaStatus.AVAILABLE);
|
||||
assert.strictEqual(seasons[1].status4k, MediaStatus.UNKNOWN);
|
||||
for (const seasonNumber of [2, 3, 4]) {
|
||||
assert.strictEqual(
|
||||
seasons[seasonNumber].status,
|
||||
MediaStatus.UNKNOWN,
|
||||
`Season ${seasonNumber} should have no standard availability`
|
||||
);
|
||||
assert.strictEqual(
|
||||
seasons[seasonNumber].status4k,
|
||||
MediaStatus.AVAILABLE,
|
||||
`Season ${seasonNumber} should be 4K AVAILABLE`
|
||||
);
|
||||
}
|
||||
|
||||
assert.strictEqual(scanned.status, MediaStatus.PARTIALLY_AVAILABLE);
|
||||
assert.strictEqual(scanned.status4k, MediaStatus.PARTIALLY_AVAILABLE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaPlexRatingKeys1791568161106 implements MigrationInterface {
|
||||
name = 'AddMediaPlexRatingKeys1791568161106';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "media" ADD "plexRatingKeys" text`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "media" DROP COLUMN "plexRatingKeys"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaPlexRatingKeys1791568116449 implements MigrationInterface {
|
||||
name = 'AddMediaPlexRatingKeys1791568116449';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_f8233358694d1677a67899b90a"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_5d6218de4f547909391a5c1347"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_c730c2d67f271a372c39a07b7e"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, "plexRatingKeys" text, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f8233358694d1677a67899b90a" ON "media" ("tmdbId", "mediaType") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_5d6218de4f547909391a5c1347" ON "media" ("status4k") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c730c2d67f271a372c39a07b7e" ON "media" ("status") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_c730c2d67f271a372c39a07b7e"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_5d6218de4f547909391a5c1347"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f8233358694d1677a67899b90a"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c730c2d67f271a372c39a07b7e" ON "media" ("status") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_5d6218de4f547909391a5c1347" ON "media" ("status4k") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f8233358694d1677a67899b90a" ON "media" ("tmdbId", "mediaType") `
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user