diff --git a/.all-contributorsrc b/.all-contributorsrc
index c57826d6..113d0af4 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": "
-orange.svg\"/>",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 45da7ba6..1a237571 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 5f0d9da7..2c74cd36 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,23 @@
+<<<<<<< HEAD
+=======
+
+
+
+
+
+
+
+
+
+
+
+>>>>>>> upstream/develop
**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!
@@ -140,4 +154,11 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
## Contributing
+<<<<<<< HEAD
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
+=======
+You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/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 a0756ae2..469994a3 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 bf7c7058..61c1055c 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 1f78cbda..20bc2184 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:
diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts
index 6999c6ba..2d80c65b 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 12228200..47217aa0 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 fad97ef6..61122afc 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 f9eeef50..c55906eb 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 24f93af8..6cc3e825 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/schedule.ts b/server/job/schedule.ts
index 6d960073..435506bc 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -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', {
diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts
new file mode 100644
index 00000000..93ccfe39
--- /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 4ba6b97a..38203b7b 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 930ca280..ebc8f4af 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 00000000..73713e52
--- /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 2c3c665f..f032fa66 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 486ebc36..55a912f3 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 0136113a..34b379e2 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,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
- const {
- data,
- error,
- mutate: revalidate,
- } = useSWR(`/api/v1/collection/${router.query.collectionId}`, {
- fallbackData: collection,
- revalidateOnMount: true,
- });
-
- const { data: genres } =
- useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
-
- const [downloadStatus, downloadStatus4k] = useMemo(() => {
- return [
+ const returnCollectionDownloadItems = (data: Collection | undefined) => {
+ const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
- }, [data?.parts]);
+
+ return { downloadStatus, downloadStatus4k };
+ };
+
+ const {
+ data,
+ error,
+ mutate: revalidate,
+ } = 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(() => {
+ 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 b5bc0cb6..b0d314d1 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 a514d6c0..320dd667 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 48c1f854..ec2ea263 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 b9071b42..74383f13 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 7121f095..7dc8c8d3 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 6f824d38..bc939362 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 c21a9c50..6d3fe7b9 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 97a99e4c..da4344ef 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 ca204210..e39055ea 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';
@@ -197,20 +198,24 @@ const ManageSlideOver = ({
{data.mediaInfo?.downloadStatus?.map((status, index) => (
- -
-
-
+ -
+
+
+
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
- -
-
-
+ -
+
+
+
))}
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx
index 235f5e90..fa79c8d6 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 d0a0113e..38febf9a 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 fffb68de..a4ad2a44 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 2589d515..4f5bb9ac 100644
--- a/src/components/RequestModal/AdvancedRequester/index.tsx
+++ b/src/components/RequestModal/AdvancedRequester/index.tsx
@@ -582,10 +582,10 @@ const AdvancedRequester = ({
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
isSelectedPart(part.id)
? '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/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx
index 4a6b25b8..25c8fd3c 100644
--- a/src/components/RequestModal/TvRequestModal.tsx
+++ b/src/components/RequestModal/TvRequestModal.tsx
@@ -540,7 +540,7 @@ const TvRequestModal = ({
aria-hidden="true"
className={`${
isAllSeasons() ? '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`}
>
@@ -631,7 +631,7 @@ const TvRequestModal = ({
isSelectedSeason(season.seasonNumber)
? '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/RequestModal/index.tsx b/src/components/RequestModal/index.tsx
index e5421fb5..9ef6b405 100644
--- a/src/components/RequestModal/index.tsx
+++ b/src/components/RequestModal/index.tsx
@@ -29,10 +29,10 @@ const RequestModal = ({
return (
{
aria-hidden="true"
className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0'
- } relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
+ } relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
>
{
as="div"
appear
show
- enter="transition ease-in-out duration-300 transform opacity-0"
+ enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
- enterTo="opacuty-100"
- leave="transition ease-in-out duration-300 transform opacity-100"
+ enterTo="opacity-100"
+ leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
diff --git a/src/components/Settings/SettingsAbout/Releases/index.tsx b/src/components/Settings/SettingsAbout/Releases/index.tsx
index cbd2ff9b..390c264a 100644
--- a/src/components/Settings/SettingsAbout/Releases/index.tsx
+++ b/src/components/Settings/SettingsAbout/Releases/index.tsx
@@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
{appDataPath}/cache/images.',
@@ -82,7 +85,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
- interval: 'short' | 'long' | 'fixed';
+ interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@@ -93,10 +96,11 @@ type JobModalState = {
job?: Job;
scheduleHours: number;
scheduleMinutes: number;
+ scheduleSeconds: number;
};
type JobModalAction =
- | { type: 'set'; hours?: number; minutes?: number }
+ | { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'close';
}
@@ -119,6 +123,7 @@ const jobModalReducer = (
job: action.job,
scheduleHours: 1,
scheduleMinutes: 5,
+ scheduleSeconds: 30,
};
case 'set':
@@ -126,6 +131,7 @@ const jobModalReducer = (
...state,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
+ scheduleSeconds: action.seconds ?? state.scheduleSeconds,
};
}
};
@@ -153,6 +159,7 @@ const SettingsJobs = () => {
isOpen: false,
scheduleHours: 1,
scheduleMinutes: 5,
+ scheduleSeconds: 30,
});
const [isSaving, setIsSaving] = useState(false);
const settings = useSettings();
@@ -205,9 +212,11 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try {
- if (jobModalState.job?.interval === 'short') {
+ if (jobModalState.job?.interval === 'seconds') {
+ jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
+ } else if (jobModalState.job?.interval === 'minutes') {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
- } else if (jobModalState.job?.interval === 'long') {
+ } else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else {
// jobs with interval: fixed should not be editable
@@ -249,10 +258,10 @@ const SettingsJobs = () => {
/>
{
{intl.formatMessage(messages.editJobSchedulePrompt)}
- {jobModalState.job?.interval === 'short' ? (
+ {jobModalState.job?.interval === 'seconds' ? (
+
+ ) : jobModalState.job?.interval === 'minutes' ? (
- {currentStatus && (
+ {currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
@@ -169,10 +169,10 @@ const TitleCard = ({
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx
index 88e172d9..6c0064e4 100644
--- a/src/components/TvDetails/index.tsx
+++ b/src/components/TvDetails/index.tsx
@@ -30,6 +30,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 { Disclosure, Transition } from '@headlessui/react';
import {
ArrowRightCircleIcon,
@@ -112,6 +113,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
mutate: revalidate,
} = useSWR(`/api/v1/tv/${router.query.tvId}`, {
fallbackData: tv,
+ refreshInterval: refreshIntervalHelper(
+ {
+ downloadStatus: tv?.mediaInfo?.downloadStatus,
+ downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
+ },
+ 15000
+ ),
});
const { data: ratingData } = useSWR(
@@ -759,18 +767,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)}
diff --git a/src/components/UserList/PlexImportModal.tsx b/src/components/UserList/PlexImportModal.tsx
index 98a24829..11f64398 100644
--- a/src/components/UserList/PlexImportModal.tsx
+++ b/src/components/UserList/PlexImportModal.tsx
@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
aria-hidden="true"
className={`${
isAllUsers() ? '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`}
>
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
isSelectedUser(user.id)
? '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/UserList/index.tsx b/src/components/UserList/index.tsx
index 2071a9de..d4901ed4 100644
--- a/src/components/UserList/index.tsx
+++ b/src/components/UserList/index.tsx
@@ -233,10 +233,10 @@ const UserList = () => {
{
{
{
{
+ if (
+ (downloadItem.downloadStatus ?? []).length > 0 ||
+ (downloadItem.downloadStatus4k ?? []).length > 0
+ ) {
+ return timer;
+ } else {
+ return 0;
+ }
+};