diff --git a/.vscode/settings.json b/.vscode/settings.json index 45da7ba67..1a2375712 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,8 @@ } ], "editor.formatOnSave": true, - "typescript.preferences.importModuleSpecifier": "non-relative" + "typescript.preferences.importModuleSpecifier": "non-relative", + "files.associations": { + "globals.css": "tailwindcss" + } } diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index eca0208c7..2e423ef39 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -1,7 +1,7 @@ import logger from '@server/logger'; import ServarrBase from './base'; -interface SonarrSeason { +export interface SonarrSeason { seasonNumber: number; monitored: boolean; statistics?: { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 6a681c476..2d1691724 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -114,29 +114,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; public serviceUrl?: string; public serviceUrl4k?: string; @@ -260,7 +260,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, @@ -270,7 +272,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, @@ -282,7 +286,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, @@ -292,7 +298,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, diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index fad97ef6b..61122afc3 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1187,3 +1187,5 @@ export class MediaRequest { } } } + +export default MediaRequest; diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f9eeef501..c55906eb7 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -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) { Object.assign(this, init); } + + @AfterRemove() + public async handleRemoveParent(): Promise { + 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; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 7925dd21f..998abf1f4 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,3 +1,4 @@ +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'; @@ -104,6 +105,23 @@ 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', diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts new file mode 100644 index 000000000..93ccfe391 --- /dev/null +++ b/server/lib/availabilitySync.ts @@ -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 = {}; + private sonarrSeasonsCache: Record = {}; + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 3afbb5093..a6f3a76a0 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -249,7 +249,8 @@ export type JobId = | 'sonarr-scan' | 'download-sync' | 'download-sync-reset' - | 'image-cache-cleanup'; + | 'image-cache-cleanup' + | 'availability-sync'; interface AllSettings { clientId: string; @@ -410,6 +411,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'availability-sync': { + schedule: '0 0 5 * * *', + }, 'download-sync': { schedule: '0 * * * * *', }, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 2c3c665f1..f032fa66b 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -800,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); -discoverRoutes.get<{ page?: number }, WatchlistResponse>( +discoverRoutes.get, 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, diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index bd7707c45..5f50eb14b 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -599,7 +599,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 ( @@ -619,7 +619,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({ @@ -643,8 +643,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, diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index b5bc0cb64..b0d314d1a 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
( appear as="div" className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70" - enter="transition opacity-0 duration-300" + enter="transition-opacity duration-300" enterFrom="opacity-0" enterTo="opacity-100" - leave="transition opacity-100 duration-300" + leave="transition-opacity duration-300" leaveFrom="opacity-100" leaveTo="opacity-0" ref={parentRef} @@ -89,10 +89,10 @@ const Modal = React.forwardRef( (
( }} appear as="div" - enter="transition opacity-0 duration-300 transform scale-75" + enter="transition duration-300" enterFrom="opacity-0 scale-75" enterTo="opacity-100 scale-100" - leave="transition opacity-100 duration-300" + leave="transition-opacity duration-300" leaveFrom="opacity-100" leaveTo="opacity-0" show={!loading} diff --git a/src/components/Common/SlideCheckbox/index.tsx b/src/components/Common/SlideCheckbox/index.tsx index a514d6c03..320dd667f 100644 --- a/src/components/Common/SlideCheckbox/index.tsx +++ b/src/components/Common/SlideCheckbox/index.tsx @@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => { aria-hidden="true" className={`${ checked ? 'translate-x-5' : 'translate-x-0' - } absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} + } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} > ); diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index 48c1f8549..ec2ea263e 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -37,10 +37,10 @@ const SlideOver = ({ as={Fragment} show={show} appear - enter="opacity-0 transition ease-in-out duration-300" + enter="transition-opacity ease-in-out duration-300" enterFrom="opacity-0" enterTo="opacity-100" - leave="opacity-100 transition ease-in-out duration-300" + leave="transition-opacity ease-in-out duration-300" leaveFrom="opacity-100" leaveTo="opacity-0" > @@ -58,16 +58,16 @@ const SlideOver = ({
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
e.stopPropagation()} > diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index b9071b424..74383f13b 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -165,10 +165,10 @@ const Discover = () => { )}
diff --git a/src/components/IssueDetails/IssueDescription/index.tsx b/src/components/IssueDetails/IssueDescription/index.tsx index 7121f0952..7dc8c8d31 100644 --- a/src/components/IssueDetails/IssueDescription/index.tsx +++ b/src/components/IssueDetails/IssueDescription/index.tsx @@ -57,11 +57,11 @@ const IssueDescription = ({ show={open} as="div" enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" + enterFrom="opacity-0 scale-95" + enterTo="opacity-100 scale-100" leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" + leaveFrom="opacity-100 scale-100" + leaveTo="opacity-0 scale-95" > { ( {
{ show={isOpen} as="div" ref={ref} - enter="transition transform duration-500" + enter="transition duration-500" enterFrom="opacity-0 translate-y-0" enterTo="opacity-100 -translate-y-full" - leave="transition duration-500 transform" + leave="transition duration-500" leaveFrom="opacity-100 -translate-y-full" leaveTo="opacity-0 translate-y-0" - className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur" + className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur" > {filteredLinks.map((link) => { const isActive = router.pathname.match(link.activeRegExp); diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 4baf56a67..d9f8ffd51 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -127,10 +127,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => { diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index c21a9c506..6d3fe7b98 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -63,11 +63,11 @@ const UserDropdown = () => { diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index a8c94fbd8..a57000f53 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -54,10 +54,10 @@ const Login = () => { diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index d0a0113eb..38febf9a8 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -122,7 +122,7 @@ const RegionSelector = ({