diff --git a/.all-contributorsrc b/.all-contributorsrc index c57826d68..113d0af47 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -773,6 +773,42 @@ "contributions": [ "code" ] + }, + { + "login": "lunks", + "name": "Pedro Nascimento", + "avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4", + "profile": "http://twitter.com/lunks/", + "contributions": [ + "code" + ] + }, + { + "login": "owenvoke", + "name": "Owen Voke", + "avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4", + "profile": "https://voke.dev", + "contributions": [ + "code" + ] + }, + { + "login": "Nimelrian", + "name": "Sebastian K", + "avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4", + "profile": "https://github.com/Nimelrian", + "contributions": [ + "code" + ] + }, + { + "login": "jariz", + "name": "jariz", + "avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4", + "profile": "https://github.com/jariz", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a34fb9fc..94ba679b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,7 @@ jobs: name: Send Discord Notification needs: semantic-release if: always() - runs-on: self-hosted + runs-on: ubuntu-20.04 steps: - name: Get Build Job Status uses: technote-space/workflow-conclusion-action@v3 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/README.md b/README.md index 5f0d9da78..e32b51407 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ Discord Docker pulls GitHub -

**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers! @@ -141,3 +140,7 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD ## Contributing You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started. + +## Contributors ✨ + +Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr. diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index a0756ae21..469994a3f 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -96,7 +96,7 @@ describe('Discover Customization', () => { .should('be.disabled'); cy.get('#data').clear(); - cy.get('#data').type('time travel{enter}', { delay: 100 }); + cy.get('#data').type('christmas{enter}', { delay: 100 }); // Confirming we have some results cy.contains('.slider-header', sliderTitle) diff --git a/next.config.js b/next.config.js index bf7c70582..61c1055ca 100644 --- a/next.config.js +++ b/next.config.js @@ -23,5 +23,6 @@ module.exports = { }, experimental: { scrollRestoration: true, + largePageDataBytes: 256000, }, }; diff --git a/overseerr-api.yml b/overseerr-api.yml index 7339aa06e..20bc21849 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3868,7 +3868,7 @@ paths: $ref: '#/components/schemas/User' /user/{userId}/requests: get: - summary: Get user by ID + summary: Get requests for a specific user description: | Retrieves a user's requests in a JSON object. tags: @@ -3964,7 +3964,7 @@ paths: example: false /user/{userId}/watchlist: get: - summary: Get user by ID + summary: Get the Plex watchlist for a specific user description: | Retrieves a user's Plex Watchlist in a JSON object. tags: @@ -5876,6 +5876,23 @@ paths: responses: '204': description: Succesfully removed media item + /media/{mediaId}/file: + delete: + summary: Delete media file + description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item /media/{mediaId}/{status}: post: summary: Update media status diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index 1637a8d8e..809c71ef9 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { ); } } + public removeMovie = async (movieId: number): Promise => { + try { + const { id, title } = await this.getMovieByTmdbId(movieId); + await this.axios.delete(`/movie/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); + logger.info(`[Radarr] Removed movie ${title}`); + } catch (e) { + throw new Error(`[Radarr] Failed to remove movie: ${e.message}`); + } + }; } export default RadarrAPI; diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index eca0208c7..2d80c65b4 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?: { @@ -321,6 +321,20 @@ class SonarrAPI extends ServarrBase<{ return newSeasons; } + public removeSerie = async (serieId: number): Promise => { + try { + const { id, title } = await this.getSeriesByTvdbId(serieId); + await this.axios.delete(`/series/${id}`, { + params: { + deleteFiles: true, + addImportExclusion: false, + }, + }); + logger.info(`[Radarr] Removed serie ${title}`); + } catch (e) { + throw new Error(`[Radarr] Failed to remove serie: ${e.message}`); + } + }; } export default SonarrAPI; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 12228200f..47217aa09 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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, 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/index.ts b/server/index.ts index 24f93af8a..6cc3e8258 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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( diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 9863f0659..dff1ea479 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -278,11 +278,11 @@ class JobJellyfinSync { ExtendedEpisodeData.MediaSources?.some((MediaSource) => { return MediaSource.MediaStreams.some((MediaStream) => { if (MediaStream.Type === 'Video') { - if (MediaStream.Width ?? 0 < 2000) { + if ((MediaStream.Width ?? 0) >= 2000) { + total4k += episodeCount; + } else { totalStandard += episodeCount; } - } else { - total4k += episodeCount; } }); }); @@ -311,13 +311,13 @@ class JobJellyfinSync { // setting the status to AVAILABLE if all of a type is there, partially if some, // and then not modifying the status if there are 0 items existingSeason.status = - totalStandard === season.episode_count + totalStandard >= season.episode_count ? MediaStatus.AVAILABLE : totalStandard > 0 ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status; existingSeason.status4k = - this.enable4kShow && total4k === season.episode_count + this.enable4kShow && total4k >= season.episode_count ? MediaStatus.AVAILABLE : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE @@ -329,13 +329,13 @@ class JobJellyfinSync { // This ternary is the same as the ones above, but it just falls back to "UNKNOWN" // if we dont have any items for the season status: - totalStandard === season.episode_count + totalStandard >= season.episode_count ? MediaStatus.AVAILABLE : totalStandard > 0 ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, status4k: - this.enable4kShow && total4k === season.episode_count + this.enable4kShow && total4k >= season.episode_count ? MediaStatus.AVAILABLE : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 6d9600736..bb56ea82e 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -16,7 +16,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 +34,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 +54,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 +74,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 +94,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 +112,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 +127,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 +142,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 +152,30 @@ 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', { 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/imageproxy.ts b/server/lib/imageproxy.ts index 4ba6b97a7..38203b7b3 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -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); } } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 930ca2804..ebc8f4af0 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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 { diff --git a/server/middleware/clearcookies.ts b/server/middleware/clearcookies.ts new file mode 100644 index 000000000..73713e525 --- /dev/null +++ b/server/middleware/clearcookies.ts @@ -0,0 +1,6 @@ +const clearCookies: Middleware = (_req, res, next) => { + res.removeHeader('Set-Cookie'); + next(); +}; + +export default clearCookies; 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/media.ts b/server/routes/media.ts index 8f93116c0..60191e5de 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,4 +1,7 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; +import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -168,6 +171,100 @@ mediaRoutes.delete( } ); +mediaRoutes.delete( + '/:id/file', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + try { + const settings = getSettings(); + const mediaRepository = getRepository(Media); + const media = await mediaRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + const is4k = media.serviceUrl4k !== undefined; + const isMovie = media.mediaType === MediaType.MOVIE; + let serviceSettings; + if (isMovie) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.isDefault && radarr.is4k === is4k + ); + } else { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.isDefault && sonarr.is4k === is4k + ); + } + + if ( + media.serviceId && + media.serviceId >= 0 && + serviceSettings?.id !== media.serviceId + ) { + if (isMovie) { + serviceSettings = settings.radarr.find( + (radarr) => radarr.id === media.serviceId + ); + } else { + serviceSettings = settings.sonarr.find( + (sonarr) => sonarr.id === media.serviceId + ); + } + } + if (!serviceSettings) { + logger.warn( + `There is no default ${ + is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' + }/ server configured. Did you set any of your ${ + is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' + } servers as default?`, + { + label: 'Media Request', + mediaId: media.id, + } + ); + return; + } + let service; + if (isMovie) { + service = new RadarrAPI({ + apiKey: serviceSettings?.apiKey, + url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + } else { + service = new SonarrAPI({ + apiKey: serviceSettings?.apiKey, + url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), + }); + } + + if (isMovie) { + await (service as RadarrAPI).removeMovie( + parseInt( + is4k + ? (media.externalServiceSlug4k as string) + : (media.externalServiceSlug as string) + ) + ); + } else { + const tmdb = new TheMovieDb(); + const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; + if (!tvdbId) { + throw new Error('TVDB ID not found'); + } + await (service as SonarrAPI).removeSerie(tvdbId); + } + + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong fetching media in delete request', { + label: 'Media', + message: e.message, + }); + next({ status: 404, message: 'Media not found' }); + } + } +); + mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( '/:id/watch_data', isAuthenticated(Permission.ADMIN), diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 486ebc368..55a912f36 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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, diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 0136113a9..34b379e24 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; +import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import type { Collection } from '@server/models/Collection'; @@ -39,6 +40,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const [requestModal, setRequestModal] = useState(false); const [is4k, setIs4k] = useState(false); + const returnCollectionDownloadItems = (data: Collection | undefined) => { + const [downloadStatus, downloadStatus4k] = [ + data?.parts.flatMap((item) => + item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : [] + ), + data?.parts.flatMap((item) => + item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : [] + ), + ]; + + return { downloadStatus, downloadStatus4k }; + }; + const { data, error, @@ -46,21 +60,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { } = useSWR(`/api/v1/collection/${router.query.collectionId}`, { fallbackData: collection, revalidateOnMount: true, + refreshInterval: refreshIntervalHelper( + returnCollectionDownloadItems(collection), + 15000 + ), }); const { data: genres } = useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); const [downloadStatus, downloadStatus4k] = useMemo(() => { - return [ - data?.parts.flatMap((item) => - item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : [] - ), - data?.parts.flatMap((item) => - item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : [] - ), - ]; - }, [data?.parts]); + const downloadItems = returnCollectionDownloadItems(data); + return [downloadItems.downloadStatus, downloadItems.downloadStatus4k]; + }, [data]); const [titles, titles4k] = useMemo(() => { return [ 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 6f824d384..bc9393621 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -128,10 +128,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 97a99e4cb..da4344efe 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -100,10 +100,10 @@ const Login = () => { diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 22ee6c0b8..e39055eab 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -1,6 +1,7 @@ import Button from '@app/components/Common/Button'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import SlideOver from '@app/components/Common/SlideOver'; +import Tooltip from '@app/components/Common/Tooltip'; import DownloadBlock from '@app/components/DownloadBlock'; import IssueBlock from '@app/components/IssueBlock'; import RequestBlock from '@app/components/RequestBlock'; @@ -8,11 +9,20 @@ import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline'; -import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid'; +import { + CheckCircleIcon, + DocumentMinusIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; import { IssueStatus } from '@server/constants/issue'; -import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; +import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; @@ -32,8 +42,12 @@ const messages = defineMessages({ manageModalClearMedia: 'Clear Data', manageModalClearMediaWarning: '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.', + manageModalRemoveMediaWarning: + '* This will irreversibly remove this {mediaType} from {arr}, including all files.', openarr: 'Open in {arr}', + removearr: 'Remove from {arr}', openarr4k: 'Open in 4K {arr}', + removearr4k: 'Remove from 4K {arr}', downloadstatus: 'Downloads', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', @@ -88,6 +102,12 @@ const ManageSlideOver = ({ ? `/api/v1/media/${data.mediaInfo.id}/watch_data` : null ); + const { data: radarrData } = useSWR( + '/api/v1/settings/radarr' + ); + const { data: sonarrData } = useSWR( + '/api/v1/settings/sonarr' + ); const deleteMedia = async () => { if (data.mediaInfo) { @@ -96,6 +116,35 @@ const ManageSlideOver = ({ } }; + const deleteMediaFile = async () => { + if (data.mediaInfo) { + await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`); + await axios.delete(`/api/v1/media/${data.mediaInfo.id}`); + revalidate(); + } + }; + + const isDefaultService = () => { + if (data.mediaInfo) { + if (data.mediaInfo.mediaType === MediaType.MOVIE) { + return ( + radarrData?.find( + (radarr) => + radarr.isDefault && radarr.id === data.mediaInfo?.serviceId + ) !== undefined + ); + } else { + return ( + sonarrData?.find( + (sonarr) => + sonarr.isDefault && sonarr.id === data.mediaInfo?.serviceId + ) !== undefined + ); + } + } + return false; + }; + const markAvailable = async (is4k = false) => { if (data.mediaInfo) { await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, { @@ -149,20 +198,24 @@ const ManageSlideOver = ({
    {data.mediaInfo?.downloadStatus?.map((status, index) => ( -
  • - -
  • +
  • + +
  • + ))} {data.mediaInfo?.downloadStatus4k?.map((status, index) => ( -
  • - -
  • +
  • + +
  • + ))}
@@ -328,6 +381,40 @@ const ManageSlideOver = ({ )} + + {hasPermission(Permission.ADMIN) && + data?.mediaInfo?.serviceUrl && + isDefaultService() && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )}
)} @@ -433,21 +520,54 @@ const ManageSlideOver = ({
)} {data?.mediaInfo?.serviceUrl4k && ( - - - + <> + + + + {isDefaultService() && ( +
+ deleteMediaFile()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + className="w-full" + > + + + {intl.formatMessage(messages.removearr4k, { + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + })} + + +
+ {intl.formatMessage( + messages.manageModalRemoveMediaWarning, + { + mediaType: intl.formatMessage( + mediaType === 'movie' + ? messages.movie + : messages.tvshow + ), + arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr', + } + )} +
+
+ )} + )}
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 235f5e905..fa79c8d68 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import { sortCrewPriority } from '@app/utils/creditHelpers'; +import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { ArrowRightCircleIcon, CloudIcon, @@ -116,6 +117,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { mutate: revalidate, } = useSWR(`/api/v1/movie/${router.query.movieId}`, { fallbackData: movie, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: movie?.mediaInfo?.downloadStatus, + downloadStatus4k: movie?.mediaInfo?.downloadStatus4k, + }, + 15000 + ), }); const { data: ratingData } = useSWR( 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 = ({ { request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; + const { data: title, error } = useSWR( inView ? `${url}` : null ); @@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { mutate: revalidate, } = useSWR(`/api/v1/request/${request.id}`, { fallbackData: request, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: request.media.downloadStatus, + downloadStatus4k: request.media.downloadStatus4k, + }, + 15000 + ), }); const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index fffb68de2..a4ad2a442 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; +import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; import { ArrowPathIcon, CheckIcon, @@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { `/api/v1/request/${request.id}`, { fallbackData: request, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: request.media.downloadStatus, + downloadStatus4k: request.media.downloadStatus4k, + }, + 15000 + ), } ); diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 2589d515f..4f5bb9ac6 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -582,10 +582,10 @@ const AdvancedRequester = ({