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

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

View File

@@ -773,6 +773,42 @@
"contributions": [
"code"
]
},
{
"login": "lunks",
"name": "Pedro Nascimento",
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
"profile": "http://twitter.com/lunks/",
"contributions": [
"code"
]
},
{
"login": "owenvoke",
"name": "Owen Voke",
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
"profile": "https://voke.dev",
"contributions": [
"code"
]
},
{
"login": "Nimelrian",
"name": "Sebastian K",
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
"profile": "https://github.com/Nimelrian",
"contributions": [
"code"
]
},
{
"login": "jariz",
"name": "jariz",
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
"profile": "https://github.com/jariz",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

@@ -16,5 +16,8 @@
}
],
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
}

View File

@@ -2,9 +2,23 @@
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
</p>
<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://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>
=======
<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>
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
@@ -140,4 +154,11 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
## Contributing
<<<<<<< HEAD
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
=======
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.

View File

@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('time travel{enter}', { delay: 100 });
cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)

View File

@@ -23,5 +23,6 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 256000,
},
};

View File

@@ -3868,7 +3868,7 @@ paths:
$ref: '#/components/schemas/User'
/user/{userId}/requests:
get:
summary: Get user by ID
summary: Get requests for a specific user
description: |
Retrieves a user's requests in a JSON object.
tags:
@@ -3964,7 +3964,7 @@ paths:
example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
summary: Get the Plex watchlist for a specific user
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
const {
data,
error,
mutate: revalidate,
} = useSWR<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 [
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
return { downloadStatus, downloadStatus4k };
};
const {
data,
error,
mutate: revalidate,
} = useSWR<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(() => {
return [

View File

@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<Transition
as={Fragment}
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
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

View File

@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition
appear
as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</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"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}}
appear
as="div"
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}

View File

@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);

View File

@@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment}
show={show}
appear
enter="opacity-0 transition ease-in-out duration-300"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition ease-in-out duration-300"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -58,16 +58,16 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
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"
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"
leaveTo="translate-x-full"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<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}
onClick={(e) => e.stopPropagation()}
>

View File

@@ -165,10 +165,10 @@ const Discover = () => {
</Transition>
<Transition
show={isEditing}
enter="transition transform duration-300"
enter="transition duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform"
leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0"
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"

View File

@@ -65,10 +65,10 @@ const IssueComment = ({
>
<Transition
as={Fragment}
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
@@ -115,11 +115,11 @@ const IssueComment = ({
as={Fragment}
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static
@@ -164,7 +164,7 @@ const IssueComment = ({
</Menu>
)}
<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'
}`}
/>

View File

@@ -57,11 +57,11 @@ const IssueDescription = ({
show={open}
as="div"
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static

View File

@@ -187,10 +187,10 @@ const IssueDetails = () => {
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}

View File

@@ -12,10 +12,10 @@ interface IssueModalProps {
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

View File

@@ -34,12 +34,12 @@ const LanguagePicker = () => {
<Transition
as="div"
show={isDropdownOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"

View File

@@ -131,13 +131,13 @@ const MobileMenu = () => {
show={isOpen}
as="div"
ref={ref}
enter="transition transform duration-500"
enter="transition duration-500"
enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform"
leave="transition duration-500"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);

View File

@@ -128,10 +128,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</Transition.Child>
<Transition.Child
as="div"
enter="transition ease-in-out duration-300 transform"
enter="transition-transform ease-in-out duration-300"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave="transition-transform ease-in-out duration-300"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>

View File

@@ -63,11 +63,11 @@ const UserDropdown = () => {
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
appear
>
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">

View File

@@ -100,10 +100,10 @@ const Login = () => {
<Transition
as="div"
show={!!error}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
import DownloadBlock from '@app/components/DownloadBlock';
import IssueBlock from '@app/components/IssueBlock';
import RequestBlock from '@app/components/RequestBlock';
@@ -197,20 +198,24 @@ const ManageSlideOver = ({
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} />
</li>
</Tooltip>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} is4k />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
))}
</ul>
</div>

View File

@@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowRightCircleIcon,
CloudIcon,
@@ -116,6 +117,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mutate: revalidate,
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
fallbackData: movie,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: movie?.mediaInfo?.downloadStatus,
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(

View File

@@ -122,7 +122,7 @@ const RegionSelector = ({
<Transition
show={open}
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"

View File

@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { withProperties } from '@app/utils/typeHelpers';
import {
ArrowPathIcon,
@@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
);
@@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
});
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({

View File

@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowPathIcon,
CheckIcon,
@@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
`/api/v1/request/${request.id}`,
{
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}
);

View File

@@ -582,10 +582,10 @@ const AdvancedRequester = ({
<Transition
show={open}
enter="transition ease-in duration-300"
enter="transition-opacity ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"

View File

@@ -324,7 +324,7 @@ const CollectionRequestModal = ({
aria-hidden="true"
className={`${
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>
</th>
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -540,7 +540,7 @@ const TvRequestModal = ({
aria-hidden="true"
className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@@ -631,7 +631,7 @@ const TvRequestModal = ({
isSelectedSeason(season.seasonNumber)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -29,10 +29,10 @@ const RequestModal = ({
return (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

View File

@@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
aria-hidden="true"
className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0'
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
} relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
>
<span
className={`${

View File

@@ -214,10 +214,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -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">
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isModalOpen}

View File

@@ -57,6 +57,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'plex-watchlist-sync': 'Plex Watchlist Sync',
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',
@@ -71,6 +72,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
editJobScheduleSelectorSeconds:
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
imagecache: 'Image Cache',
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>.',
@@ -82,7 +85,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@@ -93,10 +96,11 @@ type JobModalState = {
job?: Job;
scheduleHours: number;
scheduleMinutes: number;
scheduleSeconds: number;
};
type JobModalAction =
| { type: 'set'; hours?: number; minutes?: number }
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'close';
}
@@ -119,6 +123,7 @@ const jobModalReducer = (
job: action.job,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
};
case 'set':
@@ -126,6 +131,7 @@ const jobModalReducer = (
...state,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
};
}
};
@@ -153,6 +159,7 @@ const SettingsJobs = () => {
isOpen: false,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
});
const [isSaving, setIsSaving] = useState(false);
const settings = useSettings();
@@ -205,9 +212,11 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try {
if (jobModalState.job?.interval === 'short') {
if (jobModalState.job?.interval === 'seconds') {
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
} else if (jobModalState.job?.interval === 'minutes') {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'long') {
} else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else {
// jobs with interval: fixed should not be editable
@@ -249,10 +258,10 @@ const SettingsJobs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={jobModalState.isOpen}
@@ -291,7 +300,30 @@ const SettingsJobs = () => {
{intl.formatMessage(messages.editJobSchedulePrompt)}
</label>
<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
name="jobScheduleMinutes"
className="inline"

View File

@@ -143,10 +143,10 @@ const SettingsLogs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

View File

@@ -247,10 +247,10 @@ const SettingsServices = () => {
<Transition
as={Fragment}
show={deleteServerModal.open}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -223,10 +223,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -44,10 +44,10 @@ const StatusChecker = () => {
return (
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

View File

@@ -141,7 +141,7 @@ const TitleCard = ({
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
{currentStatus && (
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
<div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini
status={currentStatus}
@@ -154,10 +154,10 @@ const TitleCard = ({
<Transition
as={Fragment}
show={isUpdating}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -169,10 +169,10 @@ const TitleCard = ({
<Transition
as={Fragment}
show={!image || showDetail || showRequestModal}
enter="transition transform opacity-0"
enter="transition-opacity"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition transform opacity-100"
leave="transition-opacity"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

View File

@@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowRightCircleIcon,
@@ -112,6 +113,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
mutate: revalidate,
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
fallbackData: tv,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: tv?.mediaInfo?.downloadStatus,
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(
@@ -759,18 +767,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)}
<ChevronDownIcon
className={`${
open ? 'rotate-180 transform' : ''
open ? 'rotate-180' : ''
} h-6 w-6 text-gray-500`}
/>
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
enter="transition-opacity duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-75 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
// Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }}
>

View File

@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

View File

@@ -233,10 +233,10 @@ const UserList = () => {
<PageTitle title={intl.formatMessage(messages.users)} />
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
@@ -262,10 +262,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={createModal.isOpen}
@@ -445,10 +445,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showBulkEditModal}
@@ -466,10 +466,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showImportModal}

View File

@@ -631,6 +631,7 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.uptodate": "Up to Date",
"components.Settings.SettingsAbout.version": "Version",
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
"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.cacheflushed": "{cachename} cache flushed.",
@@ -649,6 +650,7 @@
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
"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.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.jelly-recently-added-scan": "Jellyfin Recently Added Scan",
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",

View File

@@ -43,8 +43,8 @@
}
.slideover {
padding-top: calc(1rem + env(safe-area-inset-top)) !important;
padding-bottom: calc(1rem + env(safe-area-inset-top)) !important;
padding-top: calc(0.75rem + env(safe-area-inset-top)) !important;
padding-bottom: calc(0.75rem + env(safe-area-inset-top)) !important;
}
.sidebar-close-button {

View 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;
}
};