Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
Fallenbagel
2023-02-28 03:28:36 +05:00
57 changed files with 1122 additions and 191 deletions

View File

@@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';
interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {

View File

@@ -115,29 +115,29 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true, type: 'int' })
public serviceId?: number | null;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true, type: 'int' })
public serviceId4k?: number | null;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId?: number | null;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number | null;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string | null;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string | null;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey?: string | null;
@Column({ nullable: true })
public ratingKey4k?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
@Column({ nullable: true })
public jellyfinMediaId?: string;
@@ -288,7 +288,9 @@ class Media {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
@@ -298,7 +300,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
@@ -310,7 +314,9 @@ class Media {
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
@@ -320,7 +326,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,

View File

@@ -1187,3 +1187,5 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

View File

@@ -1,5 +1,7 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
@@ -34,6 +36,18 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

View File

@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
@@ -192,7 +193,8 @@ app
});
server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -1,4 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
@@ -16,7 +17,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -34,7 +35,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'short',
interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(
jobs['plex-recently-added-scan'].schedule,
@@ -54,7 +55,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
@@ -74,7 +75,7 @@ export const startJobs = (): void => {
id: 'jellyfin-recently-added-sync',
name: 'Jellyfin Recently Added Sync',
type: 'process',
interval: 'long',
interval: 'minutes',
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
job: schedule.scheduleJob(
jobs['jellyfin-recently-added-sync'].schedule,
@@ -94,7 +95,7 @@ export const startJobs = (): void => {
id: 'jellyfin-full-sync',
name: 'Jellyfin Full Library Sync',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['jellyfin-full-sync'].schedule,
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
logger.info('Starting scheduled job: Jellyfin Full Sync', {
@@ -112,7 +113,7 @@ export const startJobs = (): void => {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'short',
interval: 'minutes',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
@@ -127,7 +128,7 @@ export const startJobs = (): void => {
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@@ -142,7 +143,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@@ -152,12 +153,29 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
@@ -172,7 +190,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
@@ -182,12 +200,12 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 5 minutes
// Run image cache cleanup every 24 hours
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {

View File

@@ -0,0 +1,718 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
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 type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
await this.initPlexClient();
if (!this.plexClient) {
return;
}
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
const pageSize = 50;
try {
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
try {
if (!this.running) {
throw new Error('Job aborted');
}
const mediaExists = await this.mediaExists(media);
//We can not delete media so if both versions do not exist, we will change both columns to unknown or null
if (!mediaExists) {
if (
media.status !== MediaStatus.UNKNOWN ||
media.status4k !== MediaStatus.UNKNOWN
) {
const request = await requestRepository.find({
relations: {
media: true,
},
where: { media: { id: media.id } },
});
logger.info(
`${
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
} does not exist in any of your media instances. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
});
await requestRepository.remove(request);
}
}
if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?
const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
],
});
let didDeleteSeasons = false;
for (const season of seasons) {
if (
!mediaExists &&
(season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN)
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
} else {
const seasonExists = await this.seasonExists(media, season);
if (!seasonExists) {
logger.info(
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
{ label: 'AvailabilitySync' }
);
if (
season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
}
const seasonToBeDeleted =
await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
didDeleteSeasons = true;
}
}
if (didDeleteSeasons) {
if (
media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE
) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`,
{ label: 'AvailabilitySync' }
);
if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
}
}
} catch (ex) {
logger.error('Failure with media.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const isTVType = media.mediaType === 'tv';
const request = await requestRepository.findOne({
relations: {
media: true,
},
where: { media: { id: media.id }, is4k: is4k ? true : false },
});
logger.info(
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
isTVType ? 'sonarr' : 'radarr'
} and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(
media.id,
is4k
? {
status4k: MediaStatus.UNKNOWN,
serviceId4k: null,
externalServiceId4k: null,
externalServiceSlug4k: null,
ratingKey4k: null,
}
: {
status: MediaStatus.UNKNOWN,
serviceId: null,
externalServiceId: null,
externalServiceSlug: null,
ratingKey: null,
}
);
if (isTVType) {
const seasonRepository = getRepository(Season);
await seasonRepository?.update(
{ media: { id: media.id } },
is4k
? { status4k: MediaStatus.UNKNOWN }
: { status: MediaStatus.UNKNOWN }
);
}
await requestRepository.delete({ id: request?.id });
}
private async mediaExistsInRadarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInRadarr = true;
let existsInRadarr4k = true;
for (const server of this.radarrServers) {
const api = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getMovieByTmdbId(media.tmdbId);
//check if both exist or if a single non-4k or 4k exists
//if both do not exist we will return false
if (!server.is4k && !meta.id) {
existsInRadarr = false;
}
if (server.is4k && !meta.id) {
existsInRadarr4k = false;
}
}
if (existsInRadarr && existsInRadarr4k) {
return true;
}
if (!existsInRadarr && existsInPlex) {
return true;
}
if (!existsInRadarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInRadarr || existsInRadarr4k) {
return true;
}
return false;
}
private async mediaExistsInSonarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let existsInSonarr = true;
let existsInSonarr4k = true;
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const meta = await api.getSeriesByTvdbId(media.tvdbId);
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
//check if both exist or if a single non-4k or 4k exists
//if both do not exist we will return false
if (!server.is4k && !meta.id) {
existsInSonarr = false;
}
if (server.is4k && !meta.id) {
existsInSonarr4k = false;
}
}
if (existsInSonarr && existsInSonarr4k) {
return true;
}
if (!existsInSonarr && existsInPlex) {
return true;
}
if (!existsInSonarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInSonarr || existsInSonarr4k) {
return true;
}
return false;
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
seasonExistsInPlex: boolean,
seasonExistsInPlex4k: boolean
): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let seasonExistsInSonarr = true;
let seasonExistsInSonarr4k = true;
const mediaRepository = getRepository(Media);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const seasons =
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
const hasMonitoredSeason = seasons.find(
({ monitored, seasonNumber }) =>
monitored && season.seasonNumber === seasonNumber
);
if (!server.is4k && !hasMonitoredSeason) {
seasonExistsInSonarr = false;
}
if (server.is4k && !hasMonitoredSeason) {
seasonExistsInSonarr4k = false;
}
}
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
return true;
}
if (!seasonExistsInSonarr && seasonExistsInPlex) {
return true;
}
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
return true;
}
const seasonToBeDeleted = await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
is4k: seasonExistsInSonarr ? true : false,
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
//if season does not exist, we will change status to unknown and delete related season request
//if parent media request is empty(all related seasons have been removed), parent is automatically deleted
if (
!seasonExistsInSonarr &&
seasonExistsInSonarr4k &&
!seasonExistsInPlex
) {
if (season.status !== MediaStatus.UNKNOWN) {
logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status === MediaStatus.AVAILABLE) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (
seasonExistsInSonarr &&
!seasonExistsInSonarr4k &&
!seasonExistsInPlex4k
) {
if (season.status4k !== MediaStatus.UNKNOWN) {
logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status4k: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status4k === MediaStatus.AVAILABLE) {
logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (seasonExistsInSonarr || seasonExistsInSonarr4k) {
return true;
}
return false;
}
private async mediaExists(media: Media): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let existsInPlex4k = false;
//check each plex instance to see if media exists
try {
if (ratingKey) {
const meta = await this.plexClient?.getMetadata(ratingKey);
if (meta) {
existsInPlex = true;
}
}
if (ratingKey4k) {
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
if (meta4k) {
existsInPlex4k = true;
}
}
} catch (ex) {
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
if (!ex.message.includes('response code: 404')) {
throw ex;
}
}
//base case for if both media versions exist in plex
if (existsInPlex && existsInPlex4k) {
return true;
}
//we then check radarr or sonarr has that specific media. If not, then we will move to delete
//if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr(
media,
existsInPlex,
existsInPlex4k
);
//if true, media exists in at least one radarr or plex instance.
if (existsInRadarr) {
logger.warn(
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
if (media.mediaType === 'tv') {
const existsInSonarr = await this.mediaExistsInSonarr(
media,
existsInPlex,
existsInPlex4k
);
//if true, media exists in at least one sonarr or plex instance.
if (existsInSonarr) {
logger.warn(
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
return false;
}
private async seasonExists(media: Media, season: Season) {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
let seasonExistsInPlex4k = false;
if (ratingKey) {
const children =
this.plexSeasonsCache[ratingKey] ??
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
[];
this.plexSeasonsCache[ratingKey] = children;
const seasonMeta = children?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta) {
seasonExistsInPlex = true;
}
}
if (ratingKey4k) {
const children4k =
this.plexSeasonsCache[ratingKey4k] ??
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
[];
this.plexSeasonsCache[ratingKey4k] = children4k;
const seasonMeta4k = children4k?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta4k) {
seasonExistsInPlex4k = true;
}
}
//base case for if both season versions exist in plex
if (seasonExistsInPlex && seasonExistsInPlex4k) {
return true;
}
const existsInSonarr = await this.seasonExistsInSonarr(
media,
season,
seasonExistsInPlex,
seasonExistsInPlex4k
);
if (existsInSonarr) {
logger.warn(
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
return false;
}
private async initPlexClient() {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin) {
logger.warning('No admin configured. Availability sync skipped.');
return;
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

View File

@@ -18,14 +18,14 @@ type ImageResponse = {
imageBuffer: Buffer;
};
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/cache/images`
: path.join(__dirname, '../../config/cache/images');
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
@@ -57,11 +57,7 @@ class ImageProxy {
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(
__dirname,
'../../config/cache/images/',
key
);
const cacheDirectory = path.join(baseCacheDirectory, key);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
@@ -263,7 +259,7 @@ class ImageProxy {
}
private getCacheDirectory() {
return path.join(__dirname, '../../config/cache/images/', this.key);
return path.join(baseCacheDirectory, this.key);
}
}

View File

@@ -264,7 +264,8 @@ export type JobId =
| 'download-sync-reset'
| 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync'
| 'image-cache-cleanup';
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
clientId: string;
@@ -435,6 +436,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
@@ -590,7 +594,7 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
private generateVapidKeys(force = false): void {

View File

@@ -0,0 +1,6 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

View File

@@ -800,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
@@ -829,8 +829,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -685,7 +685,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
}
);
router.get<{ id: string; page?: number }, WatchlistResponse>(
router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
@@ -705,7 +705,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
}
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
@@ -729,8 +729,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,