Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
@@ -773,6 +773,42 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"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": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -16,5 +16,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"files.associations": {
|
||||||
|
"globals.css": "tailwindcss"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -2,9 +2,23 @@
|
|||||||
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<<<<<<< HEAD
|
||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
|
=======
|
||||||
|
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20Release/badge.svg?branch=master" alt="Overseerr Release" />
|
||||||
|
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||||
|
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
|
||||||
|
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
|
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
|
||||||
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-88-orange.svg"/></a>
|
||||||
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
>>>>>>> upstream/develop
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**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!
|
**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
|
## 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 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.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
|
|||||||
.should('be.disabled');
|
.should('be.disabled');
|
||||||
|
|
||||||
cy.get('#data').clear();
|
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
|
// Confirming we have some results
|
||||||
cy.contains('.slider-header', sliderTitle)
|
cy.contains('.slider-header', sliderTitle)
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
largePageDataBytes: 256000,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3868,7 +3868,7 @@ paths:
|
|||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
/user/{userId}/requests:
|
/user/{userId}/requests:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get requests for a specific user
|
||||||
description: |
|
description: |
|
||||||
Retrieves a user's requests in a JSON object.
|
Retrieves a user's requests in a JSON object.
|
||||||
tags:
|
tags:
|
||||||
@@ -3964,7 +3964,7 @@ paths:
|
|||||||
example: false
|
example: false
|
||||||
/user/{userId}/watchlist:
|
/user/{userId}/watchlist:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get the Plex watchlist for a specific user
|
||||||
description: |
|
description: |
|
||||||
Retrieves a user's Plex Watchlist in a JSON object.
|
Retrieves a user's Plex Watchlist in a JSON object.
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import ServarrBase from './base';
|
import ServarrBase from './base';
|
||||||
|
|
||||||
interface SonarrSeason {
|
export interface SonarrSeason {
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
statistics?: {
|
statistics?: {
|
||||||
|
|||||||
@@ -115,29 +115,29 @@ class Media {
|
|||||||
@Column({ type: 'datetime', nullable: true })
|
@Column({ type: 'datetime', nullable: true })
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId?: number;
|
public serviceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public serviceId4k?: number;
|
public serviceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId?: number;
|
public externalServiceId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'int' })
|
||||||
public externalServiceId4k?: number;
|
public externalServiceId4k?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug?: string;
|
public externalServiceSlug?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public externalServiceSlug4k?: string;
|
public externalServiceSlug4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey?: string;
|
public ratingKey?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public ratingKey4k?: string;
|
public ratingKey4k?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public jellyfinMediaId?: string;
|
public jellyfinMediaId?: string;
|
||||||
@@ -288,7 +288,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.MOVIE) {
|
if (this.mediaType === MediaType.MOVIE) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -298,7 +300,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
@@ -310,7 +314,9 @@ class Media {
|
|||||||
if (this.mediaType === MediaType.TV) {
|
if (this.mediaType === MediaType.TV) {
|
||||||
if (
|
if (
|
||||||
this.externalServiceId !== undefined &&
|
this.externalServiceId !== undefined &&
|
||||||
this.serviceId !== undefined
|
this.externalServiceId !== null &&
|
||||||
|
this.serviceId !== undefined &&
|
||||||
|
this.serviceId !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId,
|
this.serviceId,
|
||||||
@@ -320,7 +326,9 @@ class Media {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
this.externalServiceId4k !== undefined &&
|
this.externalServiceId4k !== undefined &&
|
||||||
this.serviceId4k !== undefined
|
this.externalServiceId4k !== null &&
|
||||||
|
this.serviceId4k !== undefined &&
|
||||||
|
this.serviceId4k !== null
|
||||||
) {
|
) {
|
||||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||||
this.serviceId4k,
|
this.serviceId4k,
|
||||||
|
|||||||
@@ -1187,3 +1187,5 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default MediaRequest;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import {
|
import {
|
||||||
|
AfterRemove,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
|||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterRemove()
|
||||||
|
public async handleRemoveParent(): Promise<void> {
|
||||||
|
const mediaRequestRepository = getRepository(MediaRequest);
|
||||||
|
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||||
|
where: { id: this.request.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestToBeDeleted.seasons.length === 0) {
|
||||||
|
await mediaRequestRepository.delete({ id: this.request.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
|||||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
import imageproxy from '@server/routes/imageproxy';
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -192,7 +193,8 @@ app
|
|||||||
});
|
});
|
||||||
server.use('/api/v1', routes);
|
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.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
@@ -16,7 +17,7 @@ interface ScheduledJob {
|
|||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
@@ -34,7 +35,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-recently-added-scan',
|
id: 'plex-recently-added-scan',
|
||||||
name: 'Plex Recently Added Scan',
|
name: 'Plex Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['plex-recently-added-scan'].schedule,
|
jobs['plex-recently-added-scan'].schedule,
|
||||||
@@ -54,7 +55,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-full-scan',
|
id: 'plex-full-scan',
|
||||||
name: 'Plex Full Library Scan',
|
name: 'Plex Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['plex-full-scan'].schedule,
|
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||||
@@ -74,7 +75,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'jellyfin-recently-added-sync',
|
id: 'jellyfin-recently-added-sync',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Jellyfin Recently Added Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-sync'].schedule,
|
jobs['jellyfin-recently-added-sync'].schedule,
|
||||||
@@ -94,7 +95,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'jellyfin-full-sync',
|
id: 'jellyfin-full-sync',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||||
@@ -112,7 +113,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'short',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
@@ -127,7 +128,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
name: 'Radarr Scan',
|
name: 'Radarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['radarr-scan'].schedule,
|
cronSchedule: jobs['radarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||||
@@ -142,7 +143,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'sonarr-scan',
|
id: 'sonarr-scan',
|
||||||
name: 'Sonarr Scan',
|
name: 'Sonarr Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['sonarr-scan'].schedule,
|
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||||
@@ -152,12 +153,29 @@ export const startJobs = (): void => {
|
|||||||
cancelFn: () => sonarrScanner.cancel(),
|
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
|
// Run download sync every minute
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'download-sync',
|
id: 'download-sync',
|
||||||
name: 'Download Sync',
|
name: 'Download Sync',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'fixed',
|
interval: 'seconds',
|
||||||
cronSchedule: jobs['download-sync'].schedule,
|
cronSchedule: jobs['download-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||||
logger.debug('Starting scheduled job: Download Sync', {
|
logger.debug('Starting scheduled job: Download Sync', {
|
||||||
@@ -172,7 +190,7 @@ export const startJobs = (): void => {
|
|||||||
id: 'download-sync-reset',
|
id: 'download-sync-reset',
|
||||||
name: 'Download Sync Reset',
|
name: 'Download Sync Reset',
|
||||||
type: 'command',
|
type: 'command',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['download-sync-reset'].schedule,
|
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
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({
|
scheduledJobs.push({
|
||||||
id: 'image-cache-cleanup',
|
id: 'image-cache-cleanup',
|
||||||
name: 'Image Cache Cleanup',
|
name: 'Image Cache Cleanup',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'long',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||||
|
|||||||
718
server/lib/availabilitySync.ts
Normal file
718
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import type { PlexMetadata } from '@server/api/plexapi';
|
||||||
|
import PlexAPI from '@server/api/plexapi';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type { SonarrSeason } from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import { MediaStatus } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
class AvailabilitySync {
|
||||||
|
public running = false;
|
||||||
|
private plexClient: PlexAPI;
|
||||||
|
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
|
||||||
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
|
||||||
|
private radarrServers: RadarrSettings[];
|
||||||
|
private sonarrServers: SonarrSettings[];
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const settings = getSettings();
|
||||||
|
this.running = true;
|
||||||
|
this.plexSeasonsCache = {};
|
||||||
|
this.sonarrSeasonsCache = {};
|
||||||
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||||
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||||
|
await this.initPlexClient();
|
||||||
|
|
||||||
|
if (!this.plexClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting availability sync...`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||||
|
try {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('Job aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaExists = await this.mediaExists(media);
|
||||||
|
|
||||||
|
//We can not delete media so if both versions do not exist, we will change both columns to unknown or null
|
||||||
|
if (!mediaExists) {
|
||||||
|
if (
|
||||||
|
media.status !== MediaStatus.UNKNOWN ||
|
||||||
|
media.status4k !== MediaStatus.UNKNOWN
|
||||||
|
) {
|
||||||
|
const request = await requestRepository.find({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: { media: { id: media.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${
|
||||||
|
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
|
||||||
|
} does not exist in any of your media instances. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
serviceId: null,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestRepository.remove(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
// ok, the show itself exists, but do all it's seasons?
|
||||||
|
const seasons = await seasonRepository.find({
|
||||||
|
where: [
|
||||||
|
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||||
|
{
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
media: { id: media.id },
|
||||||
|
},
|
||||||
|
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||||
|
{
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
media: { id: media.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let didDeleteSeasons = false;
|
||||||
|
for (const season of seasons) {
|
||||||
|
if (
|
||||||
|
!mediaExists &&
|
||||||
|
(season.status !== MediaStatus.UNKNOWN ||
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN)
|
||||||
|
) {
|
||||||
|
await seasonRepository.update(
|
||||||
|
{ id: season.id },
|
||||||
|
{
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const seasonExists = await this.seasonExists(media, season);
|
||||||
|
|
||||||
|
if (!seasonExists) {
|
||||||
|
logger.info(
|
||||||
|
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
season.status !== MediaStatus.UNKNOWN ||
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN
|
||||||
|
) {
|
||||||
|
await seasonRepository.update(
|
||||||
|
{ id: season.id },
|
||||||
|
{
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonToBeDeleted =
|
||||||
|
await seasonRequestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
request: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
request: {
|
||||||
|
media: {
|
||||||
|
id: media.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
didDeleteSeasons = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didDeleteSeasons) {
|
||||||
|
if (
|
||||||
|
media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failure with media.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error('Failed to complete availability sync.', {
|
||||||
|
errorMessage: ex.message,
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
logger.info(`Availability sync complete.`, {
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
});
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *loadAvailableMediaPaginated(pageSize: number) {
|
||||||
|
let offset = 0;
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const whereOptions = [
|
||||||
|
{ status: MediaStatus.AVAILABLE },
|
||||||
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mediaPage: Media[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
yield* (mediaPage = await mediaRepository.find({
|
||||||
|
where: whereOptions,
|
||||||
|
skip: offset,
|
||||||
|
take: pageSize,
|
||||||
|
}));
|
||||||
|
offset += pageSize;
|
||||||
|
} while (mediaPage.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const isTVType = media.mediaType === 'tv';
|
||||||
|
|
||||||
|
const request = await requestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: { media: { id: media.id }, is4k: is4k ? true : false },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
|
||||||
|
isTVType ? 'sonarr' : 'radarr'
|
||||||
|
} and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mediaRepository.update(
|
||||||
|
media.id,
|
||||||
|
is4k
|
||||||
|
? {
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
serviceId4k: null,
|
||||||
|
externalServiceId4k: null,
|
||||||
|
externalServiceSlug4k: null,
|
||||||
|
ratingKey4k: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
serviceId: null,
|
||||||
|
externalServiceId: null,
|
||||||
|
externalServiceSlug: null,
|
||||||
|
ratingKey: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTVType) {
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
|
||||||
|
await seasonRepository?.update(
|
||||||
|
{ media: { id: media.id } },
|
||||||
|
is4k
|
||||||
|
? { status4k: MediaStatus.UNKNOWN }
|
||||||
|
: { status: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestRepository.delete({ id: request?.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInRadarr(
|
||||||
|
media: Media,
|
||||||
|
existsInPlex: boolean,
|
||||||
|
existsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
let existsInRadarr = true;
|
||||||
|
let existsInRadarr4k = true;
|
||||||
|
|
||||||
|
for (const server of this.radarrServers) {
|
||||||
|
const api = new RadarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
const meta = await api.getMovieByTmdbId(media.tmdbId);
|
||||||
|
|
||||||
|
//check if both exist or if a single non-4k or 4k exists
|
||||||
|
//if both do not exist we will return false
|
||||||
|
if (!server.is4k && !meta.id) {
|
||||||
|
existsInRadarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !meta.id) {
|
||||||
|
existsInRadarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr && existsInRadarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInRadarr && existsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInRadarr4k && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||||
|
//related media request will then be deleted
|
||||||
|
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
|
||||||
|
if (media.status !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) {
|
||||||
|
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInRadarr || existsInRadarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
existsInPlex: boolean,
|
||||||
|
existsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!media.tvdbId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existsInSonarr = true;
|
||||||
|
let existsInSonarr4k = true;
|
||||||
|
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const api = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = await api.getSeriesByTvdbId(media.tvdbId);
|
||||||
|
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
|
||||||
|
|
||||||
|
//check if both exist or if a single non-4k or 4k exists
|
||||||
|
//if both do not exist we will return false
|
||||||
|
if (!server.is4k && !meta.id) {
|
||||||
|
existsInSonarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !meta.id) {
|
||||||
|
existsInSonarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr && existsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInSonarr && existsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsInSonarr4k && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||||
|
//related media request will then be deleted
|
||||||
|
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
|
||||||
|
if (media.status !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) {
|
||||||
|
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
this.mediaUpdater(media, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsInSonarr || existsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExistsInSonarr(
|
||||||
|
media: Media,
|
||||||
|
season: Season,
|
||||||
|
seasonExistsInPlex: boolean,
|
||||||
|
seasonExistsInPlex4k: boolean
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!media.tvdbId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seasonExistsInSonarr = true;
|
||||||
|
let seasonExistsInSonarr4k = true;
|
||||||
|
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
for (const server of this.sonarrServers) {
|
||||||
|
const api = new SonarrAPI({
|
||||||
|
apiKey: server.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seasons =
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
|
||||||
|
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
|
||||||
|
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
|
||||||
|
|
||||||
|
const hasMonitoredSeason = seasons.find(
|
||||||
|
({ monitored, seasonNumber }) =>
|
||||||
|
monitored && season.seasonNumber === seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!server.is4k && !hasMonitoredSeason) {
|
||||||
|
seasonExistsInSonarr = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.is4k && !hasMonitoredSeason) {
|
||||||
|
seasonExistsInSonarr4k = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seasonExistsInSonarr && seasonExistsInPlex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonToBeDeleted = await seasonRequestRepository.findOne({
|
||||||
|
relations: {
|
||||||
|
request: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
request: {
|
||||||
|
is4k: seasonExistsInSonarr ? true : false,
|
||||||
|
media: {
|
||||||
|
id: media.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//if season does not exist, we will change status to unknown and delete related season request
|
||||||
|
//if parent media request is empty(all related seasons have been removed), parent is automatically deleted
|
||||||
|
if (
|
||||||
|
!seasonExistsInSonarr &&
|
||||||
|
seasonExistsInSonarr4k &&
|
||||||
|
!seasonExistsInPlex
|
||||||
|
) {
|
||||||
|
if (season.status !== MediaStatus.UNKNOWN) {
|
||||||
|
logger.info(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await seasonRepository.update(season.id, {
|
||||||
|
status: MediaStatus.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status === MediaStatus.AVAILABLE) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
seasonExistsInSonarr &&
|
||||||
|
!seasonExistsInSonarr4k &&
|
||||||
|
!seasonExistsInPlex4k
|
||||||
|
) {
|
||||||
|
if (season.status4k !== MediaStatus.UNKNOWN) {
|
||||||
|
logger.info(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await seasonRepository.update(season.id, {
|
||||||
|
status4k: MediaStatus.UNKNOWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (seasonToBeDeleted) {
|
||||||
|
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
logger.info(
|
||||||
|
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||||
|
{ label: 'AvailabilitySync' }
|
||||||
|
);
|
||||||
|
await mediaRepository.update(media.id, {
|
||||||
|
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonExistsInSonarr || seasonExistsInSonarr4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mediaExists(media: Media): Promise<boolean> {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
|
||||||
|
let existsInPlex = false;
|
||||||
|
let existsInPlex4k = false;
|
||||||
|
|
||||||
|
//check each plex instance to see if media exists
|
||||||
|
try {
|
||||||
|
if (ratingKey) {
|
||||||
|
const meta = await this.plexClient?.getMetadata(ratingKey);
|
||||||
|
if (meta) {
|
||||||
|
existsInPlex = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ratingKey4k) {
|
||||||
|
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
|
||||||
|
if (meta4k) {
|
||||||
|
existsInPlex4k = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
|
||||||
|
if (!ex.message.includes('response code: 404')) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//base case for if both media versions exist in plex
|
||||||
|
if (existsInPlex && existsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
//we then check radarr or sonarr has that specific media. If not, then we will move to delete
|
||||||
|
//if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
||||||
|
if (media.mediaType === 'movie') {
|
||||||
|
const existsInRadarr = await this.mediaExistsInRadarr(
|
||||||
|
media,
|
||||||
|
existsInPlex,
|
||||||
|
existsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
//if true, media exists in at least one radarr or plex instance.
|
||||||
|
if (existsInRadarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.mediaType === 'tv') {
|
||||||
|
const existsInSonarr = await this.mediaExistsInSonarr(
|
||||||
|
media,
|
||||||
|
existsInPlex,
|
||||||
|
existsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
//if true, media exists in at least one sonarr or plex instance.
|
||||||
|
if (existsInSonarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seasonExists(media: Media, season: Season) {
|
||||||
|
const ratingKey = media.ratingKey;
|
||||||
|
const ratingKey4k = media.ratingKey4k;
|
||||||
|
|
||||||
|
let seasonExistsInPlex = false;
|
||||||
|
let seasonExistsInPlex4k = false;
|
||||||
|
|
||||||
|
if (ratingKey) {
|
||||||
|
const children =
|
||||||
|
this.plexSeasonsCache[ratingKey] ??
|
||||||
|
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
|
||||||
|
[];
|
||||||
|
this.plexSeasonsCache[ratingKey] = children;
|
||||||
|
const seasonMeta = children?.find(
|
||||||
|
(child) => child.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonMeta) {
|
||||||
|
seasonExistsInPlex = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingKey4k) {
|
||||||
|
const children4k =
|
||||||
|
this.plexSeasonsCache[ratingKey4k] ??
|
||||||
|
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
|
||||||
|
[];
|
||||||
|
this.plexSeasonsCache[ratingKey4k] = children4k;
|
||||||
|
const seasonMeta4k = children4k?.find(
|
||||||
|
(child) => child.index === season.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (seasonMeta4k) {
|
||||||
|
seasonExistsInPlex4k = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//base case for if both season versions exist in plex
|
||||||
|
if (seasonExistsInPlex && seasonExistsInPlex4k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsInSonarr = await this.seasonExistsInSonarr(
|
||||||
|
media,
|
||||||
|
season,
|
||||||
|
seasonExistsInPlex,
|
||||||
|
seasonExistsInPlex4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existsInSonarr) {
|
||||||
|
logger.warn(
|
||||||
|
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initPlexClient() {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
select: { id: true, plexToken: true },
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
logger.warning('No admin configured. Availability sync skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
export default availabilitySync;
|
||||||
@@ -18,14 +18,14 @@ type ImageResponse = {
|
|||||||
imageBuffer: Buffer;
|
imageBuffer: Buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
||||||
|
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
||||||
|
: path.join(__dirname, '../../config/cache/images');
|
||||||
|
|
||||||
class ImageProxy {
|
class ImageProxy {
|
||||||
public static async clearCache(key: string) {
|
public static async clearCache(key: string) {
|
||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
__dirname,
|
|
||||||
'../../config/cache/images/',
|
|
||||||
key
|
|
||||||
);
|
|
||||||
|
|
||||||
const files = await promises.readdir(cacheDirectory);
|
const files = await promises.readdir(cacheDirectory);
|
||||||
|
|
||||||
@@ -57,11 +57,7 @@ class ImageProxy {
|
|||||||
public static async getImageStats(
|
public static async getImageStats(
|
||||||
key: string
|
key: string
|
||||||
): Promise<{ size: number; imageCount: number }> {
|
): Promise<{ size: number; imageCount: number }> {
|
||||||
const cacheDirectory = path.join(
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
__dirname,
|
|
||||||
'../../config/cache/images/',
|
|
||||||
key
|
|
||||||
);
|
|
||||||
|
|
||||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||||
@@ -263,7 +259,7 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCacheDirectory() {
|
private getCacheDirectory() {
|
||||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
return path.join(baseCacheDirectory, this.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ export type JobId =
|
|||||||
| 'download-sync-reset'
|
| 'download-sync-reset'
|
||||||
| 'jellyfin-recently-added-sync'
|
| 'jellyfin-recently-added-sync'
|
||||||
| 'jellyfin-full-sync'
|
| 'jellyfin-full-sync'
|
||||||
| 'image-cache-cleanup';
|
| 'image-cache-cleanup'
|
||||||
|
| 'availability-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -435,6 +436,9 @@ class Settings {
|
|||||||
'sonarr-scan': {
|
'sonarr-scan': {
|
||||||
schedule: '0 30 4 * * *',
|
schedule: '0 30 4 * * *',
|
||||||
},
|
},
|
||||||
|
'availability-sync': {
|
||||||
|
schedule: '0 0 5 * * *',
|
||||||
|
},
|
||||||
'download-sync': {
|
'download-sync': {
|
||||||
schedule: '0 * * * * *',
|
schedule: '0 * * * * *',
|
||||||
},
|
},
|
||||||
@@ -590,7 +594,7 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateApiKey(): string {
|
private generateApiKey(): string {
|
||||||
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
|
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateVapidKeys(force = false): void {
|
private generateVapidKeys(force = false): void {
|
||||||
|
|||||||
6
server/middleware/clearcookies.ts
Normal file
6
server/middleware/clearcookies.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const clearCookies: Middleware = (_req, res, next) => {
|
||||||
|
res.removeHeader('Set-Cookie');
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default clearCookies;
|
||||||
@@ -800,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||||
'/watchlist',
|
'/watchlist',
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
const page = req.params.page ?? 1;
|
const page = Number(req.query.page) ?? 1;
|
||||||
const offset = (page - 1) * itemsPerPage;
|
const offset = (page - 1) * itemsPerPage;
|
||||||
|
|
||||||
const activeUser = await userRepository.findOne({
|
const activeUser = await userRepository.findOne({
|
||||||
@@ -829,8 +829,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||||
totalResults: watchlist.size,
|
totalResults: watchlist.totalSize,
|
||||||
results: watchlist.items.map((item) => ({
|
results: watchlist.items.map((item) => ({
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
router.get<{ id: string }, WatchlistResponse>(
|
||||||
'/:id/watchlist',
|
'/:id/watchlist',
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
if (
|
if (
|
||||||
@@ -705,7 +705,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
const page = req.params.page ?? 1;
|
const page = Number(req.query.page) ?? 1;
|
||||||
const offset = (page - 1) * itemsPerPage;
|
const offset = (page - 1) * itemsPerPage;
|
||||||
|
|
||||||
const user = await getRepository(User).findOneOrFail({
|
const user = await getRepository(User).findOneOrFail({
|
||||||
@@ -729,8 +729,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||||
totalResults: watchlist.size,
|
totalResults: watchlist.totalSize,
|
||||||
results: watchlist.items.map((item) => ({
|
results: watchlist.items.map((item) => ({
|
||||||
ratingKey: item.ratingKey,
|
ratingKey: item.ratingKey,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { Collection } from '@server/models/Collection';
|
import type { Collection } from '@server/models/Collection';
|
||||||
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
const [requestModal, setRequestModal] = useState(false);
|
const [requestModal, setRequestModal] = useState(false);
|
||||||
const [is4k, setIs4k] = useState(false);
|
const [is4k, setIs4k] = useState(false);
|
||||||
|
|
||||||
const {
|
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||||
data,
|
const [downloadStatus, downloadStatus4k] = [
|
||||||
error,
|
|
||||||
mutate: revalidate,
|
|
||||||
} = useSWR<Collection>(`/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 [
|
|
||||||
data?.parts.flatMap((item) =>
|
data?.parts.flatMap((item) =>
|
||||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||||
),
|
),
|
||||||
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}, [data?.parts]);
|
|
||||||
|
return { downloadStatus, downloadStatus4k };
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
mutate: revalidate,
|
||||||
|
} = useSWR<Collection>(`/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(() => {
|
const [titles, titles4k] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
enter="transition ease-out duration-100 opacity-0"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75 opacity-100"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
appear
|
appear
|
||||||
as="div"
|
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"
|
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"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
<Transition
|
<Transition
|
||||||
appear
|
appear
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition opacity-0 duration-300 transform scale-75"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 scale-75"
|
enterFrom="opacity-0 scale-75"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={loading}
|
show={loading}
|
||||||
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-headline"
|
aria-labelledby="modal-headline"
|
||||||
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
}}
|
}}
|
||||||
appear
|
appear
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300 transform scale-75"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 scale-75"
|
enterFrom="opacity-0 scale-75"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={!loading}
|
show={!loading}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
checked ? 'translate-x-5' : 'translate-x-0'
|
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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const SlideOver = ({
|
|||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={show}
|
show={show}
|
||||||
appear
|
appear
|
||||||
enter="opacity-0 transition ease-in-out duration-300"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition ease-in-out duration-300"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
@@ -58,16 +58,16 @@ const SlideOver = ({
|
|||||||
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
appear
|
appear
|
||||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
enter="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||||
enterFrom="translate-x-full"
|
enterFrom="translate-x-full"
|
||||||
enterTo="translate-x-0"
|
enterTo="translate-x-0"
|
||||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
leave="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="translate-x-full"
|
leaveTo="translate-x-full"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
<div
|
<div
|
||||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
|
||||||
ref={slideoverRef}
|
ref={slideoverRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -165,10 +165,10 @@ const Discover = () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
show={isEditing}
|
show={isEditing}
|
||||||
enter="transition transform duration-300"
|
enter="transition duration-300"
|
||||||
enterFrom="opacity-0 translate-y-6"
|
enterFrom="opacity-0 translate-y-6"
|
||||||
enterTo="opacity-100 translate-y-0"
|
enterTo="opacity-100 translate-y-0"
|
||||||
leave="transition duration-300 transform"
|
leave="transition duration-300"
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 translate-y-6"
|
leaveTo="opacity-0 translate-y-6"
|
||||||
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ const IssueComment = ({
|
|||||||
>
|
>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
@@ -115,11 +115,11 @@ const IssueComment = ({
|
|||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
static
|
static
|
||||||
@@ -164,7 +164,7 @@ const IssueComment = ({
|
|||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
|
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||||
isReversed ? '-left-1' : '-right-1'
|
isReversed ? '-left-1' : '-right-1'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ const IssueDescription = ({
|
|||||||
show={open}
|
show={open}
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items
|
<Menu.Items
|
||||||
static
|
static
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ const IssueDetails = () => {
|
|||||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ interface IssueModalProps {
|
|||||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={show}
|
show={show}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const LanguagePicker = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
show={isDropdownOpen}
|
show={isDropdownOpen}
|
||||||
enter="transition ease-out duration-100 opacity-0"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75 opacity-100"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
||||||
|
|||||||
@@ -131,13 +131,13 @@ const MobileMenu = () => {
|
|||||||
show={isOpen}
|
show={isOpen}
|
||||||
as="div"
|
as="div"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
enter="transition transform duration-500"
|
enter="transition duration-500"
|
||||||
enterFrom="opacity-0 translate-y-0"
|
enterFrom="opacity-0 translate-y-0"
|
||||||
enterTo="opacity-100 -translate-y-full"
|
enterTo="opacity-100 -translate-y-full"
|
||||||
leave="transition duration-500 transform"
|
leave="transition duration-500"
|
||||||
leaveFrom="opacity-100 -translate-y-full"
|
leaveFrom="opacity-100 -translate-y-full"
|
||||||
leaveTo="opacity-0 translate-y-0"
|
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) => {
|
{filteredLinks.map((link) => {
|
||||||
const isActive = router.pathname.match(link.activeRegExp);
|
const isActive = router.pathname.match(link.activeRegExp);
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition ease-in-out duration-300 transform"
|
enter="transition-transform ease-in-out duration-300"
|
||||||
enterFrom="-translate-x-full"
|
enterFrom="-translate-x-full"
|
||||||
enterTo="translate-x-0"
|
enterTo="translate-x-0"
|
||||||
leave="transition ease-in-out duration-300 transform"
|
leave="transition-transform ease-in-out duration-300"
|
||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="-translate-x-full"
|
leaveTo="-translate-x-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ const UserDropdown = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
enterFrom="transform opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="transform opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="transition ease-in duration-75"
|
leave="transition ease-in duration-75"
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
appear
|
appear
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
|
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ const Login = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
show={!!error}
|
show={!!error}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
import SlideOver from '@app/components/Common/SlideOver';
|
import SlideOver from '@app/components/Common/SlideOver';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import DownloadBlock from '@app/components/DownloadBlock';
|
import DownloadBlock from '@app/components/DownloadBlock';
|
||||||
import IssueBlock from '@app/components/IssueBlock';
|
import IssueBlock from '@app/components/IssueBlock';
|
||||||
import RequestBlock from '@app/components/RequestBlock';
|
import RequestBlock from '@app/components/RequestBlock';
|
||||||
@@ -197,20 +198,24 @@ const ManageSlideOver = ({
|
|||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||||
<ul>
|
<ul>
|
||||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||||
<li
|
<Tooltip
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
content={status.title}
|
||||||
>
|
>
|
||||||
<DownloadBlock downloadItem={status} />
|
<li className="border-b border-gray-700 last:border-b-0">
|
||||||
</li>
|
<DownloadBlock downloadItem={status} />
|
||||||
|
</li>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||||
<li
|
<Tooltip
|
||||||
key={`dl-status-${status.externalId}-${index}`}
|
key={`dl-status-${status.externalId}-${index}`}
|
||||||
className="border-b border-gray-700 last:border-b-0"
|
content={status.title}
|
||||||
>
|
>
|
||||||
<DownloadBlock downloadItem={status} is4k />
|
<li className="border-b border-gray-700 last:border-b-0">
|
||||||
</li>
|
<DownloadBlock downloadItem={status} is4k />
|
||||||
|
</li>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleIcon,
|
ArrowRightCircleIcon,
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
@@ -116,6 +117,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
|
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
|
||||||
fallbackData: movie,
|
fallbackData: movie,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: movie?.mediaInfo?.downloadStatus,
|
||||||
|
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ratingData } = useSWR<RTRating>(
|
const { data: ratingData } = useSWR<RTRating>(
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const RegionSelector = ({
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
leave="transition ease-in duration-100"
|
leave="transition-opacity ease-in duration-100"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
|||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
request.type === 'movie'
|
request.type === 'movie'
|
||||||
? `/api/v1/movie/${request.media.tmdbId}`
|
? `/api/v1/movie/${request.media.tmdbId}`
|
||||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||||
|
|
||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||||
inView ? `${url}` : null
|
inView ? `${url}` : null
|
||||||
);
|
);
|
||||||
@@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: request.media.downloadStatus,
|
||||||
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
|||||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import {
|
import {
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
`/api/v1/request/${request.id}`,
|
`/api/v1/request/${request.id}`,
|
||||||
{
|
{
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: request.media.downloadStatus,
|
||||||
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -582,10 +582,10 @@ const AdvancedRequester = ({
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition ease-in duration-300"
|
enter="transition-opacity ease-in duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in duration-100"
|
leave="transition-opacity ease-in duration-100"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
|
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ const CollectionRequestModal = ({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
isAllParts() ? '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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
|
|||||||
isSelectedPart(part.id)
|
isSelectedPart(part.id)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: '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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ const TvRequestModal = ({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
|
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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -631,7 +631,7 @@ const TvRequestModal = ({
|
|||||||
isSelectedSeason(season.seasonNumber)
|
isSelectedSeason(season.seasonNumber)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: '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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ const RequestModal = ({
|
|||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition opacity-100 duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={show}
|
show={show}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
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`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`${
|
className={`${
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
as="div"
|
as="div"
|
||||||
appear
|
appear
|
||||||
show
|
show
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
|
|||||||
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
|
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={isModalOpen}
|
show={isModalOpen}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||||
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
|
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
|
||||||
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
|
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
|
||||||
|
'availability-sync': 'Media Availability Sync',
|
||||||
'radarr-scan': 'Radarr Scan',
|
'radarr-scan': 'Radarr Scan',
|
||||||
'sonarr-scan': 'Sonarr Scan',
|
'sonarr-scan': 'Sonarr Scan',
|
||||||
'download-sync': 'Download Sync',
|
'download-sync': 'Download Sync',
|
||||||
@@ -71,6 +72,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
||||||
editJobScheduleSelectorMinutes:
|
editJobScheduleSelectorMinutes:
|
||||||
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
||||||
|
editJobScheduleSelectorSeconds:
|
||||||
|
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
||||||
imagecache: 'Image Cache',
|
imagecache: 'Image Cache',
|
||||||
imagecacheDescription:
|
imagecacheDescription:
|
||||||
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||||
@@ -82,7 +85,7 @@ interface Job {
|
|||||||
id: JobId;
|
id: JobId;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'short' | 'long' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
nextExecutionTime: string;
|
nextExecutionTime: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -93,10 +96,11 @@ type JobModalState = {
|
|||||||
job?: Job;
|
job?: Job;
|
||||||
scheduleHours: number;
|
scheduleHours: number;
|
||||||
scheduleMinutes: number;
|
scheduleMinutes: number;
|
||||||
|
scheduleSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JobModalAction =
|
type JobModalAction =
|
||||||
| { type: 'set'; hours?: number; minutes?: number }
|
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
|
||||||
| {
|
| {
|
||||||
type: 'close';
|
type: 'close';
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,7 @@ const jobModalReducer = (
|
|||||||
job: action.job,
|
job: action.job,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
|
scheduleSeconds: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'set':
|
case 'set':
|
||||||
@@ -126,6 +131,7 @@ const jobModalReducer = (
|
|||||||
...state,
|
...state,
|
||||||
scheduleHours: action.hours ?? state.scheduleHours,
|
scheduleHours: action.hours ?? state.scheduleHours,
|
||||||
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
||||||
|
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -153,6 +159,7 @@ const SettingsJobs = () => {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
|
scheduleSeconds: 30,
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -205,9 +212,11 @@ const SettingsJobs = () => {
|
|||||||
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
||||||
|
|
||||||
try {
|
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}`;
|
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
||||||
} else if (jobModalState.job?.interval === 'long') {
|
} else if (jobModalState.job?.interval === 'hours') {
|
||||||
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
||||||
} else {
|
} else {
|
||||||
// jobs with interval: fixed should not be editable
|
// jobs with interval: fixed should not be editable
|
||||||
@@ -249,10 +258,10 @@ const SettingsJobs = () => {
|
|||||||
/>
|
/>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={jobModalState.isOpen}
|
show={jobModalState.isOpen}
|
||||||
@@ -291,7 +300,30 @@ const SettingsJobs = () => {
|
|||||||
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
{jobModalState.job?.interval === 'short' ? (
|
{jobModalState.job?.interval === 'seconds' ? (
|
||||||
|
<select
|
||||||
|
name="jobScheduleSeconds"
|
||||||
|
className="inline"
|
||||||
|
value={jobModalState.scheduleSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'set',
|
||||||
|
seconds: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[30, 45, 60].map((v) => (
|
||||||
|
<option value={v} key={`jobScheduleSeconds-${v}`}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.editJobScheduleSelectorSeconds,
|
||||||
|
{
|
||||||
|
jobScheduleSeconds: v,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : jobModalState.job?.interval === 'minutes' ? (
|
||||||
<select
|
<select
|
||||||
name="jobScheduleMinutes"
|
name="jobScheduleMinutes"
|
||||||
className="inline"
|
className="inline"
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ const SettingsLogs = () => {
|
|||||||
/>
|
/>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
appear
|
appear
|
||||||
|
|||||||
@@ -247,10 +247,10 @@ const SettingsServices = () => {
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={deleteServerModal.open}
|
show={deleteServerModal.open}
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -223,10 +223,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
as="div"
|
as="div"
|
||||||
appear
|
appear
|
||||||
show
|
show
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacuty-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ const StatusChecker = () => {
|
|||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
appear
|
appear
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const TitleCard = ({
|
|||||||
: intl.formatMessage(globalMessages.tvshow)}
|
: intl.formatMessage(globalMessages.tvshow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{currentStatus && (
|
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
|
||||||
<div className="pointer-events-none z-40 flex items-center">
|
<div className="pointer-events-none z-40 flex items-center">
|
||||||
<StatusBadgeMini
|
<StatusBadgeMini
|
||||||
status={currentStatus}
|
status={currentStatus}
|
||||||
@@ -154,10 +154,10 @@ const TitleCard = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={isUpdating}
|
show={isUpdating}
|
||||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
@@ -169,10 +169,10 @@ const TitleCard = ({
|
|||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
show={!image || showDetail || showRequestModal}
|
show={!image || showDetail || showRequestModal}
|
||||||
enter="transition transform opacity-0"
|
enter="transition-opacity"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition transform opacity-100"
|
leave="transition-opacity"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import Error from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { Disclosure, Transition } from '@headlessui/react';
|
import { Disclosure, Transition } from '@headlessui/react';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleIcon,
|
ArrowRightCircleIcon,
|
||||||
@@ -112,6 +113,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
|
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
|
||||||
fallbackData: tv,
|
fallbackData: tv,
|
||||||
|
refreshInterval: refreshIntervalHelper(
|
||||||
|
{
|
||||||
|
downloadStatus: tv?.mediaInfo?.downloadStatus,
|
||||||
|
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
|
||||||
|
},
|
||||||
|
15000
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ratingData } = useSWR<RTRating>(
|
const { data: ratingData } = useSWR<RTRating>(
|
||||||
@@ -759,18 +767,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
)}
|
)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={`${
|
className={`${
|
||||||
open ? 'rotate-180 transform' : ''
|
open ? 'rotate-180' : ''
|
||||||
} h-6 w-6 text-gray-500`}
|
} h-6 w-6 text-gray-500`}
|
||||||
/>
|
/>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition duration-100 ease-out"
|
enter="transition-opacity duration-100 ease-out"
|
||||||
enterFrom="transform opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="transform opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition duration-75 ease-out"
|
leave="transition-opacity duration-75 ease-out"
|
||||||
leaveFrom="transform opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="transform opacity-0"
|
leaveTo="opacity-0"
|
||||||
// Not sure why this transition is adding a margin without this here
|
// Not sure why this transition is adding a margin without this here
|
||||||
style={{ margin: '0px' }}
|
style={{ margin: '0px' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`${
|
className={`${
|
||||||
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
|||||||
isSelectedUser(user.id)
|
isSelectedUser(user.id)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
: 'translate-x-0'
|
: '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`}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -233,10 +233,10 @@ const UserList = () => {
|
|||||||
<PageTitle title={intl.formatMessage(messages.users)} />
|
<PageTitle title={intl.formatMessage(messages.users)} />
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={deleteModal.isOpen}
|
show={deleteModal.isOpen}
|
||||||
@@ -262,10 +262,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={createModal.isOpen}
|
show={createModal.isOpen}
|
||||||
@@ -445,10 +445,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showBulkEditModal}
|
show={showBulkEditModal}
|
||||||
@@ -466,10 +466,10 @@ const UserList = () => {
|
|||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
enter="opacity-0 transition duration-300"
|
enter="transition-opacity duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="opacity-100 transition duration-300"
|
leave="transition-opacity duration-300"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={showImportModal}
|
show={showImportModal}
|
||||||
|
|||||||
@@ -631,6 +631,7 @@
|
|||||||
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
|
||||||
"components.Settings.SettingsAbout.uptodate": "Up to Date",
|
"components.Settings.SettingsAbout.uptodate": "Up to Date",
|
||||||
"components.Settings.SettingsAbout.version": "Version",
|
"components.Settings.SettingsAbout.version": "Version",
|
||||||
|
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
|
||||||
"components.Settings.SettingsJobsCache.cache": "Cache",
|
"components.Settings.SettingsJobsCache.cache": "Cache",
|
||||||
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
"components.Settings.SettingsJobsCache.cacheDescription": "Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
|
||||||
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
|
||||||
@@ -649,6 +650,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
||||||
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
||||||
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
||||||
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
|
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
|
||||||
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slideover {
|
.slideover {
|
||||||
padding-top: calc(1rem + env(safe-area-inset-top)) !important;
|
padding-top: calc(0.75rem + env(safe-area-inset-top)) !important;
|
||||||
padding-bottom: calc(1rem + env(safe-area-inset-top)) !important;
|
padding-bottom: calc(0.75rem + env(safe-area-inset-top)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-close-button {
|
.sidebar-close-button {
|
||||||
|
|||||||
18
src/utils/refreshIntervalHelper.ts
Normal file
18
src/utils/refreshIntervalHelper.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
|
|
||||||
|
export const refreshIntervalHelper = (
|
||||||
|
downloadItem: {
|
||||||
|
downloadStatus: DownloadingItem[] | undefined;
|
||||||
|
downloadStatus4k: DownloadingItem[] | undefined;
|
||||||
|
},
|
||||||
|
timer: number
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
(downloadItem.downloadStatus ?? []).length > 0 ||
|
||||||
|
(downloadItem.downloadStatus4k ?? []).length > 0
|
||||||
|
) {
|
||||||
|
return timer;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user