diff --git a/.all-contributorsrc b/.all-contributorsrc index 3cf5e765c..faa3f7534 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -737,6 +737,24 @@ "contributions": [ "translation" ] + }, + { + "login": "Eclipseop", + "name": "Mackenzie", + "avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4", + "profile": "https://github.com/Eclipseop", + "contributions": [ + "code" + ] + }, + { + "login": "s0up4200", + "name": "soup", + "avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4", + "profile": "https://github.com/s0up4200", + "contributions": [ + "doc" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -745,5 +763,6 @@ "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", - "skipCi": false + "skipCi": false, + "commitConvention": "angular" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8890dcae3..50096840d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: - name: Upload Snap Package uses: actions/upload-artifact@v2 with: - name: overseerr-snap-package-${{ matrix.architecture }} + name: jellyseerr-snap-package-${{ matrix.architecture }} path: ${{ steps.build.outputs.snap }} - name: Review Snap Package uses: diddlesnaps/snapcraft-review-tools-action@v1 diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index bf00e04d7..63f6555c7 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -49,7 +49,7 @@ jobs: - name: Upload Snap Package uses: actions/upload-artifact@v3 with: - name: overseerr-snap-package-${{ matrix.architecture }} + name: jellyseerr-snap-package-${{ matrix.architecture }} path: ${{ steps.build.outputs.snap }} - name: Review Snap Package uses: diddlesnaps/snapcraft-review-tools-action@v1 diff --git a/.gitignore b/.gitignore index 70a5d6f2f..9a8925ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ tsconfig.tsbuildinfo # Webstorm .idea + +# Config Cache Directory +config/cache diff --git a/README.md b/README.md index 614dcadb5..5f0d9da78 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,105 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on ## Current Features -- Jellyfin Support -- Emby Support - - (Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!) - -Along with all the existing Overseerr features: - -- Full Plex integration. Authenticate and manage user access with Plex! +- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex! +- Supports Movies, Shows, Mixed Libraries! +- Ability to change email addresses for smtp purposes +- Ability to import all jellyfin/emby users - Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come! -- Plex library scan, to keep track of the titles which are already available. +- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available. - Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface. - Incredibly simple request management UI. Don't dig through the app to simply approve recent requests! - Granular permission system. - Support for various notification agents. - Mobile-friendly design, for when you need to approve requests on the go! + (Upcoming Features include: Multiple Server Instances, Music Support, and much more!) + With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested. ## Getting Started +#### Pre-requisite (Important) + +_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_ + +### Launching Jellyseerr using Docker + Check out our dockerhub for instructions on how to install and run Jellyseerr: https://hub.docker.com/r/fallenbagel/jellyseerr ### Launching Jellyseerr manually: +#### Windows + +Pre-requisites: + +- Nodejs (atleast LTS version) +- Yarn +- Download the source code from the github (Either develop branch or main for stable) + ```bash +npm i -g win-node-env yarn install yarn run build yarn start ``` +#### Linux + +Pre-requisites: + +- Nodejs (atleast LTS version) +- Yarn +- Git + +```bash +git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr +git checkout main #if you want to run stable instead of develop +yarn install +yarn run build +yarn start +``` + +_Systemd-service:_ + +- assuming jellyseerr was cloned to `/opt/` + and the environmentfile is located at `/etc/jellyseerr` + +service: + +``` +[Unit] +Description=Jellyseerr Service +Wants=network-online.target +After=network-online.target + +[Service] +EnvironmentFile=/etc/jellyseerr/jellyseerr.conf +Environment=NODE_ENV=production +Type=exec +Restart=on-failure +WorkingDirectory=/opt/jellyseerr +ExecStart=/root/.nvm/versions/node/v18.7.0/bin/node dist/index.js + +[Install] +WantedBy=multi-user.target +``` + +Environmentfile: + +``` +# Jellyseerr's default port is 5055, if you want to use both, change this. +# specify on which port to listen +PORT=5055 + +# specify on which interface to listen, by default jellyseerr listens on all interfaces +#HOST=127.0.0.1 + +# Uncomment if your media server is emby instead of jellyfin. +# JELLYFIN_TYPE=emby +``` + ### Packages: Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr) diff --git a/cypress.config.ts b/cypress.config.ts index 07b0c8b1d..457aa3262 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'cypress'; export default defineConfig({ - projectId: 'onnqy3', + projectId: 'xkm1b4', e2e: { baseUrl: 'http://localhost:5055', experimentalSessionAndOrigin: true, diff --git a/docs/extending-overseerr/fail2ban.md b/docs/extending-overseerr/fail2ban.md index 1cf9131f0..4f2b1c594 100644 --- a/docs/extending-overseerr/fail2ban.md +++ b/docs/extending-overseerr/fail2ban.md @@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"" ``` -You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. +You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md index 820430736..477129fc9 100644 --- a/docs/using-overseerr/settings/README.md +++ b/docs/using-overseerr/settings/README.md @@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can This setting is **disabled** by default. +### Enable Image Caching + +When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space. + +Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours. + +You should enable this if you are having issues with loading images directly from TMDB in your browser. + ### Display Language Set the default display language for Overseerr. Users can override this setting in their user settings. diff --git a/overseerr-api.yml b/overseerr-api.yml index 33052ad4f..128729f06 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2667,29 +2667,44 @@ paths: content: application/json: schema: - type: array - items: - type: object - properties: - id: - type: string - example: cache-id - name: - type: string - example: cache name - stats: + type: object + properties: + imageCache: + type: object + properties: + tmdb: + type: object + properties: + size: + type: number + example: 123456 + imageCount: + type: number + example: 123 + apiCaches: + type: array + items: type: object properties: - hits: - type: number - misses: - type: number - keys: - type: number - ksize: - type: number - vsize: - type: number + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: number + misses: + type: number + keys: + type: number + ksize: + type: number + vsize: + type: number /settings/cache/{cacheId}/flush: post: summary: Flush a specific cache @@ -4838,9 +4853,13 @@ paths: type: number example: 123 seasons: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + minimum: 1 + - type: string + enum: [all] is4k: type: boolean example: false @@ -4919,7 +4938,7 @@ paths: $ref: '#/components/schemas/MediaRequest' put: summary: Update MediaRequest - description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission. + description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. tags: - request parameters: @@ -4930,6 +4949,37 @@ paths: example: '1' schema: type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + seasons: + type: array + items: + type: number + minimum: 1 + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string + languageProfileId: + type: number + userId: + type: number + nullable: true + required: + - mediaType responses: '200': description: Succesfully updated request diff --git a/package.json b/package.json index b51f7c98f..77e02ad14 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,7 @@ { "path": "semantic-release-docker-buildx", "buildArgs": { - "COMMIT_TAG": "$GITHUB_SHA" + "COMMIT_TAG": "$GIT_SHA" }, "imageNames": [ "fallenbagel/jellyseerr" diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 79b0778a9..b126b55f1 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -38,6 +38,7 @@ export interface JellyfinLibraryItem { SeasonId?: string; SeasonName?: string; IndexNumber?: number; + IndexNumberEnd?: number; ParentIndexNumber?: number; MediaType: string; } @@ -178,8 +179,10 @@ class JellyfinAPI { (Item: any) => { return ( Item.Type === 'CollectionFolder' && - (Item.CollectionType === 'tvshows' || - Item.CollectionType === 'movies') + Item.CollectionType !== 'music' && + Item.CollectionType !== 'books' && + Item.CollectionType !== 'musicvideos' && + Item.CollectionType !== 'homevideos' ); } ).map((Item: any) => { @@ -204,7 +207,7 @@ class JellyfinAPI { public async getLibraryContents(id: string): Promise { try { const contents = await this.axios.get( - `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}` + `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}` ); return contents.data.Items.filter( diff --git a/server/entity/Media.ts b/server/entity/Media.ts index cf6f5a280..12228200f 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -205,8 +205,8 @@ class Media { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; if (this.jellyfinMediaId) { diff --git a/server/entity/User.ts b/server/entity/User.ts index b5f781109..8780e2d8a 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -39,7 +39,7 @@ export class User { return users.map((u) => u.filter(showFiltered)); } - static readonly filteredFields: string[] = ['email']; + static readonly filteredFields: string[] = ['email', 'plexId']; public displayName: string; @@ -76,7 +76,7 @@ export class User { @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; - @Column({ nullable: true }) + @Column({ nullable: true, select: true }) public plexId?: number; @Column({ nullable: true }) diff --git a/server/index.ts b/server/index.ts index 615e789bf..b6eb13f5f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ import WebPushAgent from '@server/lib/notifications/agents/webpush'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import routes from '@server/routes'; +import imageproxy from '@server/routes/imageproxy'; import { getAppVersion } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; @@ -186,6 +187,9 @@ app next(); }); server.use('/api/v1', routes); + + server.use('/imageproxy', imageproxy); + server.get('*', (req, res) => handle(req, res)); server.use( ( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index bafd15b1f..32ed6a542 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -54,6 +54,11 @@ export interface CacheItem { }; } +export interface CacheResponse { + apiCaches: CacheItem[]; + imageCache: Record<'tmdb', { size: number; imageCount: number }>; +} + export interface StatusResponse { version: string; commitTag: string; diff --git a/server/job/jellyfinsync/index.ts b/server/job/jellyfinsync/index.ts index 85c8dcc58..9863f0659 100644 --- a/server/job/jellyfinsync/index.ts +++ b/server/job/jellyfinsync/index.ts @@ -257,8 +257,19 @@ class JobJellyfinSync { //use for loop to make sure this loop _completes_ in full //before the next section for (const episode of episodes) { + let episodeCount = 1; + + // count number of combined episodes + if ( + episode.IndexNumber !== undefined && + episode.IndexNumberEnd !== undefined + ) { + episodeCount = + episode.IndexNumberEnd - episode.IndexNumber + 1; + } + if (!this.enable4kShow) { - totalStandard++; + totalStandard += episodeCount; } else { const ExtendedEpisodeData = await this.jfClient.getItemData( episode.Id @@ -268,10 +279,10 @@ class JobJellyfinSync { return MediaSource.MediaStreams.some((MediaStream) => { if (MediaStream.Type === 'Video') { if (MediaStream.Width ?? 0 < 2000) { - totalStandard++; + totalStandard += episodeCount; } } else { - total4k++; + total4k += episodeCount; } }); }); diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 356c475e5..6d9600736 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,5 +1,6 @@ import { MediaServerType } from '@server/constants/server'; import downloadTracker from '@server/lib/downloadtracker'; +import ImageProxy from '@server/lib/imageproxy'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; @@ -181,5 +182,21 @@ export const startJobs = (): void => { }), }); + // Run image cache cleanup every 5 minutes + scheduledJobs.push({ + id: 'image-cache-cleanup', + name: 'Image Cache Cleanup', + type: 'process', + interval: 'long', + cronSchedule: jobs['image-cache-cleanup'].schedule, + job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => { + logger.info('Starting scheduled job: Image Cache Cleanup', { + label: 'Jobs', + }); + // Clean TMDB image cache + ImageProxy.clearCache('tmdb'); + }), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts new file mode 100644 index 000000000..34a097d5f --- /dev/null +++ b/server/lib/imageproxy.ts @@ -0,0 +1,268 @@ +import logger from '@server/logger'; +import axios from 'axios'; +import rateLimit, { type rateLimitOptions } from 'axios-rate-limit'; +import { createHash } from 'crypto'; +import { promises } from 'fs'; +import path, { join } from 'path'; + +type ImageResponse = { + meta: { + revalidateAfter: number; + curRevalidate: number; + isStale: boolean; + etag: string; + extension: string; + cacheKey: string; + cacheMiss: boolean; + }; + imageBuffer: Buffer; +}; + +class ImageProxy { + public static async clearCache(key: string) { + let deletedImages = 0; + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + key + ); + + const files = await promises.readdir(cacheDirectory); + + for (const file of files) { + const filePath = path.join(cacheDirectory, file); + const stat = await promises.lstat(filePath); + + if (stat.isDirectory()) { + const imageFiles = await promises.readdir(filePath); + + for (const imageFile of imageFiles) { + const [, expireAtSt] = imageFile.split('.'); + const expireAt = Number(expireAtSt); + const now = Date.now(); + + if (now > expireAt) { + await promises.rm(path.join(filePath, imageFile)); + deletedImages += 1; + } + } + } + } + + logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { + label: 'Image Cache', + }); + } + + public static async getImageStats( + key: string + ): Promise<{ size: number; imageCount: number }> { + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + key + ); + + const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory); + const imageCount = await ImageProxy.getImageCount(cacheDirectory); + + return { + size: imageTotalSize, + imageCount, + }; + } + + private static async getDirectorySize(dir: string): Promise { + const files = await promises.readdir(dir, { + withFileTypes: true, + }); + + const paths = files.map(async (file) => { + const path = join(dir, file.name); + + if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); + + if (file.isFile()) { + const { size } = await promises.stat(path); + + return size; + } + + return 0; + }); + + return (await Promise.all(paths)) + .flat(Infinity) + .reduce((i, size) => i + size, 0); + } + + private static async getImageCount(dir: string) { + const files = await promises.readdir(dir); + + return files.length; + } + + private axios; + private cacheVersion; + private key; + + constructor( + key: string, + baseUrl: string, + options: { + cacheVersion?: number; + rateLimitOptions?: rateLimitOptions; + } = {} + ) { + this.cacheVersion = options.cacheVersion ?? 1; + this.key = key; + this.axios = axios.create({ + baseURL: baseUrl, + }); + + if (options.rateLimitOptions) { + this.axios = rateLimit(this.axios, options.rateLimitOptions); + } + } + + public async getImage(path: string): Promise { + const cacheKey = this.getCacheKey(path); + + const imageResponse = await this.get(cacheKey); + + if (!imageResponse) { + const newImage = await this.set(path, cacheKey); + + if (!newImage) { + throw new Error('Failed to load image'); + } + + return newImage; + } + + // If the image is stale, we will revalidate it in the background. + if (imageResponse.meta.isStale) { + this.set(path, cacheKey); + } + + return imageResponse; + } + + private async get(cacheKey: string): Promise { + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const files = await promises.readdir(directory); + const now = Date.now(); + + for (const file of files) { + const [maxAgeSt, expireAtSt, etag, extension] = file.split('.'); + const buffer = await promises.readFile(join(directory, file)); + const expireAt = Number(expireAtSt); + const maxAge = Number(maxAgeSt); + + return { + meta: { + curRevalidate: maxAge, + revalidateAfter: maxAge * 1000 + now, + isStale: now > expireAt, + etag, + extension, + cacheKey, + cacheMiss: false, + }, + imageBuffer: buffer, + }; + } + } catch (e) { + // No files. Treat as empty cache. + } + + return null; + } + + private async set( + path: string, + cacheKey: string + ): Promise { + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const response = await this.axios.get(path, { + responseType: 'arraybuffer', + }); + + const buffer = Buffer.from(response.data, 'binary'); + const extension = path.split('.').pop() ?? ''; + const maxAge = Number(response.headers['cache-control'].split('=')[1]); + const expireAt = Date.now() + maxAge * 1000; + const etag = response.headers.etag.replace(/"/g, ''); + + await this.writeToCacheDir( + directory, + extension, + maxAge, + expireAt, + buffer, + etag + ); + + return { + meta: { + curRevalidate: maxAge, + revalidateAfter: expireAt, + isStale: false, + etag, + extension, + cacheKey, + cacheMiss: true, + }, + imageBuffer: buffer, + }; + } catch (e) { + logger.debug('Something went wrong caching image.', { + label: 'Image Cache', + errorMessage: e.message, + }); + return null; + } + } + + private async writeToCacheDir( + dir: string, + extension: string, + maxAge: number, + expireAt: number, + buffer: Buffer, + etag: string + ) { + const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`); + + await promises.rm(dir, { force: true, recursive: true }).catch(() => { + // do nothing + }); + + await promises.mkdir(dir, { recursive: true }); + await promises.writeFile(filename, buffer); + } + + private getCacheKey(path: string) { + return this.getHash([this.key, this.cacheVersion, path]); + } + + private getHash(items: (string | number | Buffer)[]) { + const hash = createHash('sha256'); + for (const item of items) { + if (typeof item === 'number') hash.update(String(item)); + else { + hash.update(item); + } + } + // See https://en.wikipedia.org/wiki/Base64#Filenames + return hash.digest('base64').replace(/\//g, '-'); + } + + private getCacheDirectory() { + return path.join(__dirname, '../../config/cache/images/', this.key); + } +} + +export default ImageProxy; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 29e2fcf13..930ca2804 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -38,7 +38,7 @@ export interface PlexSettings { export interface JellyfinSettings { name: string; - hostname?: string; + hostname: string; externalHostname?: string; libraries: Library[]; serverId: string; @@ -263,7 +263,8 @@ export type JobId = | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-sync' - | 'jellyfin-full-sync'; + | 'jellyfin-full-sync' + | 'image-cache-cleanup'; interface AllSettings { clientId: string; @@ -446,6 +447,9 @@ class Settings { 'jellyfin-full-sync': { schedule: '0 0 3 * * *', }, + 'image-cache-cleanup': { + schedule: '0 0 5 * * *', + }, }, }; if (initialSettings) { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 0280a4286..1dabcdf31 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => { await userRepository.save(user); } else { const mainUser = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true, plexId: true }, + select: { id: true, plexToken: true, plexId: true, email: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + if (!account.id) { + logger.error('Plex ID was missing from Plex.tv response', { + label: 'API', + ip: req.ip, + email: account.email, + plexUsername: account.username, + }); + + return next({ + status: 500, + message: 'Something went wrong. Try again.', + }); + } + if ( account.id === mainUser.plexId || + (account.email === mainUser.email && !mainUser.plexId) || (await mainPlexTv.checkUserAccess(account.id)) ) { if (user) { @@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { const hostname = settings.jellyfin.hostname !== '' ? settings.jellyfin.hostname - : body.hostname; + : body.hostname ?? ''; const { externalHostname } = getSettings().jellyfin; // Try to find deviceId that corresponds to jellyfin user, else generate a new one @@ -249,8 +264,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; const account = await jellyfinserver.login(body.username, body.password); diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts new file mode 100644 index 000000000..6cf104f52 --- /dev/null +++ b/server/routes/imageproxy.ts @@ -0,0 +1,39 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, +}); + +/** + * Image Proxy + */ +router.get('/*', async (req, res) => { + const imagePath = req.path.replace('/image', ''); + try { + const imageData = await tmdbImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).send(); + } +}); + +export default router; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index dc9dbcb42..005076e10 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -16,9 +16,10 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync'; import { scheduledJobs } from '@server/job/schedule'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; +import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { plexFullScanner } from '@server/lib/scanners/plex'; -import type { Library, MainSettings } from '@server/lib/settings'; +import type { JobId, Library, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -312,8 +313,8 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ @@ -604,7 +605,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { }); }); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/cancel', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -631,7 +632,7 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -666,16 +667,23 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.get('/cache', (req, res) => { - const caches = cacheManager.getAllCaches(); +settingsRoutes.get('/cache', async (_req, res) => { + const cacheManagerCaches = cacheManager.getAllCaches(); - return res.status(200).json( - Object.values(caches).map((cache) => ({ - id: cache.id, - name: cache.name, - stats: cache.getStats(), - })) - ); + const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })); + + const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + + return res.status(200).json({ + apiCaches, + imageCache: { + tmdb: tmdbImageCache, + }, + }); }); settingsRoutes.post<{ cacheId: AvailableCacheIds }>( diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 16d8a50be..486ebc368 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -502,8 +502,8 @@ router.post( ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 3b693643a..61db367ac 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,10 +1,11 @@ -name: overseerr -adopt-info: overseerr +name: jellyseerr +adopt-info: jellyseerr license: MIT -summary: Request management and media discovery tool for the Plex ecosystem. +summary: Request management and media discovery tool for media servers description: > - Overseerr is a free and open source software application for managing requests for your media library. - It integrates with your existing services such as Sonarr, Radarr and Plex! + 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 & focusing mainly on Jellyfin & Emby media servers! + It integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex. base: core18 confinement: strict @@ -14,7 +15,7 @@ architectures: - build-on: armhf parts: - overseerr: + jellyseerr: plugin: nodejs nodejs-version: '16.17.0' nodejs-package-manager: 'yarn' @@ -36,7 +37,7 @@ parts: override-pull: | snapcraftctl pull # Get information to determine snap grade and version - git config --global --add safe.directory /data/parts/overseerr/src + git config --global --add safe.directory /data/parts/jellyyseerr/src #setup yarn.rc echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc BRANCH=$(git rev-parse --abbrev-ref HEAD) diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index b16959372..6dfb8ee75 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -1,18 +1,27 @@ import useSettings from '@app/hooks/useSettings'; -import type { ImageProps } from 'next/image'; +import type { ImageLoader, ImageProps } from 'next/image'; import Image from 'next/image'; +const imageLoader: ImageLoader = ({ src }) => src; + /** * The CachedImage component should be used wherever * we want to offer the option to locally cache images. - * - * It uses the `next/image` Image component but overrides - * the `unoptimized` prop based on the application setting `cacheImages`. **/ -const CachedImage = (props: ImageProps) => { +const CachedImage = ({ src, ...props }: ImageProps) => { const { currentSettings } = useSettings(); - return ; + let imageUrl = src; + + if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { + const parsedUrl = new URL(imageUrl); + + if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { + imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); + } + } + + return ; }; export default CachedImage; diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx index 762d1a08b..13b92a333 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/CompanyCard/index.tsx @@ -1,3 +1,4 @@ +import CachedImage from '@app/components/Common/CachedImage'; import Link from 'next/link'; import { useState } from 'react'; @@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => { role="link" tabIndex={0} > - {name} +
+ +
{ : null ); + const { mediaUrl, mediaUrl4k } = useDeepLinks({ + mediaUrl: data?.mediaInfo?.mediaUrl, + mediaUrl4k: data?.mediaInfo?.mediaUrl4k, + iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, + iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, + }); + const CommentSchema = Yup.object().shape({ message: Yup.string().required(), }); @@ -359,7 +367,7 @@ const IssueDetails = () => { {issueData?.media.mediaUrl && (
diff --git a/src/components/MediaSlider/ShowMoreCard/index.tsx b/src/components/MediaSlider/ShowMoreCard/index.tsx index 99900ac9a..2d3cee23c 100644 --- a/src/components/MediaSlider/ShowMoreCard/index.tsx +++ b/src/components/MediaSlider/ShowMoreCard/index.tsx @@ -1,6 +1,8 @@ +import TitleCard from '@app/components/TitleCard'; import { ArrowCircleRightIcon } from '@heroicons/react/solid'; import Link from 'next/link'; import { useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -15,6 +17,18 @@ interface ShowMoreCardProps { const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => { const intl = useIntl(); const [isHovered, setHovered] = useState(false); + const { ref, inView } = useInView({ + triggerOnce: true, + }); + + if (!inView) { + return ( +
+ +
+ ); + } + return ( { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); - const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); - const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); - - useEffect(() => { - if (data) { - if ( - settings.currentSettings.mediaServerType === MediaServerType.PLEX && - (/iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) - ) { - setPlexUrl(data.mediaInfo?.iOSPlexUrl); - setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k); - } else { - setPlexUrl(data.mediaInfo?.mediaUrl); - setPlexUrl4k(data.mediaInfo?.mediaUrl4k); - } - } - }, [ - data, - data?.mediaInfo?.iOSPlexUrl, - data?.mediaInfo?.iOSPlexUrl4k, - data?.mediaInfo?.mediaUrl, - data?.mediaInfo?.mediaUrl4k, - settings.currentSettings.mediaServerType, - ]); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: data?.mediaInfo?.mediaUrl, + mediaUrl4k: data?.mediaInfo?.mediaUrl4k, + iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, + iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, + }); if (!data && !error) { return ; @@ -378,7 +360,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { } tmdbId={data.mediaInfo?.tmdbId} mediaType="movie" - plexUrl={plexUrl} + plexUrl={plexUrl4k} serviceUrl={data.mediaInfo?.serviceUrl4k} /> )} diff --git a/src/components/PullToRefresh/index.tsx b/src/components/PullToRefresh/index.tsx index ce92ea605..dd782dbed 100644 --- a/src/components/PullToRefresh/index.tsx +++ b/src/components/PullToRefresh/index.tsx @@ -1,15 +1,17 @@ import { RefreshIcon } from '@heroicons/react/outline'; -import Router from 'next/router'; +import { useRouter } from 'next/router'; import PR from 'pulltorefreshjs'; import { useEffect } from 'react'; import ReactDOMServer from 'react-dom/server'; -const PullToRefresh: React.FC = () => { +const PullToRefresh = () => { + const router = useRouter(); + useEffect(() => { PR.init({ mainElement: '#pull-to-refresh', onRefresh() { - Router.reload(); + router.reload(); }, iconArrow: ReactDOMServer.renderToString(
@@ -28,11 +30,14 @@ const PullToRefresh: React.FC = () => { instructionsReleaseToRefresh: ReactDOMServer.renderToString(
), instructionsRefreshing: ReactDOMServer.renderToString(
), distReload: 60, + distIgnore: 15, + shouldPullToRefresh: () => + !window.scrollY && document.body.style.overflow !== 'hidden', }); return () => { PR.destroyAll(); }; - }, []); + }, [router]); return
; }; diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 9ccbcde02..27bb33834 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -4,6 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage'; import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; 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 { withProperties } from '@app/utils/typeHelpers'; @@ -61,6 +62,13 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { const { hasPermission } = useUser(); const intl = useIntl(); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: requestData?.media?.mediaUrl, + mediaUrl4k: requestData?.media?.mediaUrl4k, + iOSPlexUrl: requestData?.media?.iOSPlexUrl, + iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, + }); + const deleteRequest = async () => { await axios.delete(`/api/v1/media/${requestData?.media.id}`); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); @@ -138,11 +146,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { ).length > 0 } is4k={requestData.is4k} - plexUrl={ - requestData.is4k - ? requestData.media.mediaUrl4k - : requestData.media.mediaUrl - } + plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k ? requestData.media.serviceUrl4k @@ -217,6 +221,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { fallbackData: request, }); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: requestData?.media?.mediaUrl, + mediaUrl4k: requestData?.media?.mediaUrl4k, + iOSPlexUrl: requestData?.media?.iOSPlexUrl, + iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, + }); + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.post(`/api/v1/request/${request.id}/${type}`); @@ -396,11 +407,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { is4k={requestData.is4k} tmdbId={requestData.media.tmdbId} mediaType={requestData.type} - plexUrl={ - requestData.is4k - ? requestData.media.mediaUrl4k - : requestData.media.mediaUrl - } + plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k ? requestData.media.serviceUrl4k diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 6c232dc89..e5a00de75 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -4,6 +4,7 @@ import CachedImage from '@app/components/Common/CachedImage'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import RequestModal from '@app/components/RequestModal'; 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 { @@ -61,6 +62,13 @@ const RequestItemError = ({ revalidateList(); }; + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: requestData?.media?.mediaUrl, + mediaUrl4k: requestData?.media?.mediaUrl4k, + iOSPlexUrl: requestData?.media?.iOSPlexUrl, + iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, + }); + return (
@@ -130,11 +138,7 @@ const RequestItemError = ({ ).length > 0 } is4k={requestData.is4k} - plexUrl={ - requestData.is4k - ? requestData.media.mediaUrl4k - : requestData.media.mediaUrl - } + plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k ? requestData.media.serviceUrl4k @@ -316,6 +320,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { } }; + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: requestData?.media?.mediaUrl, + mediaUrl4k: requestData?.media?.mediaUrl4k, + iOSPlexUrl: requestData?.media?.iOSPlexUrl, + iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, + }); + if (!title && !error) { return (
{ is4k={requestData.is4k} tmdbId={requestData.media.tmdbId} mediaType={requestData.type} - plexUrl={ - requestData.is4k - ? requestData.media.mediaUrl4k - : requestData.media.mediaUrl - } + plexUrl={requestData.is4k ? plexUrl4k : plexUrl} serviceUrl={ requestData.is4k ? requestData.media.serviceUrl4k diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 7317c8e82..f3402e2ed 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -13,7 +13,10 @@ import { Transition } from '@headlessui/react'; import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline'; import { PencilIcon } from '@heroicons/react/solid'; import { MediaServerType } from '@server/constants/server'; -import type { CacheItem } from '@server/interfaces/api/settingsInterfaces'; +import type { + CacheItem, + CacheResponse, +} from '@server/interfaces/api/settingsInterfaces'; import type { JobId } from '@server/lib/settings'; import axios from 'axios'; import cronstrue from 'cronstrue/i18n'; @@ -58,6 +61,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', + 'image-cache-cleanup': 'Image Cache Cleanup', editJobSchedule: 'Modify Job', jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditFailed: 'Something went wrong while saving the job.', @@ -67,6 +71,11 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}', editJobScheduleSelectorMinutes: 'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}', + 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 {appDataPath}/cache/images.', + imagecachecount: 'Images Cached', + imagecachesize: 'Total Cache Size', }); interface Job { @@ -132,7 +141,8 @@ const SettingsJobs = () => { } = useSWR('/api/v1/settings/jobs', { refreshInterval: 5000, }); - const { data: cacheData, mutate: cacheRevalidate } = useSWR( + const { data: appData } = useSWR('/api/v1/status/appdata'); + const { data: cacheData, mutate: cacheRevalidate } = useSWR( '/api/v1/settings/cache', { refreshInterval: 10000, @@ -435,7 +445,7 @@ const SettingsJobs = () => { - {cacheData + {cacheData?.apiCaches ?.filter( (cache) => !( @@ -465,6 +475,41 @@ const SettingsJobs = () => {
+
+

{intl.formatMessage(messages.imagecache)}

+

+ {intl.formatMessage(messages.imagecacheDescription, { + code: (msg: React.ReactNode) => ( + {msg} + ), + appDataPath: appData ? appData.appDataPath : '/app/config', + })} +

+
+
+ + + + {intl.formatMessage(messages.cachename)} + + {intl.formatMessage(messages.imagecachecount)} + + {intl.formatMessage(messages.imagecachesize)} + + + + + The Movie Database (tmdb) + + {intl.formatNumber(cacheData?.imageCache.tmdb.imageCount ?? 0)} + + + {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} + + + +
+
); }; diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index ef0810f5e..7d4e188e5 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -46,7 +46,7 @@ const messages = defineMessages({ 'Do NOT enable this setting unless you understand what you are doing!', cacheImages: 'Enable Image Caching', cacheImagesTip: - 'Cache and serve optimized images (requires a significant amount of disk space)', + 'Cache externally sourced images (requires a significant amount of disk space)', trustProxy: 'Enable Proxy Support', trustProxyTip: 'Allow Overseerr to correctly register client IP addresses behind a proxy', diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 22fa2bbe8..cd5e0ad1a 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -5,12 +5,14 @@ import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import { MediaStatus } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import getConfig from 'next/config'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ status: '{status}', status4k: '4K {status}', - playonplex: 'Play on Plex', + playonplex: 'Play on {mediaServerName}', openinarr: 'Open in {arr}', managemedia: 'Manage {mediaType}', }); @@ -37,6 +39,7 @@ const StatusBadge = ({ const intl = useIntl(); const { hasPermission } = useUser(); const settings = useSettings(); + const { publicRuntimeConfig } = getConfig(); let mediaLink: string | undefined; let mediaLinkDescription: string | undefined; @@ -68,7 +71,14 @@ const StatusBadge = ({ : settings.currentSettings.series4kEnabled)) ) { mediaLink = plexUrl; - mediaLinkDescription = intl.formatMessage(messages.playonplex); + mediaLinkDescription = intl.formatMessage(messages.playonplex, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin', + }); } else if (hasPermission(Permission.MANAGE_REQUESTS)) { if (mediaType && tmdbId) { mediaLink = `/${mediaType}/${tmdbId}?manage=1`; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 7c38a4238..1792027e9 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -22,6 +22,7 @@ import RequestModal from '@app/components/RequestModal'; import Slider from '@app/components/Slider'; import StatusBadge from '@app/components/StatusBadge'; import Season from '@app/components/TvDetails/Season'; +import useDeepLinks from '@app/hooks/useDeepLinks'; import useLocale from '@app/hooks/useLocale'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -125,31 +126,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); - const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl); - const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k); - - useEffect(() => { - if (data) { - if ( - settings.currentSettings.mediaServerType === MediaServerType.PLEX && - (/iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) - ) { - setPlexUrl(data.mediaInfo?.iOSPlexUrl); - setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k); - } else { - setPlexUrl(data.mediaInfo?.mediaUrl); - setPlexUrl4k(data.mediaInfo?.mediaUrl4k); - } - } - }, [ - data, - data?.mediaInfo?.iOSPlexUrl, - data?.mediaInfo?.iOSPlexUrl4k, - data?.mediaInfo?.mediaUrl, - data?.mediaInfo?.mediaUrl4k, - settings.currentSettings.mediaServerType, - ]); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ + mediaUrl: data?.mediaInfo?.mediaUrl, + mediaUrl4k: data?.mediaInfo?.mediaUrl4k, + iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, + iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, + }); if (!data && !error) { return ; @@ -984,9 +966,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { tvdbId={data.externalIds.tvdbId} imdbId={data.externalIds.imdbId} rtUrl={ratingData?.url} - mediaUrl={ - data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k - } + mediaUrl={plexUrl ?? plexUrl4k} />
diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index 0cf4d7d79..115f4f4b6 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -10,6 +10,7 @@ export type AvailableLocale = | 'el' | 'es' | 'fr' + | 'hr' | 'hu' | 'it' | 'ja' @@ -60,6 +61,10 @@ export const availableLanguages: AvailableLanguageObject = { code: 'fr', display: 'Français', }, + hr: { + code: 'hr', + display: 'Hrvatski', + }, it: { code: 'it', display: 'Italiano', diff --git a/src/hooks/useDeepLinks.ts b/src/hooks/useDeepLinks.ts new file mode 100644 index 000000000..983086591 --- /dev/null +++ b/src/hooks/useDeepLinks.ts @@ -0,0 +1,45 @@ +import useSettings from '@app/hooks/useSettings'; +import { MediaServerType } from '@server/constants/server'; +import { useEffect, useState } from 'react'; + +interface useDeepLinksProps { + mediaUrl?: string; + mediaUrl4k?: string; + iOSPlexUrl?: string; + iOSPlexUrl4k?: string; +} + +const useDeepLinks = ({ + mediaUrl, + mediaUrl4k, + iOSPlexUrl, + iOSPlexUrl4k, +}: useDeepLinksProps) => { + const [returnedMediaUrl, setReturnedMediaUrl] = useState(mediaUrl); + const [returnedMediaUrl4k, setReturnedMediaUrl4k] = useState(mediaUrl4k); + const settings = useSettings(); + + useEffect(() => { + if ( + settings.currentSettings.mediaServerType === MediaServerType.PLEX && + (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) + ) { + setReturnedMediaUrl(iOSPlexUrl); + setReturnedMediaUrl4k(iOSPlexUrl4k); + } else { + setReturnedMediaUrl(mediaUrl); + setReturnedMediaUrl4k(mediaUrl4k); + } + }, [ + iOSPlexUrl, + iOSPlexUrl4k, + mediaUrl, + mediaUrl4k, + settings.currentSettings.mediaServerType, + ]); + + return { mediaUrl: returnedMediaUrl, mediaUrl4k: returnedMediaUrl4k }; +}; + +export default useDeepLinks; diff --git a/src/i18n/locale/ar.json b/src/i18n/locale/ar.json index bba3d1710..95b6e3287 100644 --- a/src/i18n/locale/ar.json +++ b/src/i18n/locale/ar.json @@ -37,7 +37,7 @@ "components.ManageSlideOver.alltime": "جميع الأوقات", "components.ManageSlideOver.downloadstatus": "التنزيلات", "components.ManageSlideOver.manageModalAdvanced": "متقدم", - "components.ManageSlideOver.manageModalClearMediaWarning": "* سيتم حذف جميع البيانات بشكل نهائي لـ {mediaType},متضمنا جميع الطلبات.إذا كان هذا المحتوى متوفر في مكتبة بليكس، سيتم إعادة تفاصيل المحتوى في عملية الفحص القادمة.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* سيتم حذف جميع البيانات بشكل نهائي لـ {mediaType},متضمنا جميع الطلبات.إذا كان هذا المحتوى متوفر في مكتبة {mediaServerName}، سيتم إعادة تفاصيل المحتوى في عملية الفحص القادمة.", "components.ManageSlideOver.manageModalRequests": "الطلبات", "components.ManageSlideOver.manageModalTitle": "إدارة {mediaType}", "components.ManageSlideOver.manageModalIssues": "المشاكل المفتوحة", diff --git a/src/i18n/locale/ca.json b/src/i18n/locale/ca.json index a0eaeb14c..9733c4b63 100644 --- a/src/i18n/locale/ca.json +++ b/src/i18n/locale/ca.json @@ -887,7 +887,7 @@ "components.IssueModal.CreateIssueModal.whatswrong": "Què passa?", "components.IssueModal.issueAudio": "Àudio", "components.IssueModal.issueOther": "Altre", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Això eliminarà de manera irreversible totes les dades de {mediaType}, incloses les sol·licituds. Si aquest element existeix a la vostra biblioteca Plex, la informació dels continguts es recrearà durant la següent exploració.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Això eliminarà de manera irreversible totes les dades de {mediaType}, incloses les sol·licituds. Si aquest element existeix a la vostra biblioteca {mediaServerName}, la informació dels continguts es recrearà durant la següent exploració.", "components.ManageSlideOver.downloadstatus": "Descàrregues", "components.IssueDetails.toasteditdescriptionsuccess": "La descripció de l'incidència s'ha editat correctament!", "components.IssueList.IssueItem.issuetype": "Tipus", @@ -1104,7 +1104,7 @@ "components.RequestBlock.delete": "Suprimeix la sol·licitud", "components.RequestBlock.edit": "Edita la sol·licitud", "components.RequestBlock.lastmodifiedby": "Última modificació per", - "components.StatusBadge.playonplex": "Reprodueix a Plex", + "components.StatusBadge.playonplex": "Reprodueix a {mediaServerName}", "components.RequestCard.declinerequest": "Rebutja la sol·licitud", "components.StatusBadge.openinarr": "Obre a {arr}", "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Sincronització de la llista de seguiment de Plex", diff --git a/src/i18n/locale/cs.json b/src/i18n/locale/cs.json index 817e12ed4..dedf8b15f 100644 --- a/src/i18n/locale/cs.json +++ b/src/i18n/locale/cs.json @@ -548,7 +548,7 @@ "components.ManageSlideOver.manageModalClearMedia": "Vyčistit data", "components.ManageSlideOver.alltime": "Pořád", "components.ManageSlideOver.manageModalAdvanced": "Pokročilý", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Tímto nenávratně odstraníte všechna data pro tento {mediaType}, včetně všech požadavků. Pokud tato položka existuje ve vaší knihovně Plex, informace o médiích budou znovu vytvořeny během příštího skenování.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Tímto nenávratně odstraníte všechna data pro tento {mediaType}, včetně všech požadavků. Pokud tato položka existuje ve vaší knihovně {mediaServerName}, informace o médiích budou znovu vytvořeny během příštího skenování.", "components.ManageSlideOver.manageModalMedia": "Média", "components.ManageSlideOver.manageModalMedia4k": "4K Média", "components.ManageSlideOver.markallseasonsavailable": "Označte všechny sezóny jako dostupné", @@ -1087,7 +1087,7 @@ "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist synchronizace", "components.StatusBadge.managemedia": "Spravovat {mediaType}", "components.StatusBadge.openinarr": "Otevřít v {arr}", - "components.StatusBadge.playonplex": "Přehrávání cez Plex", + "components.StatusBadge.playonplex": "Přehrávání cez {mediaServerName}", "components.TvDetails.manageseries": "Spravovat sérii", "components.RequestBlock.delete": "Smazat požadavek", "components.RequestBlock.edit": "Upravit požadavek", diff --git a/src/i18n/locale/da.json b/src/i18n/locale/da.json index cd6da0d2e..15da81181 100644 --- a/src/i18n/locale/da.json +++ b/src/i18n/locale/da.json @@ -201,7 +201,7 @@ "components.IssueModal.issueVideo": "Video", "components.Layout.Sidebar.issues": "Problemer", "components.ManageSlideOver.manageModalClearMedia": "Ryd Mediedata", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette alle data for denne {mediaType} uden mulighed for gendannelse, inklusiv alle forespørgsler. Hvis dette objekt findes i dit Plex bibliotek vil medieinformationen blive genskabt under næste skanning.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette alle data for denne {mediaType} uden mulighed for gendannelse, inklusiv alle forespørgsler. Hvis dette objekt findes i dit {mediaServerName} bibliotek vil medieinformationen blive genskabt under næste skanning.", "components.IssueModal.CreateIssueModal.whatswrong": "Hvad er galt?", "components.IssueModal.issueAudio": "Lyd", "components.IssueModal.issueOther": "Andet", diff --git a/src/i18n/locale/de.json b/src/i18n/locale/de.json index 61153d82c..86c3f86c0 100644 --- a/src/i18n/locale/de.json +++ b/src/i18n/locale/de.json @@ -931,7 +931,7 @@ "components.Layout.Sidebar.issues": "Probleme", "components.ManageSlideOver.downloadstatus": "Downloads", "components.ManageSlideOver.manageModalClearMedia": "Daten löschen", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Dadurch werden alle Daten für diesen {mediaType} unwiderruflich entfernt, einschließlich aller Anfragen. Wenn dieses Element in Ihrer Plex-Bibliothek existiert, werden die Medieninformationen beim nächsten Scan neu erstellt.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Dadurch werden alle Daten für diesen {mediaType} unwiderruflich entfernt, einschließlich aller Anfragen. Wenn dieses Element in Ihrer {mediaServerName}-Bibliothek existiert, werden die Medieninformationen beim nächsten Scan neu erstellt.", "components.ManageSlideOver.manageModalIssues": "Problem eröffnen", "components.ManageSlideOver.manageModalNoRequests": "Keine Anfragen.", "components.ManageSlideOver.manageModalRequests": "Anfragen", diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b5acab96c..27f21134c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -141,7 +141,7 @@ "components.ManageSlideOver.downloadstatus": "Downloads", "components.ManageSlideOver.manageModalAdvanced": "Advanced", "components.ManageSlideOver.manageModalClearMedia": "Clear Data", - "components.ManageSlideOver.manageModalClearMediaWarning": "* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.", "components.ManageSlideOver.manageModalIssues": "Open Issues", "components.ManageSlideOver.manageModalMedia": "Media", "components.ManageSlideOver.manageModalMedia4k": "4K Media", @@ -649,6 +649,11 @@ "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", + "components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup", + "components.Settings.SettingsJobsCache.imagecache": "Image Cache", + "components.Settings.SettingsJobsCache.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 {appDataPath}/cache/images.", + "components.Settings.SettingsJobsCache.imagecachecount": "Images Cached", + "components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size", "components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.", "components.Settings.SettingsJobsCache.jobScheduleEditSaved": "Job edited successfully!", "components.Settings.SettingsJobsCache.jobcancelled": "{jobname} canceled.", @@ -759,7 +764,7 @@ "components.Settings.applicationTitle": "Application Title", "components.Settings.applicationurl": "Application URL", "components.Settings.cacheImages": "Enable Image Caching", - "components.Settings.cacheImagesTip": "Cache and serve optimized images (requires a significant amount of disk space)", + "components.Settings.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)", "components.Settings.cancelscan": "Cancel Scan", "components.Settings.copied": "Copied API key to clipboard.", "components.Settings.csrfProtection": "Enable CSRF Protection", @@ -874,7 +879,7 @@ "components.Setup.welcome": "Welcome to Jellyseerr", "components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.openinarr": "Open in {arr}", - "components.StatusBadge.playonplex": "Play on Plex", + "components.StatusBadge.playonplex": "Play on {mediaServerName}", "components.StatusBadge.status": "{status}", "components.StatusBadge.status4k": "4K {status}", "components.StatusChacker.newversionDescription": "Jellyseerr has been updated! Please click the button below to reload the page.", diff --git a/src/i18n/locale/es.json b/src/i18n/locale/es.json index e66603c67..6798681ec 100644 --- a/src/i18n/locale/es.json +++ b/src/i18n/locale/es.json @@ -953,7 +953,7 @@ "components.IssueModal.issueAudio": "Audio", "components.IssueModal.issueSubtitles": "Subtítulo", "components.IssueModal.issueVideo": "Vídeo", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Esto eliminará irreversiblemente todos los datos de {mediaType}, incluyendo todas las solicitudes. Si este elemento existe en la biblioteca de Plex, la información de los contenidos se recreará en el siguiente escaneado.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Esto eliminará irreversiblemente todos los datos de {mediaType}, incluyendo todas las solicitudes. Si este elemento existe en la biblioteca de {mediaServerName}, la información de los contenidos se recreará en el siguiente escaneado.", "components.ManageSlideOver.mark4kavailable": "Marcar como Disponible en 4K", "components.ManageSlideOver.openarr4k": "Abrir en 4K {arr}", "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Token de Acceso", diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index 5a2545fa5..2acc41072 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -877,7 +877,7 @@ "components.ManageSlideOver.manageModalNoRequests": "Aucune demande.", "components.ManageSlideOver.manageModalRequests": "Demandes", "components.ManageSlideOver.manageModalTitle": "Gérer {mediaType}", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Ceci supprimera de manière irréversible toutes les données de ce(tte) {mediaType}, y compris les demandes éventuelles. Si cet élément existe dans votre bibliothèque Plex, les informations sur le média seront recréées lors de la prochaine analyse.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Ceci supprimera de manière irréversible toutes les données de ce(tte) {mediaType}, y compris les demandes éventuelles. Si cet élément existe dans votre bibliothèque {mediaServerName}, les informations sur le média seront recréées lors de la prochaine analyse.", "components.ManageSlideOver.tvshow": "série", "components.NotificationTypeSelector.issuecomment": "Commentaires du problème", "components.NotificationTypeSelector.issuecreatedDescription": "Envoyer des notifications lorsqu'un problème est signalé.", @@ -1099,7 +1099,7 @@ "components.RequestCard.declinerequest": "Refuser la demande", "components.StatusBadge.managemedia": "Gérer {mediaType}", "components.StatusBadge.openinarr": "Ouvrir dans {arr}", - "components.StatusBadge.playonplex": "Lire sur Plex", + "components.StatusBadge.playonplex": "Lire sur {mediaServerName}", "components.TvDetails.Season.somethingwentwrong": "Une erreur s'est produite lors de la récupération des données de la saison.", "components.TvDetails.rtaudiencescore": "Note d'audience de Rotten Tomatoes", "components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatomètre", diff --git a/src/i18n/locale/hr.json b/src/i18n/locale/hr.json index 710ba1fbd..ae6e1fd1f 100644 --- a/src/i18n/locale/hr.json +++ b/src/i18n/locale/hr.json @@ -80,7 +80,7 @@ "components.ManageSlideOver.movie": "film", "components.Login.validationemailrequired": "Morate unijeti valjanu adresu e-pošte", "components.ManageSlideOver.manageModalRequests": "Zahtjevi", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Ovo će nepovratno ukloniti sve podatke za ovaj {mediaType}, uključujući sve zahtjeve. Ako ova stavka postoji u vašoj Plex biblioteci, informacije o medijima ponovno će se stvoriti tijekom sljedećeg skeniranja.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Ovo će nepovratno ukloniti sve podatke za ovaj {mediaType}, uključujući sve zahtjeve. Ako ova stavka postoji u vašoj {mediaServerName} biblioteci, informacije o medijima ponovno će se stvoriti tijekom sljedećeg skeniranja.", "components.ManageSlideOver.manageModalMedia4k": "4K Mediji", "components.ManageSlideOver.manageModalNoRequests": "Nema zahtjeva.", "components.ManageSlideOver.manageModalMedia": "Mediji", diff --git a/src/i18n/locale/hu.json b/src/i18n/locale/hu.json index 90ba3a93e..46bbb76d8 100644 --- a/src/i18n/locale/hu.json +++ b/src/i18n/locale/hu.json @@ -851,7 +851,7 @@ "components.IssueModal.CreateIssueModal.toastFailedCreate": "Valami hiba történt a probléma elküldése során.", "components.IssueDetails.play4konplex": "Lejátszás Plexen 4K-ban", "components.IssueModal.CreateIssueModal.toastviewissue": "Probléma Megtekintése", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Ez visszafordíthatatlanul eltávolítja az összes adatot ehhez a {mediaType}-hez, beleértve a kéréseket is. Ha ez az elem létezik a Plex könyvtárában, a médiainformáció a következő beolvasás során újra létrejön.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Ez visszafordíthatatlanul eltávolítja az összes adatot ehhez a {mediaType}-hez, beleértve a kéréseket is. Ha ez az elem létezik a {mediaServerName} könyvtárában, a médiainformáció a következő beolvasás során újra létrejön.", "components.IssueDetails.commentplaceholder": "Hozzászólás írása…", "components.IssueDetails.comments": "Hozzászólások", "components.IssueDetails.deleteissue": "Probléma Törlése", diff --git a/src/i18n/locale/it.json b/src/i18n/locale/it.json index b87455ca4..446c24fed 100644 --- a/src/i18n/locale/it.json +++ b/src/i18n/locale/it.json @@ -950,7 +950,7 @@ "components.Settings.SettingsJobsCache.jobScheduleEditSaved": "Job modificato correttamente!", "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingsfailed": "Impossibile salvare le impostazioni Pushover.", "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingssaved": "Impostazioni Pushover salvate con successo!", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Questo rimuoverà irreversibilmente tutti i dati per questo {mediaType}, incluse eventuali richieste. Se questo elemento esiste nella tua libreria Plex, le informazioni multimediali verranno ricreate durante la scansione successiva.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Questo rimuoverà irreversibilmente tutti i dati per questo {mediaType}, incluse eventuali richieste. Se questo elemento esiste nella tua libreria {mediaServerName}, le informazioni multimediali verranno ricreate durante la scansione successiva.", "components.NotificationTypeSelector.issuecreated": "Problema Segnalato", "components.NotificationTypeSelector.issuecreatedDescription": "Invia una notifica quando un problema viene segnalato.", "components.NotificationTypeSelector.issueresolved": "Problema risolto", diff --git a/src/i18n/locale/ja.json b/src/i18n/locale/ja.json index fd0ecea6a..acdff9adb 100644 --- a/src/i18n/locale/ja.json +++ b/src/i18n/locale/ja.json @@ -503,7 +503,7 @@ "components.ManageSlideOver.manageModalClearMedia": "データを消去", "components.ManageSlideOver.manageModalRequests": "リクエスト", "components.ManageSlideOver.openarr": "{arr} を開く", - "components.ManageSlideOver.manageModalClearMediaWarning": "※リクエストを含め、すべての詳細情報が消去されます。この操作は元に戻すことができません。この作品が Plex ライブラリに存在する場合、詳細情報は次のスキャンで再作成されます。", + "components.ManageSlideOver.manageModalClearMediaWarning": "※リクエストを含め、すべての詳細情報が消去されます。この操作は元に戻すことができません。この作品が {mediaServerName} ライブラリに存在する場合、詳細情報は次のスキャンで再作成されます。", "components.ManageSlideOver.openarr4k": "4K {arr} を開く", "components.ManageSlideOver.manageModalNoRequests": "リクエストが有りません。", "components.ManageSlideOver.manageModalTitle": "{mediaType}を管理", diff --git a/src/i18n/locale/lt.json b/src/i18n/locale/lt.json index e7667f3c4..73b5189cc 100644 --- a/src/i18n/locale/lt.json +++ b/src/i18n/locale/lt.json @@ -329,7 +329,7 @@ "components.IssueModal.CreateIssueModal.problemseason": "Paveikti sezonai", "components.IssueDetails.openedby": "#{issueId} problema atverta {relativeTime}, {username}", "components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {komitas} other {komitai}} behind", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Tai negyžtamai pašalins {mediaType} tipo duomenis, įskaitant rezervacijas. Plex bibliotekoje esančios medijos informacija bus atkurta kito skanavimo metu.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Tai negyžtamai pašalins {mediaType} tipo duomenis, įskaitant rezervacijas. {mediaServerName} bibliotekoje esančios medijos informacija bus atkurta kito skanavimo metu.", "components.NotificationTypeSelector.adminissuecommentDescription": "Gauti pranešimus kai kiti vartotojai komentuoja problemą.", "components.NotificationTypeSelector.adminissueresolvedDescription": "Gauti pranešimus kai kiti vartotojai uždaro problemą.", "components.NotificationTypeSelector.issuecomment": "Problemos komentaras", diff --git a/src/i18n/locale/nb_NO.json b/src/i18n/locale/nb_NO.json index fc0cd2c1a..b90ddc89f 100644 --- a/src/i18n/locale/nb_NO.json +++ b/src/i18n/locale/nb_NO.json @@ -986,7 +986,7 @@ "components.Settings.SettingsJobsCache.cachevsize": "Verdistørrelse", "components.Settings.trustProxyTip": "Tillatt Jellyseerr å registrere klienters IP addresser korrekt bak en proxy", "components.Settings.serviceSettingsDescription": "Konfigurer dine {serverType}tjener(e) nedenfor. Du kan koble til flere forskellige {serverType}tjenere men kun to av dem kan markeres som standard (en som ikke er 4K og en 4K). Administratorer kan endre hvilken tjener som brukes før godkjennelse av nye forespørsler.", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette all data for denne tittelen uten mulighet for å bli gjennopprettet, det inkluderer alle forespørsler, avvik osv. Hvis denne tittelen finnes i ditt Plex bibliotek vil medieinformasjon bli opprettet på nytt under neste skanning.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Dette vil slette all data for denne tittelen uten mulighet for å bli gjennopprettet, det inkluderer alle forespørsler, avvik osv. Hvis denne tittelen finnes i ditt {mediaServerName} bibliotek vil medieinformasjon bli opprettet på nytt under neste skanning.", "components.Settings.Notifications.NotificationsWebhook.authheader": "Autorisasjonshode", "components.Settings.SettingsJobsCache.cacheksize": "Nøkkelstørrelse", "components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload", @@ -1097,7 +1097,7 @@ "components.Settings.advancedTooltip": "Feil konfigurering av denne innstillingen kan føre til defekt funksjonalitet", "components.TvDetails.Season.somethingwentwrong": "Noe gikk galt under henting av data for denne sesongen.", "components.StatusChecker.reloadApp": "Last inn {applicationTitle} på nytt", - "components.StatusBadge.playonplex": "Spill av med Plex", + "components.StatusBadge.playonplex": "Spill av med {mediaServerName}", "components.StatusBadge.openinarr": "Vis i {arr}", "components.StatusBadge.managemedia": "Administrer {mediaType}", "components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episoder}}", diff --git a/src/i18n/locale/nl.json b/src/i18n/locale/nl.json index 264755c1a..1a06183f8 100644 --- a/src/i18n/locale/nl.json +++ b/src/i18n/locale/nl.json @@ -939,7 +939,7 @@ "components.IssueModal.issueOther": "Andere", "components.Layout.Sidebar.issues": "Problemen", "components.ManageSlideOver.manageModalClearMedia": "Gegevens wissen", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Hiermee worden alle gegevens voor deze {mediaType} onomkeerbaar verwijderd, inclusief eventuele verzoeken. Als dit item in je Plex-bibliotheek staat, worden de mediagegevens opnieuw aangemaakt tijdens de volgende scan.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Hiermee worden alle gegevens voor deze {mediaType} onomkeerbaar verwijderd, inclusief eventuele verzoeken. Als dit item in je {mediaServerName}-bibliotheek staat, worden de mediagegevens opnieuw aangemaakt tijdens de volgende scan.", "components.ManageSlideOver.manageModalRequests": "Verzoeken", "components.ManageSlideOver.manageModalTitle": "{mediaType} beheren", "components.ManageSlideOver.tvshow": "serie", @@ -1042,7 +1042,7 @@ "components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Huidige frequentie", "components.StatusBadge.managemedia": "{mediaType} beheren", "components.StatusBadge.openinarr": "Openen in {arr}", - "components.StatusBadge.playonplex": "Afspelen op Plex", + "components.StatusBadge.playonplex": "Afspelen op {mediaServerName}", "components.UserProfile.emptywatchlist": "Media die zijn toegevoegd aan je Plex Kijklijst verschijnen hier.", "components.MovieDetails.digitalrelease": "Digitale release", "i18n.restartRequired": "Opnieuw opstarten vereist", diff --git a/src/i18n/locale/pl.json b/src/i18n/locale/pl.json index 57bc19e81..aecc2a52f 100644 --- a/src/i18n/locale/pl.json +++ b/src/i18n/locale/pl.json @@ -103,7 +103,7 @@ "components.PermissionEdit.createissues": "Zgłoś problemy", "components.PermissionEdit.manageissues": "Zarządzaj problemami", "components.PermissionEdit.manageissuesDescription": "Udziel uprawnień do zarządzania problemami z multimediami.", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Spowoduje to nieodwracalne usunięcie wszystkich danych dla {mediaType}, w tym wszelkie prośby. Jeśli ten element istnieje w Twojej bibliotece Plex, informacje o multimediach zostaną odtworzone podczas następnego skanowania.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Spowoduje to nieodwracalne usunięcie wszystkich danych dla {mediaType}, w tym wszelkie prośby. Jeśli ten element istnieje w Twojej bibliotece {mediaServerName}, informacje o multimediach zostaną odtworzone podczas następnego skanowania.", "components.IssueModal.CreateIssueModal.providedetail": "Podaj szczegółowe wyjaśnienie napotkanego problemu.", "components.IssueModal.CreateIssueModal.whatswrong": "Co jest nie tak?", "components.Discover.MovieGenreList.moviegenres": "Gatunki filmowe", diff --git a/src/i18n/locale/pt_BR.json b/src/i18n/locale/pt_BR.json index 57d716969..7f657efe9 100644 --- a/src/i18n/locale/pt_BR.json +++ b/src/i18n/locale/pt_BR.json @@ -924,7 +924,7 @@ "components.IssueModal.issueOther": "Outros", "components.IssueModal.issueSubtitles": "Legenda", "components.ManageSlideOver.manageModalClearMedia": "Limpar Dados", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Isso irá remover em definitivo todos dados desse(a) {mediaType}, incluindo quaisquer solicitações para esse item. Se este item existir in sua biblioteca do Plex, os dados de mídia serão recriados na próxima sincronia.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Isso irá remover em definitivo todos dados desse(a) {mediaType}, incluindo quaisquer solicitações para esse item. Se este item existir in sua biblioteca do {mediaServerName}, os dados de mídia serão recriados na próxima sincronia.", "components.ManageSlideOver.manageModalIssues": "Problemas Abertos", "components.ManageSlideOver.manageModalNoRequests": "Nenhuma solicitação.", "components.ManageSlideOver.manageModalRequests": "Solicitações", @@ -1098,7 +1098,7 @@ "components.RequestBlock.requestdate": "Data do pedido", "components.RequestCard.declinerequest": "Rejeitar Pedido", "components.RequestCard.editrequest": "Editar Pedido", - "components.StatusBadge.playonplex": "Reproduzir no Plex", + "components.StatusBadge.playonplex": "Reproduzir no {mediaServerName}", "components.RequestBlock.decline": "Rejeitar pedido", "components.RequestBlock.lastmodifiedby": "Última modificação por", "components.RequestBlock.delete": "Deletar pedido", diff --git a/src/i18n/locale/ru.json b/src/i18n/locale/ru.json index a4e5dc9e1..2c822999e 100644 --- a/src/i18n/locale/ru.json +++ b/src/i18n/locale/ru.json @@ -871,7 +871,7 @@ "components.IssueDetails.allseasons": "Все сезоны", "components.IssueDetails.allepisodes": "Все эпизоды", "components.ManageSlideOver.manageModalClearMedia": "Очистить данные", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Это приведёт к необратимому удалению всех данных для этого {mediaType}а, включая любые запросы. Если этот элемент существует в вашей библиотеке Plex, мультимедийная информация о нём будет воссоздана во время следующего сканирования.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Это приведёт к необратимому удалению всех данных для этого {mediaType}а, включая любые запросы. Если этот элемент существует в вашей библиотеке {mediaServerName}, мультимедийная информация о нём будет воссоздана во время следующего сканирования.", "components.IssueDetails.problemepisode": "Затронутый эпизод", "components.ManageSlideOver.manageModalRequests": "Запросы", "components.IssueDetails.closeissue": "Закрыть проблему", diff --git a/src/i18n/locale/sq.json b/src/i18n/locale/sq.json index 01ad8a021..3573bf6c6 100644 --- a/src/i18n/locale/sq.json +++ b/src/i18n/locale/sq.json @@ -5,7 +5,7 @@ "components.IssueModal.CreateIssueModal.submitissue": "Paraqit Problemin", "components.IssueModal.CreateIssueModal.toastSuccessCreate": "Raporti i problemit për {title} u paraqit me sukses!", "components.IssueModal.CreateIssueModal.toastviewissue": "Shiko Problemin", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Kjo do të heqë në mënyrë të pakthyeshme të gjitha të dhënat për këtë {mediaType}, duke përfshirë çdo kërkesë. Nëse ky artikull ekziston në bibliotekën tuaj Plex, informacioni i medias do të rikrijohet gjatë skanimit të ardhshëm.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Kjo do të heqë në mënyrë të pakthyeshme të gjitha të dhënat për këtë {mediaType}, duke përfshirë çdo kërkesë. Nëse ky artikull ekziston në bibliotekën tuaj {mediaServerName}, informacioni i medias do të rikrijohet gjatë skanimit të ardhshëm.", "components.AppDataWarning.dockerVolumeMissingDescription": "Monitimi i volumit {appDataPath} nuk u konfigurua siç duhet. Gjithë informacioni do të fshihet kur kontenieri do të mbyllet ose të ristartohet.", "components.Discover.StudioSlider.studios": "Studiot", "components.Layout.UserDropdown.settings": "Cilësimet", diff --git a/src/i18n/locale/sr.json b/src/i18n/locale/sr.json index bfc44009d..bf1364c56 100644 --- a/src/i18n/locale/sr.json +++ b/src/i18n/locale/sr.json @@ -609,7 +609,7 @@ "components.Settings.SettingsAbout.uptodate": "Najsvežiji", "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Morate da navedete važeći JSON korisni teret", "components.Settings.Notifications.validationChatIdRequired": "Morate da navedete važeći ID za ćaskanje", - "components.StatusBadge.playonplex": "Igrajte na Plex-u", + "components.StatusBadge.playonplex": "Igrajte na {mediaServerName}-u", "components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "Morate da obezbedite pristupni token", "components.UserList.userssaved": "Korisničke dozvole su uspešno sačuvane!" } diff --git a/src/i18n/locale/sv.json b/src/i18n/locale/sv.json index baadcbc03..715c49921 100644 --- a/src/i18n/locale/sv.json +++ b/src/i18n/locale/sv.json @@ -951,7 +951,7 @@ "components.NotificationTypeSelector.issuecreated": "Problem rappoterat", "components.PermissionEdit.createissues": "Rapportera problem", "components.PermissionEdit.viewissues": "Visa problem", - "components.ManageSlideOver.manageModalClearMediaWarning": "* Detta tar bort all data för denna {mediaType}, inklusive eventuella begäranden, på ett oåterkalleligt sätt. Om det här objektet finns i ditt Plex-bibliotek kommer medieinformationen att återskapas vid nästa genomsökning.", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Detta tar bort all data för denna {mediaType}, inklusive eventuella begäranden, på ett oåterkalleligt sätt. Om det här objektet finns i ditt {mediaServerName}-bibliotek kommer medieinformationen att återskapas vid nästa genomsökning.", "components.ManageSlideOver.manageModalNoRequests": "Inga förfrågningar.", "components.NotificationTypeSelector.userissueresolvedDescription": "Få meddelande när dina rapporterade problem har blivit lösta.", "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Åtkomsttoken", diff --git a/src/i18n/locale/zh_Hans.json b/src/i18n/locale/zh_Hans.json index 54cba85fd..59febf2ed 100644 --- a/src/i18n/locale/zh_Hans.json +++ b/src/i18n/locale/zh_Hans.json @@ -944,7 +944,7 @@ "components.NotificationTypeSelector.userissueresolvedDescription": "当您报告的问题解决时获取通知。", "components.ManageSlideOver.alltime": "历史", "components.ManageSlideOver.manageModalAdvanced": "高级", - "components.ManageSlideOver.manageModalClearMediaWarning": "* 这将会删除所有和{mediaType}相关的数据和所有请求。如果{mediaType}在您的Plex服务器存在,数据将会在媒体库扫描时重新建立。", + "components.ManageSlideOver.manageModalClearMediaWarning": "* 这将会删除所有和{mediaType}相关的数据和所有请求。如果{mediaType}在您的{mediaServerName}服务器存在,数据将会在媒体库扫描时重新建立。", "components.ManageSlideOver.manageModalIssues": "未解决问题", "components.ManageSlideOver.manageModalMedia": "媒体", "components.ManageSlideOver.manageModalMedia4k": "4K 媒体", diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index e9b3b4825..4aff692db 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -884,7 +884,7 @@ "components.IssueModal.issueAudio": "音訊", "components.ManageSlideOver.downloadstatus": "下載狀態", "components.IssueModal.CreateIssueModal.allepisodes": "所有集數", - "components.ManageSlideOver.manageModalClearMediaWarning": "※這將會刪除包括使用者請求在內所有有關此{mediaType}的資料。如果這{mediaType}存在於您的 Plex 伺服器,資料將會在媒體庫掃描時重新建立。", + "components.ManageSlideOver.manageModalClearMediaWarning": "※這將會刪除包括使用者請求在內所有有關此{mediaType}的資料。如果這{mediaType}存在於您的 {mediaServerName} 伺服器,資料將會在媒體庫掃描時重新建立。", "components.ManageSlideOver.mark4kavailable": "標記 4K 版為可觀看", "components.IssueModal.issueSubtitles": "字幕", "components.IssueModal.issueOther": "其他", @@ -1092,7 +1092,7 @@ "components.RequestBlock.delete": "刪除請求", "components.RequestCard.editrequest": "編輯請求", "components.RequestBlock.requestedby": "請求者", - "components.StatusBadge.playonplex": "在 Plex 上觀看", + "components.StatusBadge.playonplex": "在 {mediaServerName} 上觀看", "components.StatusBadge.managemedia": "管理{mediaType}", "components.StatusBadge.openinarr": "開啟 {arr} 伺服器", "components.TvDetails.status4k": "4K 版{status}", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d546f0a8b..4738b8cf8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -43,6 +43,8 @@ const loadLocaleData = (locale: AvailableLocale): Promise => { return import('../i18n/locale/es.json'); case 'fr': return import('../i18n/locale/fr.json'); + case 'hr': + return import('../i18n/locale/hr.json'); case 'hu': return import('../i18n/locale/hu.json'); case 'it':