From a02a0dd1765a6f9333fa105578c858ae6eb16bfc Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 26 Aug 2024 14:27:06 +0200 Subject: [PATCH 001/162] docs: fix broken anchors (#946) --- docs/getting-started/nixpkg.mdx | 8 ++++---- docs/using-jellyseerr/users/editing-users.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/getting-started/nixpkg.mdx b/docs/getting-started/nixpkg.mdx index b7d955e9..9afb2757 100644 --- a/docs/getting-started/nixpkg.mdx +++ b/docs/getting-started/nixpkg.mdx @@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => { <> {!isUpToDate ? ( - The upstream Jellyseerr Nix Package (v{nixpkgVersion}) is not up-to-date. If you want to use Jellyseerr v{jellyseerrVersion}, you will need to override the package derivation. + The upstream Jellyseerr Nix Package (v{nixpkgVersion}) is not up-to-date. If you want to use Jellyseerr v{jellyseerrVersion}, you will need to override the package derivation. ) : ( @@ -95,12 +95,12 @@ export const VersionMatch = () => { }; offlineCache = pkgs.fetchYarnDeps { - sha256 = pkgs.lib.fakeSha256; + sha256 = pkgs.lib.fakeSha256; }; - }); + }); }; }`; - + const module = `{ config, pkgs, lib, ... }: with lib; diff --git a/docs/using-jellyseerr/users/editing-users.md b/docs/using-jellyseerr/users/editing-users.md index 13ddb760..fb2b80ab 100644 --- a/docs/using-jellyseerr/users/editing-users.md +++ b/docs/using-jellyseerr/users/editing-users.md @@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene ### Discover Region & Discover Language -Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences. +Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences. ### Movie Request Limit & Series Request Limit From e57d2654d1c634a91649722d3a2bf4d73c4a02ca Mon Sep 17 00:00:00 2001 From: Joaquin Olivero <66050823+JoaquinOlivero@users.noreply.github.com> Date: Mon, 26 Aug 2024 17:14:46 -0300 Subject: [PATCH 002/162] fix: set correct user type when importing from emby (#949) fix #948 Co-authored-by: JoaquinOlivero --- server/routes/user/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index da9b649c..f8a0d41a 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; @@ -550,7 +551,10 @@ router.post( default: 'mm', size: 200, }), - userType: UserType.JELLYFIN, + userType: + settings.main.mediaServerType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY, }); await userRepository.save(newUser); From 89e0a831ec85a6905f539f59b7523bb1feb90bcf Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 27 Aug 2024 12:54:56 +0200 Subject: [PATCH 003/162] fix: add an error message to say when an email is already taken (#947) When the email is modified in the user settings and it is already taken by someone else, a generic message saying that something wrong happened, without saying that it is because the email is already taken by another user. This PR adds this error message for the email. --- server/constants/error.ts | 1 + server/routes/user/usersettings.ts | 19 +++++++++++++- .../UserGeneralSettings/index.tsx | 26 +++++++++++++++---- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/server/constants/error.ts b/server/constants/error.ts index ac18c3ec..96fafdc9 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -2,6 +2,7 @@ export enum ApiErrorCode { InvalidUrl = 'INVALID_URL', InvalidCredentials = 'INVALID_CREDENTIALS', InvalidAuthToken = 'INVALID_AUTH_TOKEN', + InvalidEmail = 'INVALID_EMAIL', NotAdmin = 'NOT_ADMIN', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 9669cb18..11cbd666 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,3 +1,4 @@ +import { ApiErrorCode } from '@server/constants/error'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { UserSettings } from '@server/entity/UserSettings'; @@ -9,6 +10,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import { ApiError } from '@server/types/error'; import { Router } from 'express'; import { canMakePermissionsChange } from '.'; @@ -98,10 +100,18 @@ userSettingsRoutes.post< } user.username = req.body.username; + const oldEmail = user.email; if (user.jellyfinUsername) { user.email = req.body.email || user.jellyfinUsername || user.email; } + const existingUser = await userRepository.findOne({ + where: { email: user.email }, + }); + if (oldEmail !== user.email && existingUser) { + throw new ApiError(400, ApiErrorCode.InvalidEmail); + } + // Update quota values only if the user has the correct permissions if ( !user.hasPermission(Permission.MANAGE_USERS) && @@ -145,7 +155,14 @@ userSettingsRoutes.post< email: savedUser.email, }); } catch (e) { - next({ status: 500, message: e.message }); + if (e.errorCode) { + return next({ + status: e.statusCode, + message: e.errorCode, + }); + } else { + return next({ status: 500, message: e.message }); + } } }); diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 15d96071..502a9d84 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -14,6 +14,7 @@ import globalMessages from '@app/i18n/globalMessages'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import { ApiErrorCode } from '@server/constants/error'; import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces'; import { Field, Form, Formik } from 'formik'; import { useRouter } from 'next/router'; @@ -42,6 +43,7 @@ const messages = defineMessages( user: 'User', toastSettingsSuccess: 'Settings saved successfully!', toastSettingsFailure: 'Something went wrong while saving settings.', + toastSettingsFailureEmail: 'This email is already taken!', region: 'Discover Region', regionTip: 'Filter content by regional availability', originallanguage: 'Discover Language', @@ -178,7 +180,7 @@ const UserGeneralSettings = () => { watchlistSyncTv: values.watchlistSyncTv, }), }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); if (currentUser?.id === user?.id && setLocale) { setLocale( @@ -193,10 +195,24 @@ const UserGeneralSettings = () => { appearance: 'success', }); } catch (e) { - addToast(intl.formatMessage(messages.toastSettingsFailure), { - autoDismiss: true, - appearance: 'error', - }); + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } + if (errorData?.message === ApiErrorCode.InvalidEmail) { + addToast(intl.formatMessage(messages.toastSettingsFailureEmail), { + autoDismiss: true, + appearance: 'error', + }); + } else { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } } finally { revalidate(); revalidateUser(); From 54cfeefe74de2c7df97491aa6cb954b94759ac5d Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 28 Aug 2024 21:58:04 +0500 Subject: [PATCH 004/162] docs(readme): update the translate badge (#951) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f0fa356..f91fe384 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Discord Docker pulls -Translation status +Translation status GitHub All Contributors From 45ef150e36944d456cc9440574b5ac75f2e4bbc1 Mon Sep 17 00:00:00 2001 From: Aidan Hilt <11202897+AidanHilt@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:05:44 -0400 Subject: [PATCH 005/162] feat: add environment variable for API key (#831) * Added the ability to set the API key with the env var API_KEY * Adding debug statements * Updating * feat: adding env var for API key * feat: update * fix(settings/index.ts): remove a print statement that logs the API key to the console * Update en.json * docs: added documentation about API_KEY environment variable * feat: add a check to ensure API key always uses env var if provided * feat: always check the API_KEY env var at startup * chore: add back the gitkeeps under ./config, accidentally deleted in prev commit * chore: revert change made to docker-compose that was accidentally committed --- docs/using-jellyseerr/settings/general.md | 2 ++ server/lib/settings/index.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/using-jellyseerr/settings/general.md b/docs/using-jellyseerr/settings/general.md index 9cec9b9f..61991ed0 100644 --- a/docs/using-jellyseerr/settings/general.md +++ b/docs/using-jellyseerr/settings/general.md @@ -12,6 +12,8 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with If you need to generate a new API key for any reason, simply click the button to the right of the text box. +If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key. + ## Application Title If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title! diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 8c55d6c3..074a4fcd 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -611,7 +611,11 @@ class Settings { } private generateApiKey(): string { - return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); + if (process.env.API_KEY) { + return process.env.API_KEY; + } else { + return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); + } } private generateVapidKeys(force = false): void { @@ -648,6 +652,12 @@ class Settings { this.data = merge(this.data, parsedJson); + if (process.env.API_KEY) { + if (this.main.apiKey != process.env.API_KEY) { + this.main.apiKey = process.env.API_KEY; + } + } + this.save(); } return this; From ee7e91c7c948b17b556a625919eb1252a721bb6e Mon Sep 17 00:00:00 2001 From: Jonas F Date: Mon, 16 Sep 2024 19:07:43 +0200 Subject: [PATCH 006/162] fix: change SeriesSearch to MissingEpisodeSearch for season requests (#711) This fix changes the behavior of how Overseerr requests series data from Sonarr. Previously, when adding new seasons to a partially available series, Overseerr would initiate a SeriesSearch, causing Sonarr to search for all monitored seasons of the series, including those already available. This behavior is now corrected by executing a MissingEpisodeSearchCommand for the specific seriesId, which aligns with the intended behavior of only searching for and adding the newly requested seasons that are not already available. Resolves: https://github.com/Fallenbagel/jellyseerr/issues/710 --- server/api/servarr/sonarr.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 67c9dd2a..8ae054ed 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{ }); try { - await this.runCommand('SeriesSearch', { seriesId }); + await this.runCommand('MissingEpisodeSearch', { seriesId }); } catch (e) { logger.error( - 'Something went wrong while executing Sonarr series search.', + 'Something went wrong while executing Sonarr missing episode search.', { label: 'Sonarr API', errorMessage: e.message, From 818aa60aac185da07bfb71b08e0448939b63a736 Mon Sep 17 00:00:00 2001 From: Joaquin Olivero <66050823+JoaquinOlivero@users.noreply.github.com> Date: Mon, 16 Sep 2024 17:08:12 -0300 Subject: [PATCH 007/162] feat: blacklist items from Discover page (#632) * feat: blacklist media items re #490 * feat: blacklist media items * feat: blacklist media items * style: formatting * refactor: close the manage slide-over when the media item is removed from the blacklist * fix: fix media data in the db when blacklisting an item * refactor: refactor component to accept show boolean * refactor: hide watchlist button in the media page when it's blacklisted. Also add a blacklist button * style: formatting --------- Co-authored-by: JoaquinOlivero --- overseerr-api.yml | 103 +++++ server/constants/media.ts | 1 + server/entity/Blacklist.ts | 95 ++++ server/entity/Media.ts | 9 +- server/entity/MediaRequest.ts | 11 + server/interfaces/api/blacklistInterfaces.ts | 14 + server/lib/permissions.ts | 2 + .../migration/1699901142442-AddBlacklist.ts | 20 + server/routes/blacklist.ts | 148 +++++++ server/routes/index.ts | 2 + server/routes/request.ts | 3 + src/components/Blacklist/index.tsx | 417 ++++++++++++++++++ src/components/BlacklistBlock/index.tsx | 129 ++++++ src/components/BlacklistModal/index.tsx | 79 ++++ src/components/CollectionDetails/index.tsx | 39 +- src/components/Common/ListView/index.tsx | 158 ++++--- .../Common/StatusBadgeMini/index.tsx | 11 +- src/components/Layout/Sidebar/index.tsx | 13 + src/components/ManageSlideOver/index.tsx | 125 +++--- src/components/MediaSlider/index.tsx | 102 +++-- src/components/MovieDetails/index.tsx | 147 ++++-- src/components/PermissionEdit/index.tsx | 23 + src/components/RequestButton/index.tsx | 2 + .../RequestModal/CollectionRequestModal.tsx | 253 ++++++----- src/components/StatusBadge/index.tsx | 11 + src/components/TitleCard/index.tsx | 181 +++++++- src/components/TvDetails/index.tsx | 147 ++++-- src/i18n/globalMessages.ts | 10 + src/i18n/locale/en.json | 25 ++ src/pages/blacklist/index.tsx | 13 + 30 files changed, 1941 insertions(+), 352 deletions(-) create mode 100644 server/entity/Blacklist.ts create mode 100644 server/interfaces/api/blacklistInterfaces.ts create mode 100644 server/migration/1699901142442-AddBlacklist.ts create mode 100644 server/routes/blacklist.ts create mode 100644 src/components/Blacklist/index.tsx create mode 100644 src/components/BlacklistBlock/index.tsx create mode 100644 src/components/BlacklistModal/index.tsx create mode 100644 src/pages/blacklist/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index d2403538..f5e1d162 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -38,6 +38,8 @@ tags: description: Endpoints related to getting service (Radarr/Sonarr) details. - name: watchlist description: Collection of media to watch later + - name: blacklist + description: Blacklisted media from discovery page. servers: - url: '{server}/api/v1' variables: @@ -46,6 +48,19 @@ servers: components: schemas: + Blacklist: + type: object + properties: + tmdbId: + type: number + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + userId: + type: number + example: 1 Watchlist: type: object properties: @@ -4042,6 +4057,94 @@ paths: restricted: type: boolean example: false + /blacklist: + get: + summary: Returns blacklisted items + description: Returns list of all blacklisted media + tags: + - settings + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 25 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: search + schema: + type: string + nullable: true + example: dune + responses: + '200': + description: Blacklisted items returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + properties: + user: + $ref: '#/components/schemas/User' + createdAt: + type: string + example: 2024-04-21T01:55:44.000Z + id: + type: number + example: 1 + mediaType: + type: string + example: movie + title: + type: string + example: Dune + tmdbId: + type: number + example: 438631 + post: + summary: Add media to blacklist + tags: + - blacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Blacklist' + responses: + '201': + description: Item succesfully blacklisted + '412': + description: Item has already been blacklisted + /blacklist/{tmdbId}: + delete: + summary: Remove media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item /watchlist: post: summary: Add media to watchlist diff --git a/server/constants/media.ts b/server/constants/media.ts index de2bf834..dbcfbd34 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -16,4 +16,5 @@ export enum MediaStatus { PROCESSING, PARTIALLY_AVAILABLE, AVAILABLE, + BLACKLISTED, } diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts new file mode 100644 index 00000000..5e24419d --- /dev/null +++ b/server/entity/Blacklist.ts @@ -0,0 +1,95 @@ +import { MediaStatus, type MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import type { ZodNumber, ZodOptional, ZodString } from 'zod'; + +@Entity() +@Unique(['tmdbId']) +export class Blacklist implements BlacklistItem { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'varchar' }) + public mediaType: MediaType; + + @Column({ nullable: true, type: 'varchar' }) + title?: string; + + @Column() + @Index() + public tmdbId: number; + + @ManyToOne(() => User, (user) => user.id, { + eager: true, + }) + user: User; + + @OneToOne(() => Media, (media) => media.blacklist, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public media: Media; + + @CreateDateColumn() + public createdAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + public static async addToBlacklist({ + blacklistRequest, + }: { + blacklistRequest: { + mediaType: MediaType; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + }; + }): Promise { + const blacklist = new this({ + ...blacklistRequest, + }); + + const mediaRepository = getRepository(Media); + let media = await mediaRepository.findOne({ + where: { + tmdbId: blacklistRequest.tmdbId, + }, + }); + + const blacklistRepository = getRepository(this); + + await blacklistRepository.save(blacklist); + + if (!media) { + media = new Media({ + tmdbId: blacklistRequest.tmdbId, + status: MediaStatus.BLACKLISTED, + status4k: MediaStatus.BLACKLISTED, + mediaType: blacklistRequest.mediaType, + blacklist: blacklist, + }); + + await mediaRepository.save(media); + } else { + media.blacklist = blacklist; + media.status = MediaStatus.BLACKLISTED; + media.status4k = MediaStatus.BLACKLISTED; + + await mediaRepository.save(media); + } + } +} diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 723eb213..4f64178a 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -3,6 +3,7 @@ import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import { Blacklist } from '@server/entity/Blacklist'; import type { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; @@ -17,6 +18,7 @@ import { Entity, Index, OneToMany, + OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -66,7 +68,7 @@ class Media { try { const media = await mediaRepository.findOne({ - where: { tmdbId: id, mediaType }, + where: { tmdbId: id, mediaType: mediaType }, relations: { requests: true, issues: true }, }); @@ -116,6 +118,11 @@ class Media { @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) public issues: Issue[]; + @OneToOne(() => Blacklist, (blacklist) => blacklist.media, { + eager: true, + }) + public blacklist: Blacklist; + @CreateDateColumn() public createdAt: Date; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..6b2c7b56 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -40,6 +40,7 @@ export class RequestPermissionError extends Error {} export class QuotaRestrictedError extends Error {} export class DuplicateMediaRequestError extends Error {} export class NoSeasonsAvailableError extends Error {} +export class BlacklistedMediaError extends Error {} type MediaRequestOptions = { isAutoRequest?: boolean; @@ -143,6 +144,16 @@ export class MediaRequest { mediaType: requestBody.mediaType, }); } else { + if (media.status === MediaStatus.BLACKLISTED) { + logger.warn('Request for media blocked due to being blacklisted', { + tmdbId: tmdbMedia.id, + mediaType: requestBody.mediaType, + label: 'Media Request', + }); + + throw new BlacklistedMediaError('This media is blacklisted.'); + } + if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { media.status = MediaStatus.PENDING; } diff --git a/server/interfaces/api/blacklistInterfaces.ts b/server/interfaces/api/blacklistInterfaces.ts new file mode 100644 index 00000000..99e56585 --- /dev/null +++ b/server/interfaces/api/blacklistInterfaces.ts @@ -0,0 +1,14 @@ +import type { User } from '@server/entity/User'; +import type { PaginatedResponse } from '@server/interfaces/api/common'; + +export interface BlacklistItem { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title?: string; + createdAt?: Date; + user: User; +} + +export interface BlacklistResultsResponse extends PaginatedResponse { + results: BlacklistItem[]; +} diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 4a4a90d8..bc477169 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -27,6 +27,8 @@ export enum Permission { AUTO_REQUEST_TV = 33554432, RECENT_VIEW = 67108864, WATCHLIST_VIEW = 134217728, + MANAGE_BLACKLIST = 268435456, + VIEW_BLACKLIST = 1073741824, } export interface PermissionCheckOptions { diff --git a/server/migration/1699901142442-AddBlacklist.ts b/server/migration/1699901142442-AddBlacklist.ts new file mode 100644 index 00000000..eb096270 --- /dev/null +++ b/server/migration/1699901142442-AddBlacklist.ts @@ -0,0 +1,20 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlacklist1699901142442 implements MigrationInterface { + name = 'AddBlacklist1699901142442'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))` + ); + + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + } +} diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts new file mode 100644 index 00000000..4a07a499 --- /dev/null +++ b/server/routes/blacklist.ts @@ -0,0 +1,148 @@ +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { Blacklist } from '@server/entity/Blacklist'; +import Media from '@server/entity/Media'; +import { NotFoundError } from '@server/entity/Watchlist'; +import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces'; +import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { QueryFailedError } from 'typeorm'; +import { z } from 'zod'; + +const blacklistRoutes = Router(); + +export const blacklistAdd = z.object({ + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), + title: z.coerce.string().optional(), + user: z.coerce.number(), +}); + +blacklistRoutes.get( + '/', + isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { + type: 'or', + }), + rateLimit({ windowMs: 60 * 1000, max: 50 }), + async (req, res, next) => { + const pageSize = req.query.take ? Number(req.query.take) : 25; + const skip = req.query.skip ? Number(req.query.skip) : 0; + const search = (req.query.search as string) ?? ''; + + try { + let query = getRepository(Blacklist) + .createQueryBuilder('blacklist') + .leftJoinAndSelect('blacklist.user', 'user'); + + if (search.length > 0) { + query = query.where('blacklist.title like :title', { + title: `%${search}%`, + }); + } + + const [blacklistedItems, itemsCount] = await query + .orderBy('blacklist.createdAt', 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(itemsCount / pageSize), + pageSize, + results: itemsCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: blacklistedItems, + } as BlacklistResultsResponse); + } catch (error) { + logger.error('Something went wrong while retrieving blacklisted items', { + label: 'Blacklist', + errorMessage: error.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve blacklisted items.', + }); + } + } +); + +blacklistRoutes.post( + '/', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const values = blacklistAdd.parse(req.body); + + await Blacklist.addToBlacklist({ + blacklistRequest: values, + }); + + return res.status(201).send(); + } catch (error) { + if (!(error instanceof Error)) { + return; + } + + if (error instanceof QueryFailedError) { + switch (error.driverError.errno) { + case 19: + return next({ status: 412, message: 'Item already blacklisted' }); + default: + logger.warn('Something wrong with data blacklist', { + tmdbId: req.body.tmdbId, + mediaType: req.body.mediaType, + label: 'Blacklist', + }); + return next({ status: 409, message: 'Something wrong' }); + } + } + + return next({ status: 500, message: error.message }); + } + } +); + +blacklistRoutes.delete( + '/:id', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const blacklisteRepository = getRepository(Blacklist); + + const blacklistItem = await blacklisteRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, + }); + + await blacklisteRepository.remove(blacklistItem); + + const mediaRepository = getRepository(Media); + + const mediaItem = await mediaRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, + }); + + await mediaRepository.remove(mediaItem); + + return res.status(204).send(); + } catch (e) { + if (e instanceof NotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + +export default blacklistRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index 12434256..c7c8389e 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -23,6 +23,7 @@ import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import authRoutes from './auth'; +import blacklistRoutes from './blacklist'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import issueRoutes from './issue'; @@ -144,6 +145,7 @@ router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); router.use('/watchlist', isAuthenticated(), watchlistRoutes); +router.use('/blacklist', isAuthenticated(), blacklistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); diff --git a/server/routes/request.ts b/server/routes/request.ts index 94ae8384..320f149b 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -8,6 +8,7 @@ import { import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { + BlacklistedMediaError, DuplicateMediaRequestError, MediaRequest, NoSeasonsAvailableError, @@ -243,6 +244,8 @@ requestRoutes.post( return next({ status: 409, message: error.message }); case NoSeasonsAvailableError: return next({ status: 202, message: error.message }); + case BlacklistedMediaError: + return next({ status: 403, message: error.message }); default: return next({ status: 500, message: error.message }); } diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx new file mode 100644 index 00000000..217f4cef --- /dev/null +++ b/src/components/Blacklist/index.tsx @@ -0,0 +1,417 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDebouncedState from '@app/hooks/useDebouncedState'; +import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { + ChevronLeftIcon, + ChevronRightIcon, + MagnifyingGlassIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; +import type { + BlacklistItem, + BlacklistResultsResponse, +} from '@server/interfaces/api/blacklistInterfaces'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { FormattedRelativeTime, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +const messages = defineMessages('components.Blacklist', { + blacklistsettings: 'Blacklist Settings', + blacklistSettingsDescription: 'Manage blacklisted media.', + mediaName: 'Name', + mediaType: 'Type', + mediaTmdbId: 'tmdb Id', + blacklistdate: 'date', + blacklistedby: '{date} by {user}', + blacklistNotFoundError: '{title} is not blacklisted.', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const Blacklist = () => { + const [currentPageSize, setCurrentPageSize] = useState(10); + const [searchFilter, debouncedSearchFilter, setSearchFilter] = + useDebouncedState(''); + const router = useRouter(); + const intl = useIntl(); + + const page = router.query.page ? Number(router.query.page) : 1; + const pageIndex = page - 1; + const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + + const { + data, + error, + mutate: revalidate, + } = useSWR( + `/api/v1/blacklist/?take=${currentPageSize} + &skip=${pageIndex * currentPageSize} + ${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`, + { + refreshInterval: 0, + revalidateOnFocus: false, + } + ); + + // check if there's no data and no errors in the table + // so as to show a spinner inside the table and not refresh the whole component + if (!data && error) { + return ; + } + + const searchItem = (e: ChangeEvent) => { + // Remove the "page" query param from the URL + // so that the "skip" query param on line 62 is empty + // and the search returns results without skipping items + if (router.query.page) router.replace(router.basePath); + + setSearchFilter(e.target.value as string); + }; + + const hasNextPage = data && data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + + return ( + <> + +

{intl.formatMessage(globalMessages.blacklist)}
+ +
+
+ + + + searchItem(e)} + /> +
+
+ + {!data ? ( + + ) : data.results.length === 0 ? ( +
+ + {intl.formatMessage(globalMessages.noresults)} + +
+ ) : ( + data.results.map((item: BlacklistItem) => { + return ( +
+ +
+ ); + }) + )} + +
+ +
+ + ); +}; + +export default Blacklist; + +interface BlacklistedItemProps { + item: BlacklistItem; + revalidateList: () => void; +} + +const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { + const [isUpdating, setIsUpdating] = useState(false); + const { addToast } = useToasts(); + const { ref, inView } = useInView({ + triggerOnce: true, + }); + const intl = useIntl(); + const { hasPermission } = useUser(); + + const url = + item.mediaType === 'movie' + ? `/api/v1/movie/${item.tmdbId}` + : `/api/v1/tv/${item.tmdbId}`; + const { data: title, error } = useSWR( + inView ? url : null + ); + + if (!title && !error) { + return ( +
+ ); + } + + const removeFromBlacklist = async (tmdbId: number, title?: string) => { + setIsUpdating(true); + + const res = await fetch('/api/v1/blacklist/' + tmdbId, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + revalidateList(); + setIsUpdating(false); + }; + + return ( +
+ {title && title.backdropPath && ( +
+ +
+
+ )} +
+
+ + + +
+
+ {title && + (isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)} +
+ + + {title && (isMovie(title) ? title.title : title.name)} + + +
+
+ +
+
+ Status + + {intl.formatMessage(globalMessages.blacklisted)} + +
+ + {item.createdAt && ( +
+ + {intl.formatMessage(globalMessages.blacklisted)} + + + {intl.formatMessage(messages.blacklistedby, { + date: ( + + ), + user: ( + + + + + {item.user.displayName} + + + + ), + })} + +
+ )} +
+ {item.mediaType === 'movie' ? ( +
+
+ {intl.formatMessage(globalMessages.movie)} +
+
+ ) : ( +
+
+ {intl.formatMessage(globalMessages.tvshow)} +
+
+ )} +
+
+
+
+ {hasPermission(Permission.MANAGE_BLACKLIST) && ( + + removeFromBlacklist( + item.tmdbId, + title && (isMovie(title) ? title.title : title.name) + ) + } + confirmText={intl.formatMessage( + isUpdating ? globalMessages.deleting : globalMessages.areyousure + )} + className={`w-full ${ + isUpdating ? 'pointer-events-none opacity-50' : '' + }`} + > + + + {intl.formatMessage(globalMessages.removefromBlacklist)} + + + )} +
+
+ ); +}; diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx new file mode 100644 index 00000000..0908d373 --- /dev/null +++ b/src/components/BlacklistBlock/index.tsx @@ -0,0 +1,129 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid'; +import type { Blacklist } from '@server/entity/Blacklist'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; + +const messages = defineMessages('component.BlacklistBlock', { + blacklistedby: 'Blacklisted By', + blacklistdate: 'Blacklisted date', +}); + +interface BlacklistBlockProps { + blacklistItem: Blacklist; + onUpdate?: () => void; + onDelete?: () => void; +} + +const BlacklistBlock = ({ + blacklistItem, + onUpdate, + onDelete, +}: BlacklistBlockProps) => { + const { user } = useUser(); + const intl = useIntl(); + const [isUpdating, setIsUpdating] = useState(false); + const { addToast } = useToasts(); + + const removeFromBlacklist = async (tmdbId: number, title?: string) => { + setIsUpdating(true); + + const res = await fetch('/api/v1/blacklist/' + tmdbId, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + onUpdate && onUpdate(); + onDelete && onDelete(); + + setIsUpdating(false); + }; + + return ( +
+
+
+
+ + + + + + + {blacklistItem.user.displayName} + + + +
+
+
+ + + +
+
+
+
+
+ + {intl.formatMessage(globalMessages.blacklisted)} + +
+
+
+ + + + + {intl.formatDate(blacklistItem.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+
+ ); +}; + +export default BlacklistBlock; diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx new file mode 100644 index 00000000..aeca8d41 --- /dev/null +++ b/src/components/BlacklistModal/index.tsx @@ -0,0 +1,79 @@ +import Modal from '@app/components/Common/Modal'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +interface BlacklistModalProps { + tmdbId: number; + type: 'movie' | 'tv' | 'collection'; + show: boolean; + onComplete?: () => void; + onCancel?: () => void; + isUpdating?: boolean; +} + +const messages = defineMessages('component.BlacklistModal', { + blacklisting: 'Blacklisting', +}); + +const isMovie = ( + movie: MovieDetails | TvDetails | undefined +): movie is MovieDetails => { + if (!movie) return false; + return (movie as MovieDetails).title !== undefined; +}; + +const BlacklistModal = ({ + tmdbId, + type, + show, + onComplete, + onCancel, + isUpdating, +}: BlacklistModalProps) => { + const intl = useIntl(); + + const { data, error } = useSWR( + `/api/v1/${type}/${tmdbId}` + ); + + return ( + + + + ); +}; + +export default BlacklistModal; diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 7afa28e4..9e8ab32a 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -183,6 +183,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); } + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return (
{ sliderKey="collection-movies" isLoading={false} isEmpty={data.parts.length === 0} - items={data.parts.map((title) => ( - - ))} + items={data.parts + .filter((title) => { + if (!blacklistVisibility) + return title.mediaInfo?.status !== MediaStatus.BLACKLISTED; + return title; + }) + .map((title) => ( + + ))} />
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 46c946ae..f1c3bf66 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,8 +1,10 @@ import PersonCard from '@app/components/PersonCard'; import TitleCard from '@app/components/TitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import { Permission, useUser } from '@app/hooks/useUser'; import useVerticalScroll from '@app/hooks/useVerticalScroll'; import globalMessages from '@app/i18n/globalMessages'; +import { MediaStatus } from '@server/constants/media'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { CollectionResult, @@ -32,7 +34,14 @@ const ListView = ({ mutateParent, }: ListViewProps) => { const intl = useIntl(); + const { hasPermission } = useUser(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); + + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return ( <> {isEmpty && ( @@ -55,76 +64,89 @@ const ListView = ({ ); })} - {items?.map((title, index) => { - let titleCard: React.ReactNode; + {items + ?.filter((title) => { + if (!blacklistVisibility) + return ( + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED + ); + return title; + }) + .map((title, index) => { + let titleCard: React.ReactNode; - switch (title.mediaType) { - case 'movie': - titleCard = ( - 0 - } - canExpand - /> - ); - break; - case 'tv': - titleCard = ( - 0 - } - canExpand - /> - ); - break; - case 'collection': - titleCard = ( - - ); - break; - case 'person': - titleCard = ( - - ); - break; - } + switch (title.mediaType) { + case 'movie': + titleCard = ( + 0 + } + canExpand + /> + ); + break; + case 'tv': + titleCard = ( + 0 + } + canExpand + /> + ); + break; + case 'collection': + titleCard = ( + + ); + break; + case 'person': + titleCard = ( + + ); + break; + } - return
  • {titleCard}
  • ; - })} + return
  • {titleCard}
  • ; + })} {isLoading && !isReachingEnd && [...Array(20)].map((_item, i) => ( diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index a7e24a37..afcd72bf 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -1,6 +1,11 @@ import Spinner from '@app/assets/spinner.svg'; import { CheckCircleIcon } from '@heroicons/react/20/solid'; -import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid'; +import { + BellIcon, + ClockIcon, + EyeSlashIcon, + MinusSmallIcon, +} from '@heroicons/react/24/solid'; import { MediaStatus } from '@server/constants/media'; interface StatusBadgeMiniProps { @@ -44,6 +49,10 @@ const StatusBadgeMini = ({ ); indicatorIcon = ; break; + case MediaStatus.BLACKLISTED: + badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white'); + indicatorIcon = ; + break; case MediaStatus.PARTIALLY_AVAILABLE: badgeStyle.push( 'bg-green-500 border-green-400 ring-green-400 text-green-100' diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index d9b7d3fb..a947e262 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -8,6 +8,7 @@ import { ClockIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, SparklesIcon, TvIcon, @@ -25,6 +26,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { browsemovies: 'Movies', browsetv: 'Series', requests: 'Requests', + blacklist: 'Blacklist', issues: 'Issues', users: 'Users', settings: 'Settings', @@ -71,6 +73,17 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/requests/, }, + { + href: '/blacklist', + messagesKey: 'blacklist', + svgIcon: , + activeRegExp: /^\/blacklist/, + requiredPermission: [ + Permission.MANAGE_BLACKLIST, + Permission.VIEW_BLACKLIST, + ], + permissionType: 'or', + }, { href: '/issues', messagesKey: 'issues', diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index b669ebb4..0f96aa20 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -1,3 +1,4 @@ +import BlacklistBlock from '@app/components/BlacklistBlock'; import Button from '@app/components/Common/Button'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import SlideOver from '@app/components/Common/SlideOver'; @@ -284,6 +285,20 @@ const ManageSlideOver = ({
    )} + {data.mediaInfo?.status === MediaStatus.BLACKLISTED && ( +
    +

    + {intl.formatMessage(globalMessages.blacklist)} +

    +
    + revalidate()} + onDelete={() => onClose()} + /> +
    +
    + )} {hasPermission(Permission.ADMIN) && (data.mediaInfo?.serviceUrl || data.mediaInfo?.tautulliUrl || @@ -603,32 +618,17 @@ const ManageSlideOver = ({
    )} - {hasPermission(Permission.ADMIN) && data?.mediaInfo && ( -
    -

    - {intl.formatMessage(messages.manageModalAdvanced)} -

    -
    - {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( - - )} - {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( + {hasPermission(Permission.ADMIN) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLACKLISTED && ( +
    +

    + {intl.formatMessage(messages.manageModalAdvanced)} +

    +
    + {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( )} -
    - deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - - {intl.formatMessage(messages.manageModalClearMedia)} - - -
    - {intl.formatMessage(messages.manageModalClearMediaWarning, { - mediaType: intl.formatMessage( - mediaType === 'movie' ? messages.movie : messages.tvshow - ), - mediaServerName: - settings.currentSettings.mediaServerType === - MediaServerType.EMBY - ? 'Emby' - : settings.currentSettings.mediaServerType === - MediaServerType.PLEX - ? 'Plex' - : 'Jellyfin', - })} + {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( + + )} +
    + deleteMedia()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.manageModalClearMedia)} + + +
    + {intl.formatMessage(messages.manageModalClearMediaWarning, { + mediaType: intl.formatMessage( + mediaType === 'movie' ? messages.movie : messages.tvshow + ), + mediaServerName: + settings.currentSettings.mediaServerType === + MediaServerType.EMBY + ? 'Emby' + : settings.currentSettings.mediaServerType === + MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin', + })} +
    -
    - )} + )}
    ); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 56e0afc8..006f0df9 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -3,8 +3,10 @@ import PersonCard from '@app/components/PersonCard'; import Slider from '@app/components/Slider'; import TitleCard from '@app/components/TitleCard'; import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; +import { Permission } from '@server/lib/permissions'; import type { MovieResult, PersonResult, @@ -41,6 +43,7 @@ const MediaSlider = ({ onNewTitles, }: MediaSliderProps) => { const settings = useSettings(); + const { hasPermission } = useUser(); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { @@ -90,50 +93,65 @@ const MediaSlider = ({ return null; } - const finalTitles = titles.slice(0, 20).map((title) => { - switch (title.mediaType) { - case 'movie': + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + + const finalTitles = titles + .slice(0, 20) + .filter((title) => { + if (!blacklistVisibility) return ( - 0} - /> + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED ); - case 'tv': - return ( - 0} - /> - ); - case 'person': - return ( - - ); - } - }); + return title; + }) + .map((title) => { + switch (title.mediaType) { + case 'movie': + return ( + 0} + /> + ); + case 'tv': + return ( + 0} + /> + ); + case 'person': + return ( + + ); + } + }); if (linkUrl && titles.length > 20) { finalTitles.push( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index e4bc991e..c6583e3d 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -5,6 +5,7 @@ import RTRotten from '@app/assets/rt_rotten.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -35,6 +36,7 @@ import { CloudIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, PlayIcon, TicketIcon, @@ -55,7 +57,7 @@ import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -125,6 +127,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const [toggleWatchlist, setToggleWatchlist] = useState( !movie?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { @@ -155,6 +160,11 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl4k: data?.mediaInfo?.mediaUrl4k, @@ -374,6 +384,60 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { } }; + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: movie?.id, + mediaType: 'movie', + title: movie?.title, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + closeBlacklistModal(); + }; + + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    { revalidate={() => revalidate()} show={showManager} /> +
    {
    - <> - {toggleWatchlist ? ( - + {showHideButton && + data?.mediaInfo?.status !== MediaStatus.PROCESSING && + data?.mediaInfo?.status !== MediaStatus.AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PENDING && + data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + - - ) : ( - - )} - + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + + )} { - return (data?.parts ?? []).map((part) => part.id); + return (data?.parts ?? []) + .filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED) + .map((part) => part.id); }; const getAllRequestedParts = (): number[] => { @@ -248,6 +250,11 @@ const CollectionRequestModal = ({ { type: 'or' } ); + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return ( - {data?.parts.map((part) => { - const partRequest = getPartRequest(part.id); - const partMedia = - part.mediaInfo && - part.mediaInfo[is4k ? 'status4k' : 'status'] !== - MediaStatus.UNKNOWN - ? part.mediaInfo - : undefined; + {data?.parts + .filter((part) => { + if (!blacklistVisibility) + return ( + part.mediaInfo?.status !== MediaStatus.BLACKLISTED + ); + return part; + }) + .map((part) => { + const partRequest = getPartRequest(part.id); + const partMedia = + part.mediaInfo && + part.mediaInfo[is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ? part.mediaInfo + : undefined; - return ( - - - togglePart(part.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { - togglePart(part.id); - } - }} - className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ - !!partMedia || - partRequest || - (quota?.movie.limit && - currentlyRemaining <= 0 && - !isSelectedPart(part.id)) - ? 'opacity-50' - : '' + return ( + + - - - - -
    - togglePart(part.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + togglePart(part.id); + } }} - width={600} - height={900} - /> -
    -
    -
    - {part.releaseDate?.slice(0, 4)} + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ + (!!partMedia && + partMedia.status !== + MediaStatus.BLACKLISTED) || + partRequest || + (quota?.movie.limit && + currentlyRemaining <= 0 && + !isSelectedPart(part.id)) + ? 'opacity-50' + : '' + }`} + > + + + + + +
    +
    -
    - {part.title} +
    +
    + {part.releaseDate?.slice(0, 4)} +
    +
    + {part.title} +
    -
    - - - {!partMedia && !partRequest && ( - - {intl.formatMessage(globalMessages.notrequested)} - - )} - {!partMedia && - partRequest?.status === - MediaRequestStatus.PENDING && ( - - {intl.formatMessage(globalMessages.pending)} + + + {!partMedia && !partRequest && ( + + {intl.formatMessage( + globalMessages.notrequested + )} )} - {((!partMedia && - partRequest?.status === - MediaRequestStatus.APPROVED) || - partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.PROCESSING) && ( - - {intl.formatMessage(globalMessages.requested)} - - )} - {partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.AVAILABLE && ( - - {intl.formatMessage(globalMessages.available)} - - )} - - - ); - })} + {!partMedia && + partRequest?.status === + MediaRequestStatus.PENDING && ( + + {intl.formatMessage(globalMessages.pending)} + + )} + {((!partMedia && + partRequest?.status === + MediaRequestStatus.APPROVED) || + partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} + {partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {partMedia?.status === MediaStatus.BLACKLISTED && ( + + {intl.formatMessage(globalMessages.blacklisted)} + + )} + + + ); + })}
    diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 1d280d28..0821c017 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -360,6 +360,17 @@ const StatusBadge = ({ ); + case MediaStatus.BLACKLISTED: + return ( + + + {intl.formatMessage(is4k ? messages.status4k : messages.status, { + status: intl.formatMessage(globalMessages.blacklisted), + })} + + + ); + default: return null; } diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index b6c88796..2d10fdf1 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -1,7 +1,9 @@ import Spinner from '@app/assets/spinner.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; +import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; @@ -13,6 +15,8 @@ import { withProperties } from '@app/utils/typeHelpers'; import { Transition } from '@headlessui/react'; import { ArrowDownTrayIcon, + EyeIcon, + EyeSlashIcon, MinusCircleIcon, StarIcon, } from '@heroicons/react/24/outline'; @@ -20,7 +24,7 @@ import { MediaStatus } from '@server/constants/media'; import type { Watchlist } from '@server/entity/Watchlist'; import type { MediaType } from '@server/models/Search'; import Link from 'next/link'; -import { Fragment, useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import { mutate } from 'swr'; @@ -65,7 +69,7 @@ const TitleCard = ({ }: TitleCardProps) => { const isTouch = useIsTouch(); const intl = useIntl(); - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const [isUpdating, setIsUpdating] = useState(false); const [currentStatus, setCurrentStatus] = useState(status); const [showDetail, setShowDetail] = useState(false); @@ -74,6 +78,8 @@ const TitleCard = ({ const [toggleWatchlist, setToggleWatchlist] = useState( !isAddedToWatchlist ); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); + const cardRef = useRef(null); // Just to get the year from the date if (year) { @@ -94,6 +100,11 @@ const TitleCard = ({ [] ); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const onClickWatchlistBtn = async (): Promise => { setIsUpdating(true); try { @@ -166,6 +177,99 @@ const TitleCard = ({ } }; + const onClickHideItemBtn = async (): Promise => { + setIsUpdating(true); + const topNode = cardRef.current; + + if (topNode) { + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: id, + mediaType, + title, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.BLACKLISTED); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsUpdating(false); + closeBlacklistModal(); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const onClickShowBlacklistBtn = async (): Promise => { + setIsUpdating(true); + const topNode = cardRef.current; + + if (topNode) { + const res = await fetch('/api/v1/blacklist/' + id, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.UNKNOWN); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsUpdating(false); + }; + const closeModal = useCallback(() => setShowRequestModal(false), []); const showRequestButton = hasPermission( @@ -178,10 +282,15 @@ const TitleCard = ({ { type: 'or' } ); + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    +
    - {showDetail && ( - <> + {showDetail && currentStatus !== MediaStatus.BLACKLISTED && ( +
    {toggleWatchlist ? ( )} - + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + + )} +
    )} + {showDetail && + showHideButton && + currentStatus == MediaStatus.BLACKLISTED && ( + + + + )} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && ( -
    - +
    +
    + +
    )}
    diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 634c72d0..cf788237 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -4,6 +4,7 @@ import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -38,6 +39,7 @@ import { ArrowRightCircleIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, PlayIcon, } from '@heroicons/react/24/outline'; @@ -61,7 +63,7 @@ import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -125,6 +127,9 @@ const TvDetails = ({ tv }: TvDetailsProps) => { const [toggleWatchlist, setToggleWatchlist] = useState( !tv?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { @@ -155,6 +160,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl4k: data?.mediaInfo?.mediaUrl4k, @@ -397,6 +407,60 @@ const TvDetails = ({ tv }: TvDetailsProps) => { } }; + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: tv?.id, + mediaType: 'tv', + title: tv?.name, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + closeBlacklistModal(); + }; + + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    {
    )} + setShowIssueModal(false)} show={showIssueModal} @@ -528,40 +600,61 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
    - <> - {toggleWatchlist ? ( - + {showHideButton && + data?.mediaInfo?.status !== MediaStatus.PROCESSING && + data?.mediaInfo?.status !== MediaStatus.AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PENDING && + data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + - - ) : ( - - )} - + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + + )} {title} was successfully blacklisted.', + blacklistError: 'Something went wrong try again.', + blacklistDuplicateError: + '{title} has already been blacklisted.', + removeFromBlacklistSuccess: + '{title} was successfully removed from the Blacklist.', + addToBlacklist: 'Add to Blacklist', + removefromBlacklist: 'Remove from Blacklist', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index cf66b67e..42e8e6f5 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1,7 +1,18 @@ { + "component.BlacklistBlock.blacklistdate": "Blacklisted date", + "component.BlacklistBlock.blacklistedby": "Blacklisted By", + "component.BlacklistModal.blacklisting": "Blacklisting", "components.AirDateBadge.airedrelative": "Aired {relativeTime}", "components.AirDateBadge.airsrelative": "Airing {relativeTime}", "components.AppDataWarning.dockerVolumeMissingDescription": "The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", + "components.Blacklist.blacklistNotFoundError": "{title} is not blacklisted.", + "components.Blacklist.blacklistSettingsDescription": "Manage blacklisted media.", + "components.Blacklist.blacklistdate": "date", + "components.Blacklist.blacklistedby": "{date} by {user}", + "components.Blacklist.blacklistsettings": "Blacklist Settings", + "components.Blacklist.mediaName": "Name", + "components.Blacklist.mediaTmdbId": "tmdb Id", + "components.Blacklist.mediaType": "Type", "components.CollectionDetails.numberofmovies": "{count} Movies", "components.CollectionDetails.overview": "Overview", "components.CollectionDetails.requestcollection": "Request Collection", @@ -200,6 +211,7 @@ "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", + "components.Layout.Sidebar.blacklist": "Blacklist", "components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", @@ -387,8 +399,12 @@ "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.", "components.PermissionEdit.autorequestSeries": "Auto-Request Series", "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.", + "components.PermissionEdit.blacklistedItems": "Blacklist media.", + "components.PermissionEdit.blacklistedItemsDescription": "Grant permission to blacklist media.", "components.PermissionEdit.createissues": "Report Issues", "components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.", + "components.PermissionEdit.manageblacklist": "Manage Blacklist", + "components.PermissionEdit.manageblacklistDescription": "Grant permission to manage blacklisted media.", "components.PermissionEdit.manageissues": "Manage Issues", "components.PermissionEdit.manageissuesDescription": "Grant permission to manage media issues.", "components.PermissionEdit.managerequests": "Manage Requests", @@ -407,6 +423,8 @@ "components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.", "components.PermissionEdit.users": "Manage Users", "components.PermissionEdit.usersDescription": "Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.", + "components.PermissionEdit.viewblacklistedItems": "View blacklisted media.", + "components.PermissionEdit.viewblacklistedItemsDescription": "Grant permission to view blacklisted media.", "components.PermissionEdit.viewissues": "View Issues", "components.PermissionEdit.viewissuesDescription": "Grant permission to view media issues reported by other users.", "components.PermissionEdit.viewrecent": "View Recently Added", @@ -1299,6 +1317,11 @@ "i18n.areyousure": "Are you sure?", "i18n.available": "Available", "i18n.back": "Back", + "i18n.blacklist": "Blacklist", + "i18n.blacklistDuplicateError": "{title} has already been blacklisted.", + "i18n.blacklistError": "Something went wrong try again.", + "i18n.blacklistSuccess": "{title} was successfully blacklisted.", + "i18n.blacklisted": "Blacklisted", "i18n.cancel": "Cancel", "i18n.canceling": "Canceling…", "i18n.close": "Close", @@ -1324,6 +1347,8 @@ "i18n.pending": "Pending", "i18n.previous": "Previous", "i18n.processing": "Processing", + "i18n.removeFromBlacklistSuccess": "{title} was successfully removed from the Blacklist.", + "i18n.removefromBlacklist": "Remove from Blacklist", "i18n.request": "Request", "i18n.request4k": "Request in 4K", "i18n.requested": "Requested", diff --git a/src/pages/blacklist/index.tsx b/src/pages/blacklist/index.tsx new file mode 100644 index 00000000..e7e3903b --- /dev/null +++ b/src/pages/blacklist/index.tsx @@ -0,0 +1,13 @@ +import Blacklist from '@app/components/Blacklist'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@server/lib/permissions'; +import type { NextPage } from 'next'; + +const BlacklistPage: NextPage = () => { + useRouteGuard([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { + type: 'or', + }); + return ; +}; + +export default BlacklistPage; From 2b05fffaceea49b51edfacd14ccb4675dc0959a1 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 17 Sep 2024 08:12:00 +0200 Subject: [PATCH 008/162] chore(issuetemplate): update defaults labels of GitHub issues (#968) --- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/ISSUE_TEMPLATE/enhancement.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index a98da750..3f760018 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,6 +1,6 @@ name: 🐛 Bug Report description: Report a problem -labels: ['type:bug', 'awaiting-triage'] +labels: ['bug', 'awaiting triage'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml index 35a7adbd..4327a8f6 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.yml +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -1,6 +1,6 @@ name: ✨ Feature Request description: Suggest an idea -labels: ['type:enhancement', 'awaiting-triage'] +labels: ['enhancement', 'awaiting triage'] body: - type: markdown attributes: From edfd80444c3d8b9d82998f8a7bbb0b3496701602 Mon Sep 17 00:00:00 2001 From: Joaquin Olivero <66050823+JoaquinOlivero@users.noreply.github.com> Date: Wed, 18 Sep 2024 23:38:14 -0300 Subject: [PATCH 009/162] refactor: Proxy and cache avatar images (#907) * refactor: proxy and cache user avatar images * fix: extract keys * fix: set avatar image URL * fix: show the correct avatar in the list of available users in advanced request * fix(s): set correct src URL for cached image * fix: remove unexpired unused image when a user changes their avatar * fix: requested changes * refactor: use 'mime' package to detmerine file extension * style: grammar * refactor: checks if the default avatar is cached to avoid creating duplicates for different users * fix: fix vulnerability * fix: fix incomplete URL substring sanitization * refactor: only cache avatar with http url protocol * fix: remove log and correctly set the if statement for the cached image component * fix: avatar images not showing on issues page * style: formatting --------- Co-authored-by: JoaquinOlivero --- next.config.js | 1 - overseerr-api.yml | 9 ++ package.json | 2 + pnpm-lock.yaml | 25 +++- server/index.ts | 2 + server/interfaces/api/settingsInterfaces.ts | 2 +- server/job/schedule.ts | 3 + server/lib/imageproxy.ts | 135 +++++++++++++----- server/routes/auth.ts | 20 ++- server/routes/avatarproxy.ts | 32 +++++ server/routes/settings/index.ts | 2 + src/components/Common/CachedImage/index.tsx | 7 +- .../IssueDetails/IssueComment/index.tsx | 6 +- src/components/IssueDetails/index.tsx | 7 +- src/components/IssueList/IssueItem/index.tsx | 5 +- src/components/Layout/UserDropdown/index.tsx | 10 +- src/components/ManageSlideOver/index.tsx | 6 +- src/components/RequestCard/index.tsx | 5 +- .../RequestList/RequestItem/index.tsx | 9 +- .../RequestModal/AdvancedRequester/index.tsx | 6 +- .../Settings/SettingsJobsCache/index.tsx | 14 ++ .../UserList/JellyfinImportModal.tsx | 4 +- src/components/UserList/index.tsx | 4 +- .../UserProfile/ProfileHeader/index.tsx | 4 +- src/i18n/locale/en.json | 2 + 25 files changed, 238 insertions(+), 84 deletions(-) create mode 100644 server/routes/avatarproxy.ts diff --git a/next.config.js b/next.config.js index 35a316c6..43aa421d 100644 --- a/next.config.js +++ b/next.config.js @@ -10,7 +10,6 @@ module.exports = { remotePatterns: [ { hostname: 'gravatar.com' }, { hostname: 'image.tmdb.org' }, - { hostname: '*', protocol: 'https' }, ], }, webpack(config) { diff --git a/overseerr-api.yml b/overseerr-api.yml index f5e1d162..96a4520a 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2790,6 +2790,15 @@ paths: imageCount: type: number example: 123 + avatar: + type: object + properties: + size: + type: number + example: 123456 + imageCount: + type: number + example: 123 apiCaches: type: array items: diff --git a/package.json b/package.json index 426d774d..9ce9330f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "formik": "^2.4.6", "gravatar-url": "3.1.0", "lodash": "4.17.21", + "mime": "3", "next": "^14.2.4", "node-cache": "5.1.2", "node-gyp": "9.3.1", @@ -119,6 +120,7 @@ "@types/express": "4.17.17", "@types/express-session": "1.17.6", "@types/lodash": "4.14.191", + "@types/mime": "3", "@types/node": "20.14.8", "@types/node-schedule": "2.1.0", "@types/nodemailer": "6.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea593fea..7391a775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: lodash: specifier: 4.17.21 version: 4.17.21 + mime: + specifier: '3' + version: 3.0.0 next: specifier: ^14.2.4 version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -264,6 +267,9 @@ importers: '@types/lodash': specifier: 4.14.191 version: 4.14.191 + '@types/mime': + specifier: '3' + version: 3.0.4 '@types/node': specifier: 20.14.8 version: 20.14.8 @@ -2848,6 +2854,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/mime@3.0.4': + resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==} + '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} @@ -10836,7 +10845,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.6.2 + semver: 7.3.8 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -10911,13 +10920,13 @@ snapshots: '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.2 + semver: 7.3.8 optional: true '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.2 + semver: 7.3.8 '@npmcli/move-file@1.1.2': dependencies: @@ -12326,7 +12335,7 @@ snapshots: read-pkg: 5.2.0 registry-auth-token: 5.0.2 semantic-release: 19.0.5(encoding@0.1.13) - semver: 7.6.2 + semver: 7.3.8 tempy: 1.0.1 '@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))': @@ -12670,6 +12679,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/mime@3.0.4': {} + '@types/minimatch@3.0.5': {} '@types/minimist@1.2.5': {} @@ -12887,7 +12898,7 @@ snapshots: debug: 4.3.5(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.2 + semver: 7.3.8 tsutils: 3.21.0(typescript@4.9.5) optionalDependencies: typescript: 4.9.5 @@ -17269,7 +17280,7 @@ snapshots: nopt: 5.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.6.2 + semver: 7.3.8 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -17348,7 +17359,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.14.0 - semver: 7.6.2 + semver: 7.3.8 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} diff --git a/server/index.ts b/server/index.ts index ef20674d..4ccc6fed 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; +import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; import { getAppVersion } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; @@ -202,6 +203,7 @@ app // Do not set cookies so CDNs can cache them server.use('/imageproxy', clearCookies, imageproxy); + server.use('/avatarproxy', clearCookies, avatarproxy); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 1bf40cdb..579f1109 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -58,7 +58,7 @@ export interface CacheItem { export interface CacheResponse { apiCaches: CacheItem[]; - imageCache: Record<'tmdb', { size: number; imageCount: number }>; + imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; } export interface StatusResponse { diff --git a/server/job/schedule.ts b/server/job/schedule.ts index b358130c..a210988e 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -227,6 +227,9 @@ export const startJobs = (): void => { }); // Clean TMDB image cache ImageProxy.clearCache('tmdb'); + + // Clean users avatar image cache + ImageProxy.clearCache('avatar'); }), }); diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 195e96b9..badfe94f 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -3,6 +3,7 @@ import type { RateLimitOptions } from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit'; import { createHash } from 'crypto'; import { promises } from 'fs'; +import mime from 'mime/lite'; import path, { join } from 'path'; type ImageResponse = { @@ -11,7 +12,7 @@ type ImageResponse = { curRevalidate: number; isStale: boolean; etag: string; - extension: string; + extension: string | null; cacheKey: string; cacheMiss: boolean; }; @@ -27,29 +28,45 @@ class ImageProxy { let deletedImages = 0; const cacheDirectory = path.join(baseCacheDirectory, key); - const files = await promises.readdir(cacheDirectory); + try { + const files = await promises.readdir(cacheDirectory); - for (const file of files) { - const filePath = path.join(cacheDirectory, file); - const stat = await promises.lstat(filePath); + 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); + 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(); + 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; + if (now > expireAt) { + await promises.rm(path.join(filePath), { + recursive: true, + }); + deletedImages += 1; + } } } } + } catch (e) { + if (e.code === 'ENOENT') { + logger.error('Directory not found', { + label: 'Image Cache', + message: e.message, + }); + } else { + logger.error('Failed to read directory', { + label: 'Image Cache', + message: e.message, + }); + } } - logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { + logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, { label: 'Image Cache', }); } @@ -69,33 +86,49 @@ class ImageProxy { } private static async getDirectorySize(dir: string): Promise { - const files = await promises.readdir(dir, { - withFileTypes: true, - }); + try { + const files = await promises.readdir(dir, { + withFileTypes: true, + }); - const paths = files.map(async (file) => { - const path = join(dir, file.name); + const paths = files.map(async (file) => { + const path = join(dir, file.name); - if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); + if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); - if (file.isFile()) { - const { size } = await promises.stat(path); + if (file.isFile()) { + const { size } = await promises.stat(path); - return size; + return size; + } + + return 0; + }); + + return (await Promise.all(paths)) + .flat(Infinity) + .reduce((i, size) => i + size, 0); + } catch (e) { + if (e.code === 'ENOENT') { + return 0; } + } - return 0; - }); - - return (await Promise.all(paths)) - .flat(Infinity) - .reduce((i, size) => i + size, 0); + return 0; } private static async getImageCount(dir: string) { - const files = await promises.readdir(dir); + try { + const files = await promises.readdir(dir); - return files.length; + return files.length; + } catch (e) { + if (e.code === 'ENOENT') { + return 0; + } + } + + return 0; } private fetch: typeof fetch; @@ -147,6 +180,27 @@ class ImageProxy { return imageResponse; } + public async clearCachedImage(path: string) { + // find cacheKey + const cacheKey = this.getCacheKey(path); + + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const files = await promises.readdir(directory); + + await promises.rm(directory, { recursive: true }); + + logger.info(`Cleared ${files[0]} from cache 'avatar'`, { + label: 'Image Cache', + }); + } catch (e) { + logger.error('Failed to clear cached image', { + label: 'Image Cache', + message: e.message, + }); + } + } + private async get(cacheKey: string): Promise { try { const directory = join(this.getCacheDirectory(), cacheKey); @@ -187,16 +241,25 @@ class ImageProxy { const directory = join(this.getCacheDirectory(), cacheKey); const href = this.baseUrl + - (this.baseUrl.endsWith('/') ? '' : '/') + + (this.baseUrl.length > 0 + ? this.baseUrl.endsWith('/') + ? '' + : '/' + : '') + (path.startsWith('/') ? path.slice(1) : path); const response = await this.fetch(href); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const extension = path.split('.').pop() ?? ''; - const maxAge = Number( + const extension = mime.getExtension( + response.headers.get('content-type') ?? '' + ); + + let maxAge = Number( (response.headers.get('cache-control') ?? '0').split('=')[1] ); + + if (!maxAge) maxAge = 86400; const expireAt = Date.now() + maxAge * 1000; const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); @@ -232,7 +295,7 @@ class ImageProxy { private async writeToCacheDir( dir: string, - extension: string, + extension: string | null, maxAge: number, expireAt: number, buffer: Buffer, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cd931c25..4e7f7727 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; +import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -342,6 +343,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }), userType: UserType.EMBY, }); + break; case MediaServerType.JELLYFIN: settings.main.mediaServerType = MediaServerType.JELLYFIN; @@ -360,6 +362,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }), userType: UserType.JELLYFIN, }); + break; default: throw new Error('select_server_type'); @@ -407,12 +410,24 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ); // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { - user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + if (avatar !== user.avatar) { + const avatarProxy = new ImageProxy('avatar', ''); + avatarProxy.clearCachedImage(user.avatar); + } + user.avatar = avatar; } else { - user.avatar = gravatarUrl(user.email || account.User.Name, { + const avatar = gravatarUrl(user.email || account.User.Name, { default: 'mm', size: 200, }); + + if (avatar !== user.avatar) { + const avatarProxy = new ImageProxy('avatar', ''); + avatarProxy.clearCachedImage(user.avatar); + } + + user.avatar = avatar; } user.jellyfinUsername = account.User.Name; @@ -462,6 +477,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ? UserType.JELLYFIN : UserType.EMBY, }); + //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; if (passedExplicitPassword) { diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts new file mode 100644 index 00000000..65638df2 --- /dev/null +++ b/server/routes/avatarproxy.ts @@ -0,0 +1,32 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); + +const avatarImageProxy = new ImageProxy('avatar', ''); +// Proxy avatar images +router.get('/*', async (req, res) => { + const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url; + + try { + const imageData = await avatarImageProxy.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 avatar image', { + imagePath, + errorMessage: e.message, + }); + } +}); + +export default router; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 30898d2a..30c854af 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -746,11 +746,13 @@ settingsRoutes.get('/cache', async (_req, res) => { })); const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + const avatarImageCache = await ImageProxy.getImageStats('avatar'); return res.status(200).json({ apiCaches, imageCache: { tmdb: tmdbImageCache, + avatar: avatarImageCache, }, }); }); diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index 6dfb8ee7..7c0d52c2 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -16,8 +16,11 @@ const CachedImage = ({ src, ...props }: ImageProps) => { 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'); + if (parsedUrl.host === 'image.tmdb.org') { + if (currentSettings.cacheImages) + imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); + } else if (parsedUrl.host !== 'gravatar.com') { + imageUrl = '/avatarproxy/' + imageUrl; } } diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index 0c36ca66..ab3f59ad 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -1,4 +1,5 @@ import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; import Modal from '@app/components/Common/Modal'; import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; @@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import type { default as IssueCommentType } from '@server/entity/IssueComment'; import { Field, Form, Formik } from 'formik'; -import Image from 'next/image'; import Link from 'next/link'; import { Fragment, useState } from 'react'; import { FormattedRelativeTime, useIntl } from 'react-intl'; @@ -88,8 +88,8 @@ const IssueComment = ({ - { } className="group ml-1 inline-flex h-full items-center xl:ml-1.5" > - diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 1b52be3e..c49a57d4 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -11,7 +11,6 @@ import { MediaType } from '@server/constants/media'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; -import Image from 'next/image'; import Link from 'next/link'; import { useInView } from 'react-intersection-observer'; import { FormattedRelativeTime, useIntl } from 'react-intl'; @@ -226,8 +225,8 @@ const IssueItem = ({ issue }: IssueItemProps) => { href={`/users/${issue.createdBy.id}`} className="group flex items-center truncate" > - { className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" data-testid="user-menu" > - {
    - - {user.displayName} - {user.displayName} { className="group flex items-center" > - { className="group flex items-center" > - - - { className="group flex items-center truncate" > - { className="group flex items-center truncate" > - - - {appDataPath}/cache/images.', imagecachecount: 'Images Cached', imagecachesize: 'Total Cache Size', + usersavatars: "Users' Avatars", } ); @@ -573,6 +574,19 @@ const SettingsJobs = () => { {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} + + + {intl.formatMessage(messages.usersavatars)} (avatar) + + + {intl.formatNumber( + cacheData?.imageCache.avatar.imageCount ?? 0 + )} + + + {formatBytes(cacheData?.imageCache.avatar.size ?? 0)} + +
    diff --git a/src/components/UserList/JellyfinImportModal.tsx b/src/components/UserList/JellyfinImportModal.tsx index 36dbe0aa..e95a0a7d 100644 --- a/src/components/UserList/JellyfinImportModal.tsx +++ b/src/components/UserList/JellyfinImportModal.tsx @@ -1,11 +1,11 @@ import Alert from '@app/components/Common/Alert'; +import CachedImage from '@app/components/Common/CachedImage'; import Modal from '@app/components/Common/Modal'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { MediaServerType } from '@server/constants/server'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; -import Image from 'next/image'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -249,7 +249,7 @@ const JellyfinImportModal: React.FC = ({
    - { href={`/users/${user.id}`} className="h-10 w-10 flex-shrink-0" > - {
    - Date: Wed, 25 Sep 2024 21:25:44 +0200 Subject: [PATCH 010/162] fix(blacklist): add blacklist to mobile menu (#980) * fix(blacklist): add blacklist to mobile menu The "Blacklist" menu was only available in the desktop sidebar, not in the mobile menu. fix #979 * fix: export translations --- src/components/Layout/MobileMenu/index.tsx | 14 ++++++++++++++ src/i18n/locale/en.json | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 4338810e..fe1e2e40 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -7,6 +7,7 @@ import { CogIcon, EllipsisHorizontalIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, SparklesIcon, TvIcon, @@ -16,6 +17,7 @@ import { ClockIcon as FilledClockIcon, CogIcon as FilledCogIcon, ExclamationTriangleIcon as FilledExclamationTriangleIcon, + EyeSlashIcon as FilledEyeSlashIcon, FilmIcon as FilledFilmIcon, SparklesIcon as FilledSparklesIcon, TvIcon as FilledTvIcon, @@ -84,6 +86,18 @@ const MobileMenu = () => { svgIconSelected: , activeRegExp: /^\/requests/, }, + { + href: '/blacklist', + content: intl.formatMessage(menuMessages.blacklist), + svgIcon: , + svgIconSelected: , + activeRegExp: /^\/blacklist/, + requiredPermission: [ + Permission.MANAGE_BLACKLIST, + Permission.VIEW_BLACKLIST, + ], + permissionType: 'or', + }, { href: '/issues', content: intl.formatMessage(menuMessages.issues), diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 689e2315..c86c5d3e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1030,6 +1030,7 @@ "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving…", "components.Settings.scan": "Sync Libraries", + "components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.", "components.Settings.scanning": "Syncing…", "components.Settings.serverLocal": "local", "components.Settings.serverRemote": "remote", @@ -1050,6 +1051,7 @@ "components.Settings.tautulliSettings": "Tautulli Settings", "components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.", "components.Settings.timeout": "Timeout", + "components.Settings.tip": "Tip", "components.Settings.toastPlexConnecting": "Attempting to connect to Plex…", "components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.", "components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!", @@ -1079,16 +1081,14 @@ "components.Setup.continue": "Continue", "components.Setup.finish": "Finish Setup", "components.Setup.finishing": "Finishing…", - "components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign In", + "components.Setup.signin": "Sign in to your account", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", "components.Setup.signinWithPlex": "Enter your Plex details", "components.Setup.subtitle": "Get started by choosing your media server", - "components.Setup.tip": "Tip", "components.Setup.welcome": "Welcome to Jellyseerr", "components.StatusBadge.managemedia": "Manage {mediaType}", "components.StatusBadge.openinarr": "Open in {arr}", @@ -1233,6 +1233,7 @@ "components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…", "components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.", + "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!", "components.UserProfile.UserSettings.UserGeneralSettings.user": "User", "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", @@ -1312,6 +1313,7 @@ "components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.totalrequests": "Total Requests", "components.UserProfile.unlimited": "Unlimited", + "i18n.addToBlacklist": "Add to Blacklist", "i18n.advanced": "Advanced", "i18n.all": "All", "i18n.approve": "Approve", From a5d22ba5b83dd0e812b16f06476d993b5d59cb2a Mon Sep 17 00:00:00 2001 From: Thomas Loubiou Date: Mon, 30 Sep 2024 18:56:25 +0200 Subject: [PATCH 011/162] feat: allow request managers to delete data from sonarr/radarr (#644) * feat: allow requests managers to delete media files * fix(i18n): add missing translations * fix(i18n): remove french translation * refactor: use fetch API --- .../RequestList/RequestItem/index.tsx | 43 +++++++++++++++---- src/i18n/locale/en.json | 1 + 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 22a988cc..e989f2f6 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -42,6 +42,7 @@ const messages = defineMessages('components.RequestList.RequestItem', { tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', unknowntitle: 'Unknown Title', + removearr: 'Remove from {arr}', profileName: 'Profile', }); @@ -341,6 +342,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { revalidateList(); }; + const deleteMediaFile = async () => { + if (request.media) { + await fetch(`/api/v1/media/${request.media.id}/file`, { + method: 'DELETE', + }); + await fetch(`/api/v1/media/${request.media.id}`, { + method: 'DELETE', + }); + revalidateList(); + } + }; + const retryRequest = async () => { setRetrying(true); @@ -666,14 +679,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { )} {requestData.status !== MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( - deleteRequest()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.deleterequest)} - + <> + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deleterequest)} + + deleteMediaFile()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.removearr, { + arr: request.type === 'movie' ? 'Radarr' : 'Sonarr', + })} + + + )} {requestData.status === MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c86c5d3e..1642872b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -298,6 +298,7 @@ "components.ManageSlideOver.plays": "{playCount, number} {playCount, plural, one {play} other {plays}}", "components.ManageSlideOver.removearr": "Remove from {arr}", "components.ManageSlideOver.removearr4k": "Remove from 4K {arr}", + "components.RequestList.RequestItem.removearr": "Remove from {arr}", "components.ManageSlideOver.tvshow": "series", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", From 96e1d40304749ce00d2ff7359efc39a1d9724358 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 2 Oct 2024 20:59:35 +0200 Subject: [PATCH 012/162] fix(session): set the correct TTL for the cookie store (#992) The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of 30 days. fix #991 --- server/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 4ccc6fed..96590361 100644 --- a/server/index.ts +++ b/server/index.ts @@ -175,7 +175,7 @@ app }, store: new TypeormStore({ cleanupLimit: 2, - ttl: 1000 * 60 * 60 * 24 * 30, + ttl: 60 * 60 * 24 * 30, }).connect(sessionRespository) as Store, }) ); From 92ba26207dcb1ddd696e0f01931d2609c521ae45 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 10 Oct 2024 11:37:08 +0200 Subject: [PATCH 013/162] feat: refresh monitored downloads before getting queue items (#994) Currently, we sync with sonarr/radarr with whatever value those return. Radarr/Sonarr syncs the activity from the download clients every few minutes. This leads to inaccurate estimated download times, because of the refresh delay with Jellyseerr and the *arrs. This PR fixes this by making a request to the *arrs to refresh the monitored downloads just before we get these downloads information. re #866 --- server/api/externalapi.ts | 6 +++--- server/api/servarr/base.ts | 27 ++++++++++++++++++++------- server/lib/downloadtracker.ts | 2 ++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 4f0ded02..0dfddefc 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -76,7 +76,7 @@ class ExternalAPI { } const data = await this.getDataFromResponse(response); - if (this.cache) { + if (this.cache && ttl !== 0) { this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL); } @@ -120,7 +120,7 @@ class ExternalAPI { } const resData = await this.getDataFromResponse(response); - if (this.cache) { + if (this.cache && ttl !== 0) { this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); } @@ -164,7 +164,7 @@ class ExternalAPI { } const resData = await this.getDataFromResponse(response); - if (this.cache) { + if (this.cache && ttl !== 0) { this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL); } diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index ae024b6e..8b0d5ca0 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -157,9 +157,13 @@ class ServarrBase extends ExternalAPI { public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { try { - const data = await this.get>(`/queue`, { - includeEpisode: 'true', - }); + const data = await this.get>( + `/queue`, + { + includeEpisode: 'true', + }, + 0 + ); return data.records; } catch (e) { @@ -193,15 +197,24 @@ class ServarrBase extends ExternalAPI { } }; + async refreshMonitoredDownloads(): Promise { + await this.runCommand('RefreshMonitoredDownloads', {}); + } + protected async runCommand( commandName: string, options: Record ): Promise { try { - await this.post(`/command`, { - name: commandName, - ...options, - }); + await this.post( + `/command`, + { + name: commandName, + ...options, + }, + {}, + 0 + ); } catch (e) { throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`); } diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index e948c580..b96bfca8 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -85,6 +85,7 @@ class DownloadTracker { }); try { + await radarr.refreshMonitoredDownloads(); const queueItems = await radarr.getQueue(); this.radarrServers[server.id] = queueItems.map((item) => ({ @@ -162,6 +163,7 @@ class DownloadTracker { }); try { + await sonarr.refreshMonitoredDownloads(); const queueItems = await sonarr.getQueue(); this.sonarrServers[server.id] = queueItems.map((item) => ({ From a0f80fe7647ef4a9025ca93407cd21ddc640fed1 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 16 Oct 2024 03:50:21 +0800 Subject: [PATCH 014/162] fix: use jellyfinMediaId4k for mediaUrl4k (#1006) Fixes the issue where mediaUrl4K was still using the non-4k mediaId despite having the correct 4k Id stored. fix #520 --- server/entity/Media.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 4f64178a..de10cebc 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -231,7 +231,7 @@ class Media { this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; } if (this.jellyfinMediaId4k) { - this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; + this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; } } } From 4945b5429848b36fc0ee41cf0277ed79f53d8286 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 17 Oct 2024 07:25:06 +0800 Subject: [PATCH 015/162] fix: fetch override to attach XSRF token to fix csrfProtection issue (#1014) During the migration from Axios to fetch, we overlooked the fact that Axios automatically handled CSRF tokens, while fetch does not. When CSRF protection was turned on, requests were failing with an "invalid CSRF token" error for users accessing the app even via HTTPS. This commit overrides fetch to ensure that the CSRF token is included in all requests. fix #1011 --- src/pages/_app.tsx | 1 + src/utils/fetchOverride.ts | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/utils/fetchOverride.ts diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ba0677c6..e5704052 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -12,6 +12,7 @@ import { SettingsProvider } from '@app/context/SettingsContext'; import { UserContext } from '@app/context/UserContext'; import type { User } from '@app/hooks/useUser'; import '@app/styles/globals.css'; +import '@app/utils/fetchOverride'; import { polyfillIntl } from '@app/utils/polyfillIntl'; import { MediaServerType } from '@server/constants/server'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; diff --git a/src/utils/fetchOverride.ts b/src/utils/fetchOverride.ts new file mode 100644 index 00000000..e0a90012 --- /dev/null +++ b/src/utils/fetchOverride.ts @@ -0,0 +1,46 @@ +const getCsrfToken = (): string | null => { + if (typeof window !== 'undefined') { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; + } + return null; +}; + +const isSameOrigin = (url: RequestInfo | URL): boolean => { + const parsedUrl = new URL( + url instanceof Request ? url.url : url.toString(), + window.location.origin + ); + return parsedUrl.origin === window.location.origin; +}; + +// We are using a custom fetch implementation to add the X-XSRF-TOKEN heade +// to all requests. This is required when CSRF protection is enabled. +if (typeof window !== 'undefined') { + const originalFetch: typeof fetch = window.fetch; + + (window as typeof globalThis).fetch = async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + if (!isSameOrigin(input)) { + return originalFetch(input, init); + } + + const csrfToken = getCsrfToken(); + + const headers = { + ...(init?.headers || {}), + ...(csrfToken ? { 'XSRF-TOKEN': csrfToken } : {}), + }; + + const newInit: RequestInit = { + ...init, + headers, + }; + + return originalFetch(input, newInit); + }; +} + +export {}; From 9de304d17a222c6b48294b0b83f606d378949a63 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 07:25:36 +0800 Subject: [PATCH 016/162] docs: add M0NsTeRRR as a contributor for security (#1015) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 91017932..3614dbd1 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -439,6 +439,15 @@ "contributions": [ "code" ] + }, + { + "login": "M0NsTeRRR", + "name": "Ludovic Ortega", + "avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4", + "profile": "https://github.com/M0NsTeRRR", + "contributions": [ + "security" + ] } ] } diff --git a/README.md b/README.md index f91fe384..fb6c8790 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. @@ -146,6 +146,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Baraa
    Baraa

    💻 Francisco Sales
    Francisco Sales

    💻 Oliver Laing
    Oliver Laing

    💻 + Ludovic Ortega
    Ludovic Ortega

    🛡️ From a351264b878b2660ae7a6415f26d38b52015c591 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 17 Oct 2024 12:37:19 +0200 Subject: [PATCH 017/162] fix: handle non-existent rottentomatoes rating (#1018) This fixes a bug where some media don't have any rottentomatoes ratings. --- server/api/rating/rottentomatoes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/rating/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts index e86c2488..f4fbe12b 100644 --- a/server/api/rating/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI { ); } - if (!tvshow) { + if (!tvshow || !tvshow.rottenTomatoes) { return null; } From 4e48fdf2cb9f76ae5c25073b585718650abd3288 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 17 Oct 2024 15:24:15 +0200 Subject: [PATCH 018/162] fix: rewrite avatarproxy and CachedImage (#1016) * fix: rewrite avatarproxy and CachedImage Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby images and TMDB images. fix #1012, #1013 * fix: resolve CodeQL error * fix: resolve CodeQL error * fix: resolve review comments * fix: resolve review comment * fix: resolve CodeQL error * fix: update imageproxy path --- server/routes/auth.ts | 15 +++------ server/routes/avatarproxy.ts | 23 +++++++++++-- server/routes/settings/index.ts | 7 +--- server/routes/user/index.ts | 8 +---- src/components/Blacklist/index.tsx | 3 ++ src/components/CollectionDetails/index.tsx | 2 ++ src/components/Common/CachedImage/index.tsx | 32 ++++++++++++------- src/components/Common/ImageFader/index.tsx | 1 + src/components/Common/Modal/index.tsx | 1 + src/components/CompanyCard/index.tsx | 1 + src/components/GenreCard/index.tsx | 1 + .../IssueDetails/IssueComment/index.tsx | 3 +- src/components/IssueDetails/index.tsx | 5 ++- src/components/IssueList/IssueItem/index.tsx | 5 ++- src/components/Layout/UserDropdown/index.tsx | 2 ++ src/components/ManageSlideOver/index.tsx | 2 ++ src/components/MovieDetails/index.tsx | 3 ++ src/components/PersonCard/index.tsx | 1 + src/components/PersonDetails/index.tsx | 1 + src/components/RequestCard/index.tsx | 4 +++ .../RequestList/RequestItem/index.tsx | 6 ++++ .../RequestModal/AdvancedRequester/index.tsx | 2 ++ .../RequestModal/CollectionRequestModal.tsx | 1 + src/components/Selector/index.tsx | 2 ++ src/components/TitleCard/index.tsx | 1 + src/components/TvDetails/index.tsx | 2 ++ .../UserList/JellyfinImportModal.tsx | 1 + src/components/UserList/index.tsx | 1 + .../UserProfile/ProfileHeader/index.tsx | 1 + 29 files changed, 97 insertions(+), 40 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 4e7f7727..560f04d5 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -262,8 +262,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { urlBase: body.urlBase, }); - const { externalHostname } = getSettings().jellyfin; - // Try to find deviceId that corresponds to jellyfin user, else generate a new one let user = await userRepository.findOne({ where: { jellyfinUsername: body.username }, @@ -281,11 +279,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { // First we need to attempt to log the user in to jellyfin const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : hostname; - const ip = req.ip; let clientIp; @@ -336,7 +329,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, @@ -355,7 +348,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, @@ -410,7 +403,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ); // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { - const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; if (avatar !== user.avatar) { const avatarProxy = new ImageProxy('avatar', ''); avatarProxy.clearCachedImage(user.avatar); @@ -467,7 +460,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 65638df2..e6f6f3b5 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -1,5 +1,8 @@ +import { MediaServerType } from '@server/constants/server'; import ImageProxy from '@server/lib/imageproxy'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; const router = Router(); @@ -7,9 +10,25 @@ const router = Router(); const avatarImageProxy = new ImageProxy('avatar', ''); // Proxy avatar images router.get('/*', async (req, res) => { - const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url; - + let imagePath = ''; try { + const jellyfinAvatar = req.url.match( + /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ + )?.[1]; + if (!jellyfinAvatar) { + const mediaServerType = getSettings().main.mediaServerType; + throw new Error( + `Provided URL is not ${ + mediaServerType === MediaServerType.JELLYFIN + ? 'a Jellyfin' + : 'an Emby' + } avatar.` + ); + } + + const imageUrl = new URL(jellyfinAvatar, getHostname()); + imagePath = imageUrl.toString(); + const imageData = await avatarImageProxy.getImage(imagePath); res.writeHead(200, { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 30c854af..3d6b6b0d 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -377,11 +377,6 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { settingsRoutes.get('/jellyfin/users', async (req, res) => { const settings = getSettings(); - const { externalHostname } = settings.jellyfin; - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : getHostname(); const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ @@ -401,7 +396,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { username: user.Name, id: user.Id, thumb: user.PrimaryImageTag - ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` + ? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` : gravatarUrl(user.Name, { default: 'mm', size: 200 }), email: user.Name, })); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f8a0d41a..83ad0910 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -516,12 +516,6 @@ router.post( //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; - const { externalHostname } = getSettings().jellyfin; - - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : hostname; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); @@ -546,7 +540,7 @@ router.post( email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, avatar: jellyfinUser?.PrimaryImageTag - ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` + ? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` : gravatarUrl(jellyfinUser?.Name ?? '', { default: 'mm', size: 200, diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index 217f4cef..a752e95f 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -268,6 +268,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { {title && title.backdropPath && (
    { className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { { {data.backdropPath && (
    {
    src; +export type CachedImageProps = ImageProps & { + src: string; + type: 'tmdb' | 'avatar'; +}; + /** * The CachedImage component should be used wherever * we want to offer the option to locally cache images. **/ -const CachedImage = ({ src, ...props }: ImageProps) => { +const CachedImage = ({ src, type, ...props }: CachedImageProps) => { const { currentSettings } = useSettings(); - let imageUrl = src; + let imageUrl: string; - if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { - const parsedUrl = new URL(imageUrl); - - if (parsedUrl.host === 'image.tmdb.org') { - if (currentSettings.cacheImages) - imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); - } else if (parsedUrl.host !== 'gravatar.com') { - imageUrl = '/avatarproxy/' + imageUrl; - } + if (type === 'tmdb') { + // tmdb stuff + imageUrl = + currentSettings.cacheImages && !src.startsWith('/') + ? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') + : src; + } else if (type === 'avatar') { + // jellyfin avatar (in any) + const jellyfinAvatar = src.match( + /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ + )?.[1]; + imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src; + } else { + return null; } return ; diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx index 20ccb698..930471e9 100644 --- a/src/components/Common/ImageFader/index.tsx +++ b/src/components/Common/ImageFader/index.tsx @@ -61,6 +61,7 @@ const ImageFader: ForwardRefRenderFunction = ( {...props} > ( {backdrop && (
    { >
    { tabIndex={0} > { {data.backdropPath && (
    {
    { className="group ml-1 inline-flex h-full items-center xl:ml-1.5" > { {title.backdropPath && (
    { className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { className="group flex items-center truncate" > { data-testid="user-menu" > {
    { {data.backdropPath && (
    {
    {
    { {data.profilePath && (
    { > { {title.backdropPath && (
    { > { className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28" > { {title.backdropPath && (
    { className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { > { >
    { {data.backdropPath && (
    {
    = ({
    { className="h-10 w-10 flex-shrink-0" > {
    Date: Thu, 17 Oct 2024 23:12:41 +0800 Subject: [PATCH 019/162] docs(buildfromsource): remove latest/develop tabs and update instructions to support 2.0.0 (#1021) re #1020 --- docs/getting-started/buildfromsource.mdx | 69 +----------------------- 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/docs/getting-started/buildfromsource.mdx b/docs/getting-started/buildfromsource.mdx index 5b39912c..c22ff23a 100644 --- a/docs/getting-started/buildfromsource.mdx +++ b/docs/getting-started/buildfromsource.mdx @@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ### Prerequisites - - - - [Node.js 18.x](https://nodejs.org/en/download/) - - [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install) - - [Git](https://git-scm.com/downloads) - - - - [Node.js 20.x](https://nodejs.org/en/download/) - [Pnpm 9.x](https://pnpm.io/installation) - [Git](https://git-scm.com/downloads) - - - ## Unix (Linux, macOS) ### Installation - - -1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it: -```bash -sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr -``` -2. Clone the Jellyseerr repository and checkout the latest release: -```bash -git clone https://github.com/Fallenbagel/jellyseerr.git -cd jellyseerr -git checkout main -``` -3. Install the dependencies: -```bash -CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 -``` -4. Build the project: -```bash -yarn build -``` -5. Start Jellyseerr: -```bash -yarn start -``` - - 1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it: ```bash sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr @@ -77,8 +40,6 @@ pnpm build ```bash pnpm start ``` - - :::info You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser. @@ -234,33 +195,6 @@ pm2 status jellyseerr ## Windows ### Installation - - -1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it: -```powershell -mkdir C:\jellyseerr -cd C:\jellyseerr -``` -2. Clone the Jellyseerr repository and checkout the latest release: -```powershell -git clone https://github.com/Fallenbagel/jellyseerr.git . -git checkout main -``` -3. Install the dependencies: -```powershell -npm install -g win-node-env -set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000 -``` -4. Build the project: -```powershell -yarn build -``` -5. Start Jellyseerr: -```powershell -yarn start -``` - - 1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it: ```powershell mkdir C:\jellyseerr @@ -284,8 +218,6 @@ pnpm build ```powershell pnpm start ``` - - :::tip You can add the environment variables to a `.env` file in the Jellyseerr directory. @@ -313,6 +245,7 @@ node dist/index.js - Set the trigger to "When the computer starts" - Set the action to "Start a program" - Set the program/script to the path of the `start-jellyseerr.bat` file +- Set the "Start in" to the jellyseerr directory. - Click "Finish" Now, Jellyseerr will start when the computer boots up in the background. From cbb1a74526ef5c003b7081c31146c52e7e551d60 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Fri, 18 Oct 2024 06:28:42 +0800 Subject: [PATCH 020/162] fix: fixes wrong avatar rendered for the modifiedBy user in request list (#1028) This fixes an issue where when the request is modified it was showing the avatar of the requester instead of the modifiedBy user fix #1017 --- src/components/RequestList/RequestItem/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index b1454790..80ba2ab7 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -635,7 +635,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { Date: Fri, 18 Oct 2024 12:24:29 +0200 Subject: [PATCH 021/162] feat: exit Jellyseerr when migration fails (#1026) --- server/lib/settings/index.ts | 2 +- server/lib/settings/migrator.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 074a4fcd..0fc47af9 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -648,7 +648,7 @@ class Settings { if (data) { const parsedJson = JSON.parse(data); - this.data = await runMigrations(parsedJson); + this.data = await runMigrations(parsedJson, SETTINGS_PATH); this.data = merge(this.data, parsedJson); diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 856016e1..002e5516 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; import fs from 'fs'; @@ -6,7 +7,8 @@ import path from 'path'; const migrationsDir = path.join(__dirname, 'migrations'); export const runMigrations = async ( - settings: AllSettings + settings: AllSettings, + SETTINGS_PATH: string ): Promise => { const migrations = fs .readdirSync(migrationsDir) @@ -17,14 +19,43 @@ export const runMigrations = async ( let migrated = settings; try { + const settingsBefore = JSON.stringify(migrated); + for (const migration of migrations) { migrated = await migration(migrated); } + + const settingsAfter = JSON.stringify(migrated); + + if (settingsBefore !== settingsAfter) { + // a migration occured + // we check that the new config will be saved + fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' ')); + const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); + if (JSON.stringify(fileSaved) !== settingsAfter) { + // something went wrong while saving file + throw new Error('Unable to save settings after migration.'); + } + } } catch (e) { logger.error( `Something went wrong while running settings migrations: ${e.message}`, { label: 'Settings Migrator' } ); + // we stop jellyseerr if the migration failed + console.log( + '====================================================================' + ); + console.log( + ' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS ' + ); + console.log( + ' Please check that your configuration folder is properly set up ' + ); + console.log( + '====================================================================' + ); + process.exit(); } return migrated; From 32e0b129fe78730c47d96a04481c70597ab58944 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Tue, 22 Oct 2024 05:20:14 +0800 Subject: [PATCH 022/162] docs(aur): add disclaimer about being maintained by third-party (#1044) --- docs/getting-started/aur.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/getting-started/aur.mdx b/docs/getting-started/aur.mdx index a67a0b24..025118c8 100644 --- a/docs/getting-started/aur.mdx +++ b/docs/getting-started/aur.mdx @@ -6,6 +6,10 @@ sidebar_position: 4 # AUR (Arch User Repository) +:::note Disclaimer +This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues. +::: + :::info This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution. ::: From 0bbcfcbd5e03137aba35ceb07e42f623aefa41d7 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 24 Oct 2024 18:11:25 +0200 Subject: [PATCH 023/162] fix: cache Jellyfin/Emby avatars from API (#1045) * fix: cache Jellyfin/Emby avatars from API Previously, avatars were cached using image links from Jellyfin/Emby. Now, avatar images are obtained directly from the API to avoid some configuration bugs. * fix: update avatar on new login --- server/lib/imageproxy.ts | 21 ++++++-- server/routes/auth.ts | 45 ++-------------- server/routes/avatarproxy.ts | 59 ++++++++++++++++----- server/routes/settings/index.ts | 5 +- server/routes/user/index.ts | 7 +-- src/components/Common/CachedImage/index.tsx | 7 +-- 6 files changed, 73 insertions(+), 71 deletions(-) diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index badfe94f..04e320a0 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -135,6 +135,7 @@ class ImageProxy { private cacheVersion; private key; private baseUrl; + private headers: HeadersInit | null = null; constructor( key: string, @@ -142,6 +143,7 @@ class ImageProxy { options: { cacheVersion?: number; rateLimitOptions?: RateLimitOptions; + headers?: HeadersInit; } = {} ) { this.cacheVersion = options.cacheVersion ?? 1; @@ -155,9 +157,13 @@ class ImageProxy { } else { this.fetch = fetch; } + this.headers = options.headers || null; } - public async getImage(path: string): Promise { + public async getImage( + path: string, + fallbackPath?: string + ): Promise { const cacheKey = this.getCacheKey(path); const imageResponse = await this.get(cacheKey); @@ -166,7 +172,11 @@ class ImageProxy { const newImage = await this.set(path, cacheKey); if (!newImage) { - throw new Error('Failed to load image'); + if (fallbackPath) { + return await this.getImage(fallbackPath); + } else { + throw new Error('Failed to load image'); + } } return newImage; @@ -247,7 +257,12 @@ class ImageProxy { : '/' : '') + (path.startsWith('/') ? path.slice(1) : path); - const response = await this.fetch(href); + const response = await this.fetch(href, { + headers: this.headers || undefined, + }); + if (!response.ok) { + return null; + } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 560f04d5..70e674f9 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,7 +6,6 @@ import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; -import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -15,7 +14,6 @@ import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; -import gravatarUrl from 'gravatar-url'; import net from 'net'; const authRoutes = Router(); @@ -328,12 +326,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: UserType.EMBY, }); @@ -347,12 +340,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: UserType.JELLYFIN, }); @@ -401,27 +389,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - // Update the users avatar with their jellyfin profile pic (incase it changed) - if (account.User.PrimaryImageTag) { - const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; - if (avatar !== user.avatar) { - const avatarProxy = new ImageProxy('avatar', ''); - avatarProxy.clearCachedImage(user.avatar); - } - user.avatar = avatar; - } else { - const avatar = gravatarUrl(user.email || account.User.Name, { - default: 'mm', - size: 200, - }); - - if (avatar !== user.avatar) { - const avatarProxy = new ImageProxy('avatar', ''); - avatarProxy.clearCachedImage(user.avatar); - } - - user.avatar = avatar; - } + user.avatar = `/avatarproxy/${account.User.Id}`; user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { @@ -459,12 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index e6f6f3b5..2d72e2f1 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -1,21 +1,39 @@ import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; import ImageProxy from '@server/lib/imageproxy'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; const router = Router(); -const avatarImageProxy = new ImageProxy('avatar', ''); -// Proxy avatar images -router.get('/*', async (req, res) => { - let imagePath = ''; +let _avatarImageProxy: ImageProxy | null = null; +async function initAvatarImageProxy() { + if (!_avatarImageProxy) { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + where: { id: 1 }, + select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], + order: { id: 'ASC' }, + }); + const deviceId = admin?.jellyfinDeviceId; + const authToken = getSettings().jellyfin.apiKey; + _avatarImageProxy = new ImageProxy('avatar', '', { + headers: { + 'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`, + }, + }); + } + return _avatarImageProxy; +} + +router.get('/:jellyfinUserId', async (req, res) => { try { - const jellyfinAvatar = req.url.match( - /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ - )?.[1]; - if (!jellyfinAvatar) { + if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { const mediaServerType = getSettings().main.mediaServerType; throw new Error( `Provided URL is not ${ @@ -26,10 +44,28 @@ router.get('/*', async (req, res) => { ); } - const imageUrl = new URL(jellyfinAvatar, getHostname()); - imagePath = imageUrl.toString(); + const avatarImageCache = await initAvatarImageProxy(); - const imageData = await avatarImageProxy.getImage(imagePath); + const user = await getRepository(User).findOne({ + where: { jellyfinUserId: req.params.jellyfinUserId }, + }); + + const fallbackUrl = gravatarUrl(user?.email || 'none', { + default: 'mm', + size: 200, + }); + const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${ + req.params.jellyfinUserId + }`; + let imageData = await avatarImageCache.getImage( + jellyfinAvatarUrl, + fallbackUrl + ); + + if (imageData.meta.extension === 'json') { + // this is a 404 + imageData = await avatarImageCache.getImage(fallbackUrl); + } res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, @@ -42,7 +78,6 @@ router.get('/*', async (req, res) => { res.end(imageData.imageBuffer); } catch (e) { logger.error('Failed to proxy avatar image', { - imagePath, errorMessage: e.message, }); } diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 3d6b6b0d..c5a070d2 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -32,7 +32,6 @@ import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import gravatarUrl from 'gravatar-url'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; @@ -395,9 +394,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { const users = resp.users.map((user) => ({ username: user.Name, id: user.Id, - thumb: user.PrimaryImageTag - ? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` - : gravatarUrl(user.Name, { default: 'mm', size: 200 }), + thumb: `/avatarproxy/${user.Id}`, email: user.Name, })); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 83ad0910..2a29c037 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -539,12 +539,7 @@ router.post( ).toString('base64'), email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, - avatar: jellyfinUser?.PrimaryImageTag - ? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` - : gravatarUrl(jellyfinUser?.Name ?? '', { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${jellyfinUser?.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index b01a47bb..a6d2fb00 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -25,11 +25,8 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => { ? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') : src; } else if (type === 'avatar') { - // jellyfin avatar (in any) - const jellyfinAvatar = src.match( - /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ - )?.[1]; - imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src; + // jellyfin avatar (if any) + imageUrl = src; } else { return null; } From 326001c3ecc92dc730f327130a71e797882a62b9 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 24 Oct 2024 18:12:42 +0200 Subject: [PATCH 024/162] feat: add more logs to migrations and create a settings backup (#1036) * feat: add more logs to migrations and create a settings backup * fix: avoid backup to be replaced at next startup * fix: resolve review comments * fix: try to fix CodeQL warnings --- .gitignore | 1 + server/lib/settings/migrator.ts | 54 +++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 9a8925ab..c417acb0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # database config/db/*.sqlite3* config/settings.json +config/settings.old.json # logs config/logs/*.log* diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 002e5516..6f61e508 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import fs from 'fs'; +import fs from 'fs/promises'; import path from 'path'; const migrationsDir = path.join(__dirname, 'migrations'); @@ -10,19 +10,46 @@ export const runMigrations = async ( settings: AllSettings, SETTINGS_PATH: string ): Promise => { - const migrations = fs - .readdirSync(migrationsDir) - .filter((file) => file.endsWith('.js') || file.endsWith('.ts')) - // eslint-disable-next-line @typescript-eslint/no-var-requires - .map((file) => require(path.join(migrationsDir, file)).default); - let migrated = settings; try { + // we read old backup and create a backup of currents settings + const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); + let oldBackup: Buffer | null = null; + try { + oldBackup = await fs.readFile(BACKUP_PATH); + } catch { + /* empty */ + } + await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' ')); + + const migrations = (await fs.readdir(migrationsDir)).filter( + (file) => file.endsWith('.js') || file.endsWith('.ts') + ); + const settingsBefore = JSON.stringify(migrated); for (const migration of migrations) { - migrated = await migration(migrated); + try { + logger.debug(`Checking migration '${migration}'...`, { + label: 'Settings Migrator', + }); + const { default: migrationFn } = await import( + path.join(migrationsDir, migration) + ); + const newSettings = await migrationFn(migrated); + if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { + logger.debug(`Migration '${migration}' has been applied.`, { + label: 'Settings Migrator', + }); + } + migrated = newSettings; + } catch (e) { + logger.error(`Error while running migration '${migration}'`, { + label: 'Settings Migrator', + }); + throw e; + } } const settingsAfter = JSON.stringify(migrated); @@ -30,12 +57,19 @@ export const runMigrations = async ( if (settingsBefore !== settingsAfter) { // a migration occured // we check that the new config will be saved - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' ')); - const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(migrated, undefined, ' ') + ); + const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); if (JSON.stringify(fileSaved) !== settingsAfter) { // something went wrong while saving file throw new Error('Unable to save settings after migration.'); } + } else if (oldBackup) { + // no migration occured + // we save the old backup (to avoid settings.json and settings.old.json being the same) + await fs.writeFile(BACKUP_PATH, oldBackup.toString()); } } catch (e) { logger.error( From f2b63156d1d4aa903eb261d2c80c059c39d9091b Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 24 Oct 2024 18:13:11 +0200 Subject: [PATCH 025/162] feat: add a warning if permissions are missing from config folder (#1030) --- overseerr-api.yml | 3 +++ server/index.ts | 7 +++++++ server/routes/index.ts | 7 ++++++- server/utils/appDataVolume.ts | 11 ++++++++++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 96a4520a..ef3ccf8b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1988,6 +1988,9 @@ paths: appDataPath: type: string example: /app/config + appDataPermissions: + type: boolean + example: true /settings/main: get: summary: Get main settings diff --git a/server/index.ts b/server/index.ts index 96590361..092b93fe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; +import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; @@ -51,6 +52,12 @@ const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +if (!appDataPermissions()) { + logger.error( + 'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started' + ); +} + app .prepare() .then(async () => { diff --git a/server/routes/index.ts b/server/routes/index.ts index c7c8389e..120e2e86 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -17,7 +17,11 @@ import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; -import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; +import { + appDataPath, + appDataPermissions, + appDataStatus, +} from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; @@ -93,6 +97,7 @@ router.get('/status/appdata', (_req, res) => { return res.status(200).json({ appData: appDataStatus(), appDataPath: appDataPath(), + appDataPermissions: appDataPermissions(), }); }); diff --git a/server/utils/appDataVolume.ts b/server/utils/appDataVolume.ts index 73c80b2c..837f7f66 100644 --- a/server/utils/appDataVolume.ts +++ b/server/utils/appDataVolume.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { accessSync, existsSync } from 'fs'; import path from 'path'; const CONFIG_PATH = process.env.CONFIG_DIRECTORY @@ -14,3 +14,12 @@ export const appDataStatus = (): boolean => { export const appDataPath = (): string => { return CONFIG_PATH; }; + +export const appDataPermissions = (): boolean => { + try { + accessSync(CONFIG_PATH); + return true; + } catch (err) { + return false; + } +}; From d331798b28a7bd32a27fc0ccbad2354be2e15b02 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 24 Oct 2024 18:34:01 +0200 Subject: [PATCH 026/162] fix: remove language profiles dropdown for Sonarr v4 (#1000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, the language profiles removed with Sonarr v4 are still available for compatibility reasons. However, Jellyseerr still queries and displays language profiles (marking them as “Deprecated”). This PR hides and does not query language profiles unless Sonarr v3 is used. fix #207 --- server/routes/service.ts | 6 +- server/routes/settings/sonarr.ts | 11 +- src/components/Settings/SonarrModal/index.tsx | 206 +++++++++--------- 3 files changed, 118 insertions(+), 105 deletions(-) diff --git a/server/routes/service.ts b/server/routes/service.ts index 083e1eb5..8f6c92b0 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -123,9 +123,13 @@ serviceRoutes.get<{ sonarrId: string }>( }); try { + const systemStatus = await sonarr.getSystemStatus(); + const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); + const profiles = await sonarr.getProfiles(); const rootFolders = await sonarr.getRootFolders(); - const languageProfiles = await sonarr.getLanguageProfiles(); + const languageProfiles = + sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 358d0700..8c74fa20 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -43,13 +43,14 @@ sonarrRoutes.post('/test', async (req, res, next) => { url: SonarrAPI.buildUrl(req.body, '/api/v3'), }); - const urlBase = await sonarr - .getSystemStatus() - .then((value) => value.urlBase) - .catch(() => req.body.baseUrl); + const systemStatus = await sonarr.getSystemStatus(); + const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); + + const urlBase = systemStatus.urlBase; const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); - const languageProfiles = await sonarr.getLanguageProfiles(); + const languageProfiles = + sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index ed6d1f56..f2bcd4db 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -86,10 +86,12 @@ interface TestResponse { id: number; path: string; }[]; - languageProfiles: { - id: number; - name: string; - }[]; + languageProfiles: + | { + id: number; + name: string; + }[] + | null; tags: { id: number; label: string; @@ -112,7 +114,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { const [testResponse, setTestResponse] = useState({ profiles: [], rootFolders: [], - languageProfiles: [], + languageProfiles: null, tags: [], }); const SonarrSettingsSchema = Yup.object().shape({ @@ -137,9 +139,11 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { activeProfileId: Yup.string().required( intl.formatMessage(messages.validationProfileRequired) ), - activeLanguageProfileId: Yup.number().required( - intl.formatMessage(messages.validationLanguageProfileRequired) - ), + activeLanguageProfileId: testResponse.languageProfiles + ? Yup.number().required( + intl.formatMessage(messages.validationLanguageProfileRequired) + ) + : Yup.number(), externalUrl: Yup.string() .url(intl.formatMessage(messages.validationApplicationUrl)) .test( @@ -658,54 +662,56 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { )}
    -
    - -
    -
    - - - {testResponse.languageProfiles.length > 0 && - testResponse.languageProfiles.map((language) => ( - - ))} - + {testResponse.languageProfiles && ( +
    + +
    +
    + + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
    + {errors.activeLanguageProfileId && + touched.activeLanguageProfileId && ( +
    + {errors.activeLanguageProfileId} +
    + )}
    - {errors.activeLanguageProfileId && - touched.activeLanguageProfileId && ( -
    - {errors.activeLanguageProfileId} -
    - )}
    -
    + )}
    -
    - -
    -
    - - - {testResponse.languageProfiles.length > 0 && - testResponse.languageProfiles.map((language) => ( - - ))} - + {testResponse.languageProfiles && ( +
    + +
    +
    + + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
    + {errors.activeAnimeLanguageProfileId && + touched.activeAnimeLanguageProfileId && ( +
    + {errors.activeAnimeLanguageProfileId} +
    + )}
    - {errors.activeAnimeLanguageProfileId && - touched.activeAnimeLanguageProfileId && ( -
    - {errors.activeAnimeLanguageProfileId} -
    - )}
    -
    + )}
    +
    + +
    +
    + +
    + {errors.httpProxy && + touched.httpProxy && + typeof errors.httpProxy === 'string' && ( +
    {errors.httpProxy}
    + )} +
    +
    From f2ed101e522561dab8563b744d908ff036c957c5 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 31 Oct 2024 15:51:57 +0100 Subject: [PATCH 028/162] fix: use fs/promises for settings (#1057) * fix: use fs/promises for settings This PR switches from synchronous operations with the 'fs' module to asynchronous operations with the 'fs/promises' module. It also corrects a small error with hostname migration. * fix: add missing merge function of default and current config * refactor: add more logs to migration --- server/api/jellyfin.ts | 2 +- server/api/plexapi.ts | 2 +- server/lib/scanners/plex/index.ts | 2 +- server/lib/settings/index.ts | 81 +++++++++---------- .../migrations/0001_migrate_hostname.ts | 11 +-- .../migrations/0002_migrate_apitokens.ts | 10 ++- server/lib/settings/migrator.ts | 47 ++++++----- server/routes/auth.ts | 4 +- server/routes/settings/index.ts | 26 +++--- server/routes/settings/notifications.ts | 40 ++++----- server/routes/settings/radarr.ts | 12 +-- server/routes/settings/sonarr.ts | 12 +-- 12 files changed, 128 insertions(+), 121 deletions(-) diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index f6550347..7b45cdaf 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI { ).AccessToken; } catch (e) { logger.error( - `Something went wrong while creating an API key the Jellyfin server: ${e.message}`, + `Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API' } ); diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f6b8f3cb..10d5d1d2 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -180,7 +180,7 @@ class PlexAPI { settings.plex.libraries = []; } - settings.save(); + await settings.save(); } public async getLibraryContents( diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f074872b..f6049630 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -129,7 +129,7 @@ class PlexScanner }); settings.plex.libraries = newLibraries; - settings.save(); + await settings.save(); } } else { for (const library of this.libraries) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 360aeb29..d0e6166d 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import { randomUUID } from 'crypto'; -import fs from 'fs'; +import fs from 'fs/promises'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; @@ -481,10 +481,6 @@ class Settings { } get main(): MainSettings { - if (!this.data.main.apiKey) { - this.data.main.apiKey = this.generateApiKey(); - this.save(); - } return this.data.main; } @@ -586,29 +582,20 @@ class Settings { } get clientId(): string { - if (!this.data.clientId) { - this.data.clientId = randomUUID(); - this.save(); - } - return this.data.clientId; } get vapidPublic(): string { - this.generateVapidKeys(); - return this.data.vapidPublic; } get vapidPrivate(): string { - this.generateVapidKeys(); - return this.data.vapidPrivate; } - public regenerateApiKey(): MainSettings { + public async regenerateApiKey(): Promise { this.main.apiKey = this.generateApiKey(); - this.save(); + await this.save(); return this.main; } @@ -620,15 +607,6 @@ class Settings { } } - private generateVapidKeys(force = false): void { - if (!this.data.vapidPublic || !this.data.vapidPrivate || force) { - const vapidKeys = webpush.generateVAPIDKeys(); - this.data.vapidPrivate = vapidKeys.privateKey; - this.data.vapidPublic = vapidKeys.publicKey; - this.save(); - } - } - /** * Settings Load * @@ -643,30 +621,51 @@ class Settings { return this; } - if (!fs.existsSync(SETTINGS_PATH)) { - this.save(); + let data; + try { + data = await fs.readFile(SETTINGS_PATH, 'utf-8'); + } catch { + await this.save(); } - const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { const parsedJson = JSON.parse(data); - this.data = await runMigrations(parsedJson, SETTINGS_PATH); - - this.data = merge(this.data, parsedJson); - - if (process.env.API_KEY) { - if (this.main.apiKey != process.env.API_KEY) { - this.main.apiKey = process.env.API_KEY; - } - } - - this.save(); + const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); + this.data = merge(this.data, migratedData); } + + // generate keys and ids if it's missing + let change = false; + if (!this.data.main.apiKey) { + this.data.main.apiKey = this.generateApiKey(); + change = true; + } else if (process.env.API_KEY) { + if (this.main.apiKey != process.env.API_KEY) { + this.main.apiKey = process.env.API_KEY; + } + } + if (!this.data.clientId) { + this.data.clientId = randomUUID(); + change = true; + } + if (!this.data.vapidPublic || !this.data.vapidPrivate) { + const vapidKeys = webpush.generateVAPIDKeys(); + this.data.vapidPrivate = vapidKeys.privateKey; + this.data.vapidPublic = vapidKeys.publicKey; + change = true; + } + if (change) { + await this.save(); + } + return this; } - public save(): void { - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); + public async save(): Promise { + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(this.data, undefined, ' ') + ); } } diff --git a/server/lib/settings/migrations/0001_migrate_hostname.ts b/server/lib/settings/migrations/0001_migrate_hostname.ts index c514ac2d..ddc8211c 100644 --- a/server/lib/settings/migrations/0001_migrate_hostname.ts +++ b/server/lib/settings/migrations/0001_migrate_hostname.ts @@ -1,15 +1,14 @@ import type { AllSettings } from '@server/lib/settings'; const migrateHostname = (settings: any): AllSettings => { - const oldJellyfinSettings = settings.jellyfin; - if (oldJellyfinSettings && oldJellyfinSettings.hostname) { - const { hostname } = oldJellyfinSettings; + if (settings.jellyfin?.hostname) { + const { hostname } = settings.jellyfin; const protocolMatch = hostname.match(/^(https?):\/\//i); const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https'; const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); - delete oldJellyfinSettings.hostname; + delete settings.jellyfin.hostname; if (urlMatch) { const [, ip, , port, urlBase] = urlMatch; settings.jellyfin = { @@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => { }; } } - if (settings.jellyfin && settings.jellyfin.hostname) { - delete settings.jellyfin.hostname; - } + return settings; }; diff --git a/server/lib/settings/migrations/0002_migrate_apitokens.ts b/server/lib/settings/migrations/0002_migrate_apitokens.ts index 46340433..0149c3e3 100644 --- a/server/lib/settings/migrations/0002_migrate_apitokens.ts +++ b/server/lib/settings/migrations/0002_migrate_apitokens.ts @@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise => { admin.jellyfinDeviceId ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); - const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); - settings.jellyfin.apiKey = apiKey; + try { + const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); + settings.jellyfin.apiKey = apiKey; + } catch { + throw new Error( + "Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue." + ); + } } return settings; }; diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 6f61e508..80114000 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; import fs from 'fs/promises'; @@ -15,9 +14,9 @@ export const runMigrations = async ( try { // we read old backup and create a backup of currents settings const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); - let oldBackup: Buffer | null = null; + let oldBackup: string | null = null; try { - oldBackup = await fs.readFile(BACKUP_PATH); + oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8'); } catch { /* empty */ } @@ -37,7 +36,7 @@ export const runMigrations = async ( const { default: migrationFn } = await import( path.join(migrationsDir, migration) ); - const newSettings = await migrationFn(migrated); + const newSettings = await migrationFn(structuredClone(migrated)); if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { logger.debug(`Migration '${migration}' has been applied.`, { label: 'Settings Migrator', @@ -45,10 +44,20 @@ export const runMigrations = async ( } migrated = newSettings; } catch (e) { - logger.error(`Error while running migration '${migration}'`, { - label: 'Settings Migrator', - }); - throw e; + // we stop jellyseerr if the migration failed + logger.error( + `Error while running migration '${migration}': ${e.message}`, + { + label: 'Settings Migrator', + } + ); + logger.error( + 'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.', + { + label: 'Settings Migrator', + } + ); + process.exit(); } } @@ -72,22 +81,18 @@ export const runMigrations = async ( await fs.writeFile(BACKUP_PATH, oldBackup.toString()); } } catch (e) { + // we stop jellyseerr if the migration failed logger.error( `Something went wrong while running settings migrations: ${e.message}`, - { label: 'Settings Migrator' } + { + label: 'Settings Migrator', + } ); - // we stop jellyseerr if the migration failed - console.log( - '====================================================================' - ); - console.log( - ' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS ' - ); - console.log( - ' Please check that your configuration folder is properly set up ' - ); - console.log( - '====================================================================' + logger.error( + 'A common cause for this issue is a permission error of your configuration folder.', + { + label: 'Settings Migrator', + } ); process.exit(); } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 70e674f9..d38ae221 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -87,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => { }); settings.main.mediaServerType = MediaServerType.PLEX; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); @@ -366,7 +366,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.apiKey = apiKey; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index c5a070d2..bc8c5ef7 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -69,19 +69,19 @@ settingsRoutes.get('/main', (req, res, next) => { res.status(200).json(filteredMainSettings(req.user, settings.main)); }); -settingsRoutes.post('/main', (req, res) => { +settingsRoutes.post('/main', async (req, res) => { const settings = getSettings(); settings.main = merge(settings.main, req.body); - settings.save(); + await settings.save(); return res.status(200).json(settings.main); }); -settingsRoutes.post('/main/regenerate', (req, res, next) => { +settingsRoutes.post('/main/regenerate', async (req, res, next) => { const settings = getSettings(); - const main = settings.regenerateApiKey(); + const main = await settings.regenerateApiKey(); if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); @@ -118,7 +118,7 @@ settingsRoutes.post('/plex', async (req, res, next) => { settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.name = result.MediaContainer.friendlyName; - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Plex connection', { label: 'API', @@ -231,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.plex.libraries); }); @@ -282,7 +282,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => { Object.assign(settings.jellyfin, req.body); settings.jellyfin.serverId = result.Id; settings.jellyfin.name = result.ServerName; - settings.save(); + await settings.save(); } catch (e) { if (e instanceof ApiError) { logger.error('Something went wrong testing Jellyfin connection', { @@ -370,7 +370,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.jellyfin.libraries); }); @@ -434,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { throw new Error('Tautulli version not supported'); } - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Tautulli connection', { label: 'API', @@ -695,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', - (req, res, next) => { + async (req, res, next) => { const scheduledJob = scheduledJobs.find( (job) => job.id === req.params.jobId ); @@ -709,7 +709,7 @@ settingsRoutes.post<{ jobId: JobId }>( if (result) { settings.jobs[scheduledJob.id].schedule = req.body.schedule; - settings.save(); + await settings.save(); scheduledJob.cronSchedule = req.body.schedule; @@ -766,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( settingsRoutes.post( '/initialize', isAuthenticated(Permission.ADMIN), - (_req, res) => { + async (_req, res) => { const settings = getSettings(); settings.public.initialized = true; - settings.save(); + await settings.save(); return res.status(200).json(settings.public); } diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index be2fd89a..5b2e1715 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => { res.status(200).json(settings.notifications.agents.discord); }); -notificationRoutes.post('/discord', (req, res) => { +notificationRoutes.post('/discord', async (req, res) => { const settings = getSettings(); settings.notifications.agents.discord = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.discord); }); @@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => { res.status(200).json(settings.notifications.agents.slack); }); -notificationRoutes.post('/slack', (req, res) => { +notificationRoutes.post('/slack', async (req, res) => { const settings = getSettings(); settings.notifications.agents.slack = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.slack); }); @@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => { res.status(200).json(settings.notifications.agents.telegram); }); -notificationRoutes.post('/telegram', (req, res) => { +notificationRoutes.post('/telegram', async (req, res) => { const settings = getSettings(); settings.notifications.agents.telegram = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.telegram); }); @@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => { res.status(200).json(settings.notifications.agents.pushbullet); }); -notificationRoutes.post('/pushbullet', (req, res) => { +notificationRoutes.post('/pushbullet', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushbullet = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushbullet); }); @@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => { res.status(200).json(settings.notifications.agents.pushover); }); -notificationRoutes.post('/pushover', (req, res) => { +notificationRoutes.post('/pushover', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushover = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushover); }); @@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => { res.status(200).json(settings.notifications.agents.email); }); -notificationRoutes.post('/email', (req, res) => { +notificationRoutes.post('/email', async (req, res) => { const settings = getSettings(); settings.notifications.agents.email = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.email); }); @@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => { res.status(200).json(settings.notifications.agents.webpush); }); -notificationRoutes.post('/webpush', (req, res) => { +notificationRoutes.post('/webpush', async (req, res) => { const settings = getSettings(); settings.notifications.agents.webpush = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webpush); }); @@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => { res.status(200).json(response); }); -notificationRoutes.post('/webhook', (req, res, next) => { +notificationRoutes.post('/webhook', async (req, res, next) => { const settings = getSettings(); try { JSON.parse(req.body.options.jsonPayload); @@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => { authHeader: req.body.options.authHeader, }, }; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webhook); } catch (e) { @@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => { res.status(200).json(settings.notifications.agents.lunasea); }); -notificationRoutes.post('/lunasea', (req, res) => { +notificationRoutes.post('/lunasea', async (req, res) => { const settings = getSettings(); settings.notifications.agents.lunasea = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.lunasea); }); @@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, res) => { +notificationRoutes.post('/gotify', async (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.gotify); }); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index c2b0a6f5..efa58665 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.radarr); }); -radarrRoutes.post('/', (req, res) => { +radarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newRadarr = req.body as RadarrSettings; @@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => { } settings.radarr = [...settings.radarr, newRadarr]; - settings.save(); + await settings.save(); return res.status(201).json(newRadarr); }); @@ -76,7 +76,7 @@ radarrRoutes.post< radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( '/:id', - (req, res, next) => { + async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( ...req.body, id: Number(req.params.id), } as RadarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.radarr[radarrIndex]); } @@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { ); }); -radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { +radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { } const removed = settings.radarr.splice(radarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); }); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 8c74fa20..84bf4d79 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.sonarr); }); -sonarrRoutes.post('/', (req, res) => { +sonarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newSonarr = req.body as SonarrSettings; @@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => { } settings.sonarr = [...settings.sonarr, newSonarr]; - settings.save(); + await settings.save(); return res.status(201).json(newSonarr); }); @@ -73,7 +73,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { } }); -sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -101,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { ...req.body, id: Number(req.params.id), } as SonarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.sonarr[sonarrIndex]); }); -sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -120,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { } const removed = settings.sonarr.splice(sonarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); }); From ca838a00fa4acb0ccdfbac8be4cf7fde493346f7 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 31 Oct 2024 16:10:45 +0100 Subject: [PATCH 029/162] feat: add bypass list, bypass local addresses and username/password to proxy setting (#1059) * fix: use fs/promises for settings This PR switches from synchronous operations with the 'fs' module to asynchronous operations with the 'fs/promises' module. It also corrects a small error with hostname migration. * fix: add missing merge function of default and current config * feat: add bypass list, bypass local addresses and username/password to proxy setting This PR adds more options to the proxy setting, like username/password authentication, bypass list of domains and bypass local addresses. The UX is taken from *arrs. * fix: add error handling for proxy creating * fix: remove logs --- server/index.ts | 6 +- server/lib/settings/index.ts | 24 +- server/utils/customProxyAgent.ts | 111 +++++++++ server/utils/restartFlag.ts | 2 +- .../Settings/SettingsMain/index.tsx | 216 ++++++++++++++++-- 5 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 server/utils/customProxyAgent.ts diff --git a/server/index.ts b/server/index.ts index f37d7522..cd65d566 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,6 +23,7 @@ import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; +import createCustomProxyAgent from '@server/utils/customProxyAgent'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; @@ -38,7 +39,6 @@ import dns from 'node:dns'; import net from 'node:net'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; -import { ProxyAgent, setGlobalDispatcher } from 'undici'; import YAML from 'yamljs'; if (process.env.forceIpv4First === 'true') { @@ -76,8 +76,8 @@ app restartFlag.initializeSettings(settings.main); // Register HTTP proxy - if (settings.main.httpProxy) { - setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy)); + if (settings.main.proxy.enabled) { + await createCustomProxyAgent(settings.main.proxy); } // Migrate library types diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index d0e6166d..29447f53 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -99,6 +99,17 @@ interface Quota { quotaDays?: number; } +export interface ProxySettings { + enabled: boolean; + hostname: string; + port: number; + useSsl: boolean; + user: string; + password: string; + bypassFilter: string; + bypassLocalAddresses: boolean; +} + export interface MainSettings { apiKey: string; applicationTitle: string; @@ -119,7 +130,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; locale: string; - httpProxy: string; + proxy: ProxySettings; } interface PublicSettings { @@ -326,7 +337,16 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, locale: 'en', - httpProxy: '', + proxy: { + enabled: false, + hostname: '', + port: 8080, + useSsl: false, + user: '', + password: '', + bypassFilter: '', + bypassLocalAddresses: true, + }, }, plex: { name: '', diff --git a/server/utils/customProxyAgent.ts b/server/utils/customProxyAgent.ts new file mode 100644 index 00000000..3b622368 --- /dev/null +++ b/server/utils/customProxyAgent.ts @@ -0,0 +1,111 @@ +import type { ProxySettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { Dispatcher } from 'undici'; +import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; + +export default async function createCustomProxyAgent( + proxySettings: ProxySettings +) { + const defaultAgent = new Agent(); + + const skipUrl = (url: string) => { + const hostname = new URL(url).hostname; + + if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) { + return true; + } + + for (const address of proxySettings.bypassFilter.split(',')) { + const trimmedAddress = address.trim(); + if (!trimmedAddress) { + continue; + } + + if (trimmedAddress.startsWith('*')) { + const domain = trimmedAddress.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } else if (hostname === trimmedAddress) { + return true; + } + } + + return false; + }; + + const noProxyInterceptor = ( + dispatch: Dispatcher['dispatch'] + ): Dispatcher['dispatch'] => { + return (opts, handler) => { + const url = opts.origin?.toString(); + return url && skipUrl(url) + ? defaultAgent.dispatch(opts, handler) + : dispatch(opts, handler); + }; + }; + + const token = + proxySettings.user && proxySettings.password + ? `Basic ${Buffer.from( + `${proxySettings.user}:${proxySettings.password}` + ).toString('base64')}` + : undefined; + + try { + const proxyAgent = new ProxyAgent({ + uri: + (proxySettings.useSsl ? 'https://' : 'http://') + + proxySettings.hostname + + ':' + + proxySettings.port, + token, + interceptors: { + Client: [noProxyInterceptor], + }, + }); + + setGlobalDispatcher(proxyAgent); + } catch (e) { + logger.error('Failed to connect to the proxy: ' + e.message, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + return; + } + + try { + const res = await fetch('https://www.google.com', { method: 'HEAD' }); + if (res.ok) { + logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); + } else { + logger.error('Proxy responded, but with a non-OK status: ' + res.status, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + } + } catch (e) { + logger.error( + 'Failed to connect to the proxy: ' + e.message + ': ' + e.cause, + { label: 'Proxy' } + ); + setGlobalDispatcher(defaultAgent); + } +} + +function isLocalAddress(hostname: string) { + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return true; + } + + const privateIpRanges = [ + /^10\./, // 10.x.x.x + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x + /^192\.168\./, // 192.168.x.x + ]; + if (privateIpRanges.some((regex) => regex.test(hostname))) { + return true; + } + + return false; +} diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts index bb5f011d..18d03ea6 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -14,7 +14,7 @@ class RestartFlag { return ( this.settings.csrfProtection !== settings.csrfProtection || this.settings.trustProxy !== settings.trustProxy || - this.settings.httpProxy !== settings.httpProxy + this.settings.proxy.enabled !== settings.proxy.enabled ); } } diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index b4fdea78..2d1e0219 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -55,8 +55,17 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', locale: 'Display Language', - httpProxy: 'HTTP Proxy', - httpProxyTip: 'Tooltip to write', + proxyEnabled: 'HTTP(S) Proxy', + proxyHostname: 'Proxy Hostname', + proxyPort: 'Proxy Port', + proxySsl: 'Use SSL For Proxy', + proxyUser: 'Proxy Username', + proxyPassword: 'Proxy Password', + proxyBypassFilter: 'Proxy Ignored Addresses', + proxyBypassFilterTip: + "Use ',' as a separator, and '*.' as a wildcard for subdomains", + proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses', + validationProxyPort: 'You must provide a valid port', }); const SettingsMain = () => { @@ -84,9 +93,12 @@ const SettingsMain = () => { intl.formatMessage(messages.validationApplicationUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), - httpProxy: Yup.string().url( - intl.formatMessage(messages.validationApplicationUrl) - ), + proxyPort: Yup.number().when('proxyEnabled', { + is: (proxyEnabled: boolean) => proxyEnabled, + then: Yup.number().required( + intl.formatMessage(messages.validationProxyPort) + ), + }), }); const regenerate = async () => { @@ -142,7 +154,14 @@ const SettingsMain = () => { partialRequestsEnabled: data?.partialRequestsEnabled, trustProxy: data?.trustProxy, cacheImages: data?.cacheImages, - httpProxy: data?.httpProxy, + proxyEnabled: data?.proxy?.enabled, + proxyHostname: data?.proxy?.hostname, + proxyPort: data?.proxy?.port, + proxySsl: data?.proxy?.useSsl, + proxyUser: data?.proxy?.user, + proxyPassword: data?.proxy?.password, + proxyBypassFilter: data?.proxy?.bypassFilter, + proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses, }} enableReinitialize validationSchema={MainSettingsSchema} @@ -164,7 +183,16 @@ const SettingsMain = () => { partialRequestsEnabled: values.partialRequestsEnabled, trustProxy: values.trustProxy, cacheImages: values.cacheImages, - httpProxy: values.httpProxy, + proxy: { + enabled: values.proxyEnabled, + hostname: values.proxyHostname, + port: values.proxyPort, + useSsl: values.proxySsl, + user: values.proxyUser, + password: values.proxyPassword, + bypassFilter: values.proxyBypassFilter, + bypassLocalAddresses: values.proxyBypassLocalAddresses, + }, }), }); if (!res.ok) throw new Error(); @@ -445,27 +473,175 @@ const SettingsMain = () => {
    -
    + {values.proxyEnabled && ( + <> +
    + +
    +
    + +
    + {errors.proxyHostname && + touched.proxyHostname && + typeof errors.proxyHostname === 'string' && ( +
    {errors.proxyHostname}
    + )} +
    +
    +
    + +
    +
    + +
    + {errors.proxyPort && + touched.proxyPort && + typeof errors.proxyPort === 'string' && ( +
    {errors.proxyPort}
    + )} +
    +
    +
    + +
    + { + setFieldValue('proxySsl', !values.proxySsl); + }} + /> +
    +
    +
    + +
    +
    + +
    + {errors.proxyUser && + touched.proxyUser && + typeof errors.proxyUser === 'string' && ( +
    {errors.proxyUser}
    + )} +
    +
    +
    + +
    +
    + +
    + {errors.proxyPassword && + touched.proxyPassword && + typeof errors.proxyPassword === 'string' && ( +
    {errors.proxyPassword}
    + )} +
    +
    +
    + +
    +
    + +
    + {errors.proxyBypassFilter && + touched.proxyBypassFilter && + typeof errors.proxyBypassFilter === 'string' && ( +
    + {errors.proxyBypassFilter} +
    + )} +
    +
    +
    + +
    + { + setFieldValue( + 'proxyBypassLocalAddresses', + !values.proxyBypassLocalAddresses + ); + }} + /> +
    +
    + + )}
    From cf59102ef91fa0e907cc6369b0fe60b503c823ca Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Sun, 3 Nov 2024 14:35:20 +0800 Subject: [PATCH 030/162] fix(externalapi): extract basic auth and pass it through header (#1062) This commit adds extraction of basic authentication credentials from the URL and then pass the credentials as the `Authorization` header. And then credentials are removed from the URL before being passed to fetch. This is done because fetch request cannot be constructed using a URL with credentials fix #1027 --- server/api/externalapi.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 0dfddefc..0dc1f967 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -32,13 +32,27 @@ class ExternalAPI { this.fetch = fetch; } - this.baseUrl = baseUrl; - this.params = params; + const url = new URL(baseUrl); + this.defaultHeaders = { 'Content-Type': 'application/json', Accept: 'application/json', + ...((url.username || url.password) && { + Authorization: `Basic ${Buffer.from( + `${url.username}:${url.password}` + ).toString('base64')}`, + }), ...options.headers, }; + + if (url.username || url.password) { + url.username = ''; + url.password = ''; + baseUrl = url.toString(); + } + + this.baseUrl = baseUrl; + this.params = params; this.cache = options.nodeCache; } From 2d3b777daf2df618f78c8b97480df48ed2a771c8 Mon Sep 17 00:00:00 2001 From: Ludovic Ortega Date: Mon, 4 Nov 2024 15:48:37 +0100 Subject: [PATCH 031/162] docs: migrate to docker compose v2 (#1073) Signed-off-by: Ludovic Ortega --- .dockerignore | 2 +- .gitattributes | 2 +- CONTRIBUTING.md | 2 +- docker-compose.yml => compose.yaml | 1 - docs/extending-jellyseerr/reverse-proxy.mdx | 2 +- docs/getting-started/docker.mdx | 8 ++++---- 6 files changed, 8 insertions(+), 9 deletions(-) rename docker-compose.yml => compose.yaml (93%) diff --git a/.dockerignore b/.dockerignore index 21a5da86..5a009f2a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,7 +18,7 @@ config/logs/* config/*.json dist Dockerfile* -docker-compose.yml +compose.yaml docs LICENSE node_modules diff --git a/.gitattributes b/.gitattributes index eb5d2314..d9863caf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,7 +40,7 @@ docs export-ignore .all-contributorsrc export-ignore .editorconfig export-ignore Dockerfile.local export-ignore -docker-compose.yml export-ignore +compose.yaml export-ignore stylelint.config.js export-ignore public/os_logo_filled.png export-ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5f768c2..32d0ac06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to pnpm dev ``` - - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. + - Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. 5. Create your patch and test your changes. diff --git a/docker-compose.yml b/compose.yaml similarity index 93% rename from docker-compose.yml rename to compose.yaml index 91b76e1e..94705357 100644 --- a/docker-compose.yml +++ b/compose.yaml @@ -1,4 +1,3 @@ -version: '3' services: jellyseerr: build: diff --git a/docs/extending-jellyseerr/reverse-proxy.mdx b/docs/extending-jellyseerr/reverse-proxy.mdx index 1ac36545..c78ae915 100644 --- a/docs/extending-jellyseerr/reverse-proxy.mdx +++ b/docs/extending-jellyseerr/reverse-proxy.mdx @@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain. ## Traefik (v2) -Add the following labels to the Jellyseerr service in your `docker-compose.yml` file: +Add the following labels to the Jellyseerr service in your `compose.yaml` file: ```yaml labels: diff --git a/docs/getting-started/docker.mdx b/docs/getting-started/docker.mdx index 3d8d690c..5e710735 100644 --- a/docs/getting-started/docker.mdx +++ b/docs/getting-started/docker.mdx @@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). #### Installation: -Define the `jellyseerr` service in your `docker-compose.yml` as follows: +Define the `jellyseerr` service in your `compose.yaml` as follows: ```yaml --- services: @@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable Then, start all services defined in the Compose file: ```bash -docker-compose up -d +docker compose up -d ``` #### Updating: Pull the latest image: ```bash -docker-compose pull jellyseerr +docker compose pull jellyseerr ``` Then, restart all services defined in the Compose file: ```bash -docker-compose up -d +docker compose up -d ``` :::tip You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. From 64f4610b9ffcad01c24ecdd81b8b3a2f3db4c98d Mon Sep 17 00:00:00 2001 From: Gauthier Date: Wed, 6 Nov 2024 08:21:19 +0100 Subject: [PATCH 032/162] fix: resolve error when setup on second attempt (#1061) --- server/routes/auth.ts | 112 ++++++++++++++++++++++++++---------------- 1 file changed, 70 insertions(+), 42 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d38ae221..19e99a8e 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -299,54 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => { where: { jellyfinUserId: account.User.Id }, }); - if (!user && !(await userRepository.count())) { + const missingAdminUser = !user && !(await userRepository.count()); + if ( + missingAdminUser || + settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED + ) { // Check if user is admin on jellyfin if (account.User.Policy.IsAdministrator === false) { throw new ApiError(403, ApiErrorCode.NotAdmin); } - logger.info( - 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', - { - label: 'API', - ip: req.ip, + if ( + body.serverType !== MediaServerType.JELLYFIN && + body.serverType !== MediaServerType.EMBY + ) { + throw new Error('select_server_type'); + } + settings.main.mediaServerType = body.serverType; + + if (missingAdminUser) { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr', + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); + + // User doesn't exist, and there are no users in the database, we'll create the user + // with admin permissions + + user = new User({ + id: 1, + email: body.email || account.User.Name, jellyfinUsername: account.User.Name, + jellyfinUserId: account.User.Id, + jellyfinDeviceId: deviceId, + jellyfinAuthToken: account.AccessToken, + permissions: Permission.ADMIN, + avatar: `/avatarproxy/${account.User.Id}`, + userType: + body.serverType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY, + }); + + await userRepository.save(user); + } else { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr', + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); + + // User alread exist but settings.json is not configured, we'll edit the admin user + + user = await userRepository.findOne({ + where: { id: 1 }, + }); + if (!user) { + throw new Error('Unable to find admin user to edit'); } - ); + user.email = body.email || account.User.Name; + user.jellyfinUsername = account.User.Name; + user.jellyfinUserId = account.User.Id; + user.jellyfinDeviceId = deviceId; + user.jellyfinAuthToken = account.AccessToken; + user.permissions = Permission.ADMIN; + user.avatar = `/avatarproxy/${account.User.Id}`; + user.userType = + body.serverType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY; - // User doesn't exist, and there are no users in the database, we'll create the user - // with admin permissions - switch (body.serverType) { - case MediaServerType.EMBY: - settings.main.mediaServerType = MediaServerType.EMBY; - user = new User({ - email: body.email || account.User.Name, - jellyfinUsername: account.User.Name, - jellyfinUserId: account.User.Id, - jellyfinDeviceId: deviceId, - jellyfinAuthToken: account.AccessToken, - permissions: Permission.ADMIN, - avatar: `/avatarproxy/${account.User.Id}`, - userType: UserType.EMBY, - }); - - break; - case MediaServerType.JELLYFIN: - settings.main.mediaServerType = MediaServerType.JELLYFIN; - user = new User({ - email: body.email || account.User.Name, - jellyfinUsername: account.User.Name, - jellyfinUserId: account.User.Id, - jellyfinDeviceId: deviceId, - jellyfinAuthToken: account.AccessToken, - permissions: Permission.ADMIN, - avatar: `/avatarproxy/${account.User.Id}`, - userType: UserType.JELLYFIN, - }); - - break; - default: - throw new Error('select_server_type'); + await userRepository.save(user); } // Create an API key on Jellyfin from this admin user @@ -368,8 +398,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.apiKey = apiKey; await settings.save(); startJobs(); - - await userRepository.save(user); } // User already exists, let's update their information else if (account.User.Id === user?.jellyfinUserId) { From 2829c2548aa0cd03f92433d3bc3b9b2739e98486 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sun, 10 Nov 2024 19:51:45 +0100 Subject: [PATCH 033/162] fix(setup): add leading slash validation for baseUrl (#1083) --- src/components/Login/JellyfinLogin.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index ba59d11b..057198e3 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -82,10 +82,17 @@ const JellyfinLogin: React.FC = ({ port: Yup.number().required( intl.formatMessage(messages.validationPortRequired) ), - urlBase: Yup.string().matches( - /^(.*[^/])$/, - intl.formatMessage(messages.validationUrlBaseTrailingSlash) - ), + urlBase: Yup.string() + .test( + 'leading-slash', + intl.formatMessage(messages.validationUrlBaseLeadingSlash), + (value) => !value || value.startsWith('/') + ) + .test( + 'trailing-slash', + intl.formatMessage(messages.validationUrlBaseTrailingSlash), + (value) => !value || !value.endsWith('/') + ), email: Yup.string() .email(intl.formatMessage(messages.validationemailformat)) .required(intl.formatMessage(messages.validationemailrequired)), From cb94ad5a2ee35f49cb96117dcdbcee8e9d1134da Mon Sep 17 00:00:00 2001 From: Gauthier Date: Mon, 11 Nov 2024 16:31:55 +0100 Subject: [PATCH 034/162] docs: fix pnpm install command (#1086) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32d0ac06..e76b2c14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to 4. Run the development environment: ```bash - pnpm + pnpm install pnpm dev ``` From a2d2fd3c2a53fc98d6288bd049fd8e37a1914280 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 12 Nov 2024 19:58:33 +0100 Subject: [PATCH 035/162] fix(i18n): update extractMessages function for better escaping of characters (#1079) This PR fix a bug when a translation message has two single quote like "message": "hello 'world'", the extractMessages function was escaping the message correcly. --- src/i18n/extractMessages.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/i18n/extractMessages.ts b/src/i18n/extractMessages.ts index bdb901eb..9820a9c7 100644 --- a/src/i18n/extractMessages.ts +++ b/src/i18n/extractMessages.ts @@ -25,15 +25,14 @@ async function extractMessages( try { const formattedMessages = messages .trim() - .replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":') - .replace( - /'.*'/g, - (match) => - `"${match - .match(/'(.*)'/)?.[1] - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"')}"` - ) + .replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":') + .replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => { + const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match); + if (!parts) return match; + return `${parts[1]}"${parts[2] + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"')}",`; + }) .replace(/,$/, ''); const messagesJson = JSON.parse(`{${formattedMessages}}`); return { namespace: namespace.trim(), messages: messagesJson }; From 694913c767c558147f413e2375b2512567541127 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Tue, 12 Nov 2024 20:01:06 +0100 Subject: [PATCH 036/162] fix(blacklist): request data only when modal is shown, remove useless ratelimit and lazy load blacklist (#1084) * perf: remove eager load of Blacklist entity from Media entity Try to resolve some performance issues by removing the eager loading of Blacklist items from the Media entity * fix: fix ManageSlideOver for blacklist * perf(blacklist): request data only when modal is shown For admin users, the button to blacklist a media (used on every media card) was displaying a Modal, that was requesting data BEFORE the modal was displayed. This resulted in dozens of additional requests everytime media cards were displayed. * perf(blacklist): remove useless ratelimit --- overseerr-api.yml | 15 +++++++++++ server/entity/Blacklist.ts | 4 +-- server/entity/Media.ts | 6 ++--- server/routes/blacklist.ts | 33 ++++++++++++++++++++---- src/components/BlacklistBlock/index.tsx | 27 ++++++++++++------- src/components/BlacklistModal/index.tsx | 2 +- src/components/ManageSlideOver/index.tsx | 2 +- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index ef3ccf8b..9e2505f4 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4142,6 +4142,21 @@ paths: '412': description: Item has already been blacklisted /blacklist/{tmdbId}: + get: + summary: Get media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Blacklist details in JSON delete: summary: Remove media from blacklist tags: diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts index 5e24419d..4ce3a86e 100644 --- a/server/entity/Blacklist.ts +++ b/server/entity/Blacklist.ts @@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem { status: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED, mediaType: blacklistRequest.mediaType, - blacklist: blacklist, + blacklist: Promise.resolve(blacklist), }); await mediaRepository.save(media); } else { - media.blacklist = blacklist; + media.blacklist = Promise.resolve(blacklist); media.status = MediaStatus.BLACKLISTED; media.status4k = MediaStatus.BLACKLISTED; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index de10cebc..a9991dc4 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -118,10 +118,8 @@ class Media { @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) public issues: Issue[]; - @OneToOne(() => Blacklist, (blacklist) => blacklist.media, { - eager: true, - }) - public blacklist: Blacklist; + @OneToOne(() => Blacklist, (blacklist) => blacklist.media) + public blacklist: Promise; @CreateDateColumn() public createdAt: Date; diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts index 4a07a499..bb2dafe8 100644 --- a/server/routes/blacklist.ts +++ b/server/routes/blacklist.ts @@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { Blacklist } from '@server/entity/Blacklist'; import Media from '@server/entity/Media'; -import { NotFoundError } from '@server/entity/Watchlist'; import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import rateLimit from 'express-rate-limit'; -import { QueryFailedError } from 'typeorm'; +import { EntityNotFoundError, QueryFailedError } from 'typeorm'; import { z } from 'zod'; const blacklistRoutes = Router(); @@ -26,7 +24,6 @@ blacklistRoutes.get( isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { type: 'or', }), - rateLimit({ windowMs: 60 * 1000, max: 50 }), async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 25; const skip = req.query.skip ? Number(req.query.skip) : 0; @@ -71,6 +68,32 @@ blacklistRoutes.get( } ); +blacklistRoutes.get( + '/:id', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const blacklisteRepository = getRepository(Blacklist); + + const blacklistItem = await blacklisteRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, + }); + + return res.status(200).send(blacklistItem); + } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + blacklistRoutes.post( '/', isAuthenticated([Permission.MANAGE_BLACKLIST], { @@ -134,7 +157,7 @@ blacklistRoutes.delete( return res.status(204).send(); } catch (e) { - if (e instanceof NotFoundError) { + if (e instanceof EntityNotFoundError) { return next({ status: 401, message: e.message, diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 0908d373..8d619aa3 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -1,5 +1,6 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Tooltip from '@app/components/Common/Tooltip'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -10,6 +11,7 @@ import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; const messages = defineMessages('component.BlacklistBlock', { blacklistedby: 'Blacklisted By', @@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', { }); interface BlacklistBlockProps { - blacklistItem: Blacklist; + tmdbId: number; onUpdate?: () => void; onDelete?: () => void; } const BlacklistBlock = ({ - blacklistItem, + tmdbId, onUpdate, onDelete, }: BlacklistBlockProps) => { @@ -31,6 +33,7 @@ const BlacklistBlock = ({ const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); + const { data } = useSWR(`/api/v1/blacklist/${tmdbId}`); const removeFromBlacklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); @@ -62,6 +65,14 @@ const BlacklistBlock = ({ setIsUpdating(false); }; + if (!data) { + return ( + <> + + + ); + } + return (
    @@ -73,13 +84,13 @@ const BlacklistBlock = ({ - {blacklistItem.user.displayName} + {data.user.displayName} @@ -91,9 +102,7 @@ const BlacklistBlock = ({ >
    +
    + +
    +
    + +
    + {errors.webhookRoleId && + touched.webhookRoleId && + typeof errors.webhookRoleId === 'string' && ( +
    {errors.webhookRoleId}
    + )} +
    +
    diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 272c96bf..599106e9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -503,6 +503,7 @@ "components.RequestList.requests": "Requests", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.sortAdded": "Most Recent", + "components.RequestList.sortDirection": "Toggle Sort Direction", "components.RequestList.sortModified": "Last Modified", "components.RequestModal.AdvancedRequester.advancedoptions": "Advanced", "components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.", @@ -1099,7 +1100,7 @@ "components.Setup.finishing": "Finishing…", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign in to your account", + "components.Setup.signin": "Sign In", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", From 347a24a97b354725c4ccb3b5a07793b96ff60b80 Mon Sep 17 00:00:00 2001 From: Ben Haney <31331498+benhaney@users.noreply.github.com> Date: Fri, 20 Dec 2024 04:23:14 -0600 Subject: [PATCH 065/162] fix: handle non-existent rottentomatoes rating for movies (#1169) This fixes a bug where some movies don't have any rottentomatoes ratings, which causes ratings from other services to not show --- server/api/rating/rottentomatoes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/rating/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts index f4fbe12b..170cbb64 100644 --- a/server/api/rating/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -128,7 +128,7 @@ class RottenTomatoes extends ExternalAPI { movie = contentResults.hits.find((movie) => movie.title === name); } - if (!movie) { + if (!movie?.rottenTomatoes) { return null; } From 1da2f258a70e15aae33eff4112edcbd6ab776989 Mon Sep 17 00:00:00 2001 From: GkhnGRBZ <127258824+GkhnGRBZ@users.noreply.github.com> Date: Sat, 21 Dec 2024 00:37:46 +0300 Subject: [PATCH 066/162] Turkish language added (#1165) * Add files via upload * Add files via upload --- src/context/LanguageContext.tsx | 5 +++++ src/pages/_app.tsx | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index 10a658db..cb4338aa 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -31,6 +31,7 @@ export type AvailableLocale = | 'sq' | 'sr' | 'sv' + | 'tr' | 'uk' | 'zh-CN' | 'zh-TW'; @@ -149,6 +150,10 @@ export const availableLanguages: AvailableLanguageObject = { code: 'sr', display: 'српски језик', }, + tr: { + code: 'tr', + display: 'Türkçe', + }, ar: { code: 'ar', display: 'العربية', diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d12b9191..d0fbbfa9 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -85,6 +85,8 @@ const loadLocaleData = (locale: AvailableLocale): Promise => { return import('../i18n/locale/sr.json'); case 'sv': return import('../i18n/locale/sv.json'); + case 'tr': + return import('../i18n/locale/tr.json'); case 'uk': return import('../i18n/locale/uk.json'); case 'zh-CN': From d76d79441142ccc6fe2357549f39a1fba3546ff9 Mon Sep 17 00:00:00 2001 From: astro <18621898+bytebone@users.noreply.github.com> Date: Sat, 21 Dec 2024 07:54:55 +0100 Subject: [PATCH 067/162] feat(notifications): added telegram thread id's (#1145) * feat(notifications): added telegram thread id's * undid unwanted formatting * chore: remove manual translations * style: conformed formatting * fix: add missing migration * fix: corrected erroneous migration --- cypress/config/settings.cypress.json | 1 + overseerr-api.yml | 5 +++ server/entity/UserSettings.ts | 3 ++ .../interfaces/api/userSettingsInterfaces.ts | 1 + server/lib/notifications/agents/telegram.ts | 6 +++ server/lib/settings/index.ts | 2 + ...734287582736-AddTelegramMessageThreadId.ts | 33 ++++++++++++++ server/routes/user/usersettings.ts | 5 +++ .../Notifications/NotificationsTelegram.tsx | 39 ++++++++++++++++ .../UserNotificationsTelegram.tsx | 45 +++++++++++++++++++ src/i18n/locale/en.json | 6 +++ 11 files changed, 146 insertions(+) create mode 100644 server/migration/1734287582736-AddTelegramMessageThreadId.ts diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 69c8db42..f45bcbc0 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -100,6 +100,7 @@ "options": { "botAPI": "", "chatId": "", + "messageThreadId": "", "sendSilently": false } }, diff --git a/overseerr-api.yml b/overseerr-api.yml index dc59b7af..ac76f6a7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1338,6 +1338,8 @@ components: type: string chatId: type: string + messageThreadId: + type: string sendSilently: type: boolean PushbulletSettings: @@ -1821,6 +1823,9 @@ components: telegramChatId: type: string nullable: true + telegramMessageThreadId: + type: string + nullable: true telegramSendSilently: type: boolean nullable: true diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index d5a7555a..82671fe3 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -60,6 +60,9 @@ export class UserSettings { @Column({ nullable: true }) public telegramChatId?: string; + @Column({ nullable: true }) + public telegramMessageThreadId?: string; + @Column({ nullable: true }) public telegramSendSilently?: boolean; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 43c567c7..32776461 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -34,6 +34,7 @@ export interface UserSettingsNotificationsResponse { telegramEnabled?: boolean; telegramBotUsername?: string; telegramChatId?: string; + telegramMessageThreadId?: string; telegramSendSilently?: boolean; webPushEnabled?: boolean; notificationTypes: Partial; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index a66f9710..db12b494 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -17,6 +17,7 @@ interface TelegramMessagePayload { text: string; parse_mode: string; chat_id: string; + message_thread_id: string; disable_notification: boolean; } @@ -25,6 +26,7 @@ interface TelegramPhotoPayload { caption: string; parse_mode: string; chat_id: string; + message_thread_id: string; disable_notification: boolean; } @@ -182,6 +184,7 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: settings.options.chatId, + message_thread_id: settings.options.messageThreadId, disable_notification: !!settings.options.sendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); @@ -233,6 +236,8 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: payload.notifyUser.settings.telegramChatId, + message_thread_id: + payload.notifyUser.settings.telegramMessageThreadId, disable_notification: !!payload.notifyUser.settings.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), @@ -296,6 +301,7 @@ class TelegramAgent body: JSON.stringify({ ...notificationPayload, chat_id: user.settings.telegramChatId, + message_thread_id: user.settings.telegramMessageThreadId, disable_notification: !!user.settings?.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index f14e0eb6..f1c73022 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -213,6 +213,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig { botUsername?: string; botAPI: string; chatId: string; + messageThreadId: string; sendSilently: boolean; }; } @@ -423,6 +424,7 @@ class Settings { options: { botAPI: '', chatId: '', + messageThreadId: '', sendSilently: false, }, }, diff --git a/server/migration/1734287582736-AddTelegramMessageThreadId.ts b/server/migration/1734287582736-AddTelegramMessageThreadId.ts new file mode 100644 index 00000000..94a76b99 --- /dev/null +++ b/server/migration/1734287582736-AddTelegramMessageThreadId.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTelegramMessageThreadId1734287582736 + implements MigrationInterface +{ + name = 'AddTelegramMessageThreadId1734287582736'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index e4c16b1e..24ca976b 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -323,6 +323,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( telegramEnabled: settings.telegram.enabled, telegramBotUsername: settings.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, + telegramMessageThreadId: user.settings?.telegramMessageThreadId, telegramSendSilently: user.settings?.telegramSendSilently, webPushEnabled: settings.webpush.enabled, notificationTypes: user.settings?.notificationTypes ?? {}, @@ -365,6 +366,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( pushoverApplicationToken: req.body.pushoverApplicationToken, pushoverUserKey: req.body.pushoverUserKey, telegramChatId: req.body.telegramChatId, + telegramMessageThreadId: req.body.telegramMessageThreadId, telegramSendSilently: req.body.telegramSendSilently, notificationTypes: req.body.notificationTypes, }); @@ -377,6 +379,8 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.pushoverSound = req.body.pushoverSound; user.settings.telegramChatId = req.body.telegramChatId; + user.settings.telegramMessageThreadId = + req.body.telegramMessageThreadId; user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.notificationTypes = Object.assign( {}, @@ -395,6 +399,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( pushoverUserKey: user.settings.pushoverUserKey, pushoverSound: user.settings.pushoverSound, telegramChatId: user.settings.telegramChatId, + telegramMessageThreadId: user.settings.telegramMessageThreadId, telegramSendSilently: user.settings.telegramSendSilently, notificationTypes: user.settings.notificationTypes, }); diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index 53ee4787..6636c6b4 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -23,8 +23,13 @@ const messages = defineMessages('components.Settings.Notifications', { chatId: 'Chat ID', chatIdTip: 'Start a chat with your bot, add @get_id_bot, and issue the /my_id command', + messageThreadId: 'Thread/Topic ID', + messageThreadIdTip: + "If your group-chat has topics enabled, you can specify a thread/topic's ID here", validationBotAPIRequired: 'You must provide a bot authorization token', validationChatIdRequired: 'You must provide a valid chat ID', + validationMessageThreadId: + 'The thread/topic ID must be a positive whole number', telegramsettingssaved: 'Telegram notification settings saved successfully!', telegramsettingsfailed: 'Telegram notification settings failed to save.', toastTelegramTestSending: 'Sending Telegram test notification…', @@ -64,6 +69,15 @@ const NotificationsTelegram = () => { /^-?\d+$/, intl.formatMessage(messages.validationChatIdRequired) ), + messageThreadId: Yup.string() + .when(['types'], { + is: (enabled: boolean, types: number) => enabled && !!types, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationMessageThreadId)), + otherwise: Yup.string().nullable(), + }) + .matches(/^\d+$/, intl.formatMessage(messages.validationMessageThreadId)), }); if (!data && !error) { @@ -78,6 +92,7 @@ const NotificationsTelegram = () => { botUsername: data?.options.botUsername, botAPI: data?.options.botAPI, chatId: data?.options.chatId, + messageThreadId: data?.options.messageThreadId, sendSilently: data?.options.sendSilently, }} validationSchema={NotificationsTelegramSchema} @@ -94,6 +109,7 @@ const NotificationsTelegram = () => { options: { botAPI: values.botAPI, chatId: values.chatId, + messageThreadId: values.messageThreadId, sendSilently: values.sendSilently, botUsername: values.botUsername, }, @@ -151,6 +167,7 @@ const NotificationsTelegram = () => { options: { botAPI: values.botAPI, chatId: values.chatId, + messageThreadId: values.messageThreadId, sendSilently: values.sendSilently, botUsername: values.botUsername, }, @@ -286,6 +303,28 @@ const NotificationsTelegram = () => { )}
    +
    + +
    +
    + +
    + {errors.messageThreadId && + touched.messageThreadId && + typeof errors.messageThreadId === 'string' && ( +
    {errors.messageThreadId}
    + )} +
    +
    +
    + +
    +
    + +
    + {errors.telegramMessageThreadId && + touched.telegramMessageThreadId && + typeof errors.telegramMessageThreadId === 'string' && ( +
    + {errors.telegramMessageThreadId} +
    + )} +
    +
    +

    + {intl.formatMessage(messages.overrideRules)} +

    +
      + {rules && ( + + )} +
    • +
      + +
      +
    • +
    ); }} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 447d3167..9453d39e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -589,6 +589,7 @@ "components.Selector.searchKeywords": "Search keywords…", "components.Selector.searchStatus": "Select status...", "components.Selector.searchStudios": "Search studios…", + "components.Selector.searchUsers": "Select users…", "components.Selector.showless": "Show Less", "components.Selector.showmore": "Show More", "components.Selector.starttyping": "Starting typing to search.", @@ -735,7 +736,37 @@ "components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions", "components.Settings.Notifications.webhookUrl": "Webhook URL", "components.Settings.Notifications.webhookUrlTip": "Create a webhook integration in your server", + "components.Settings.OverrideRuleModal.conditions": "Conditions", + "components.Settings.OverrideRuleModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).", + "components.Settings.OverrideRuleModal.create": "Create rule", + "components.Settings.OverrideRuleModal.createrule": "New Override Rule", + "components.Settings.OverrideRuleModal.editrule": "Edit Override Rule", + "components.Settings.OverrideRuleModal.genres": "Genres", + "components.Settings.OverrideRuleModal.keywords": "Keywords", + "components.Settings.OverrideRuleModal.languages": "Languages", + "components.Settings.OverrideRuleModal.notagoptions": "No tags.", + "components.Settings.OverrideRuleModal.qualityprofile": "Quality Profile", + "components.Settings.OverrideRuleModal.rootfolder": "Root Folder", + "components.Settings.OverrideRuleModal.ruleCreated": "Override rule created successfully!", + "components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!", + "components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile", + "components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder", + "components.Settings.OverrideRuleModal.selecttags": "Select tags", + "components.Settings.OverrideRuleModal.settings": "Settings", + "components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.", + "components.Settings.OverrideRuleModal.tags": "Tags", + "components.Settings.OverrideRuleModal.users": "Users", + "components.Settings.OverrideRuleTile.conditions": "Conditions", + "components.Settings.OverrideRuleTile.genre": "Genre", + "components.Settings.OverrideRuleTile.keywords": "Keywords", + "components.Settings.OverrideRuleTile.language": "Language", + "components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile", + "components.Settings.OverrideRuleTile.rootfolder": "Root Folder", + "components.Settings.OverrideRuleTile.settings": "Settings", + "components.Settings.OverrideRuleTile.tags": "Tags", + "components.Settings.OverrideRuleTile.users": "Users", "components.Settings.RadarrModal.add": "Add Server", + "components.Settings.RadarrModal.addrule": "New Override Rule", "components.Settings.RadarrModal.announced": "Announced", "components.Settings.RadarrModal.apiKey": "API Key", "components.Settings.RadarrModal.baseUrl": "URL Base", @@ -754,6 +785,7 @@ "components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.RadarrModal.minimumAvailability": "Minimum Availability", "components.Settings.RadarrModal.notagoptions": "No tags.", + "components.Settings.RadarrModal.overrideRules": "Override Rules", "components.Settings.RadarrModal.port": "Port", "components.Settings.RadarrModal.qualityprofile": "Quality Profile", "components.Settings.RadarrModal.released": "Released", @@ -929,6 +961,7 @@ "components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.", "components.Settings.SettingsUsers.users": "Users", "components.Settings.SonarrModal.add": "Add Server", + "components.Settings.SonarrModal.addrule": "New Override Rule", "components.Settings.SonarrModal.animeSeriesType": "Anime Series Type", "components.Settings.SonarrModal.animeTags": "Anime Tags", "components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile", @@ -951,6 +984,7 @@ "components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.SonarrModal.notagoptions": "No tags.", + "components.Settings.SonarrModal.overrideRules": "Override Rules", "components.Settings.SonarrModal.port": "Port", "components.Settings.SonarrModal.qualityprofile": "Quality Profile", "components.Settings.SonarrModal.rootfolder": "Root Folder", From 66948b420feecd85bd6bbdb2371b2ee32675baef Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sat, 28 Dec 2024 23:22:26 +0100 Subject: [PATCH 077/162] refactor(i18n): add better types to our custom defineMessages (#1192) --- .../Discover/DiscoverTvUpcoming.tsx | 4 ++- src/i18n/locale/en.json | 1 + src/utils/defineMessages.ts | 32 ++++++++++++------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index 182cd1a5..a6a3be17 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages'; import type { TvResult } from '@server/models/Search'; import { useIntl } from 'react-intl'; -const messages = defineMessages('components.DiscoverTvUpcoming', {}); +const messages = defineMessages('components.DiscoverTvUpcoming', { + upcomingtv: 'Upcoming Series', +}); const DiscoverTvUpcoming = () => { const intl = useIntl(); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 9453d39e..3fce7abd 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -100,6 +100,7 @@ "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", + "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series", "components.Discover.createnewslider": "Create New Slider", "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", diff --git a/src/utils/defineMessages.ts b/src/utils/defineMessages.ts index 69a04a7d..bb8fadb4 100644 --- a/src/utils/defineMessages.ts +++ b/src/utils/defineMessages.ts @@ -1,18 +1,26 @@ import { defineMessages as intlDefineMessages } from 'react-intl'; -export default function defineMessages( +type Messages> = { + [K in keyof T]: { + id: string; + defaultMessage: T[K]; + }; +}; + +export default function defineMessages>( prefix: string, - messages: Record -) { - const modifiedMessages: Record< - string, - { id: string; defaultMessage: string } - > = {}; - for (const key of Object.keys(messages)) { - modifiedMessages[key] = { - id: prefix + '.' + key, + messages: T +): Messages { + const keys: (keyof T)[] = Object.keys(messages); + const modifiedMessagesEntries = keys.map((key) => [ + key, + { + id: `${prefix}.${key as string}`, defaultMessage: messages[key], - }; - } + }, + ]); + const modifiedMessages: Messages = Object.fromEntries( + modifiedMessagesEntries + ); return intlDefineMessages(modifiedMessages); } From b6e2e6ce615cb94cea8d2335140fe245a0ca2d8a Mon Sep 17 00:00:00 2001 From: Gauthier Date: Sun, 29 Dec 2024 22:03:49 +0100 Subject: [PATCH 078/162] feat: add a setting for special episodes (#1193) * feat: add a setting for special episodes This PR adds a separate setting for special episodes and disables them by default, to avoid unwanted library status updates. * refactor(settings): re-order setting for allow specials request --------- Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> --- cypress/config/settings.cypress.json | 1 + overseerr-api.yml | 3 + server/entity/MediaRequest.ts | 7 ++- server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/scanners/plex/index.ts | 7 ++- server/lib/scanners/sonarr/index.ts | 7 ++- server/lib/settings/index.ts | 4 ++ src/components/RequestCard/index.tsx | 8 ++- .../RequestList/RequestItem/index.tsx | 8 ++- .../RequestModal/TvRequestModal.tsx | 17 +++-- .../Settings/SettingsMain/index.tsx | 62 +++++++++++++------ src/components/TvDetails/index.tsx | 9 ++- src/context/SettingsContext.tsx | 1 + src/i18n/locale/en.json | 1 + src/pages/_app.tsx | 1 + 15 files changed, 108 insertions(+), 29 deletions(-) diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index f45bcbc0..e3d31cc1 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -22,6 +22,7 @@ "trustProxy": false, "mediaServerType": 1, "partialRequestsEnabled": true, + "enableSpecialEpisodes": false, "locale": "en" }, "plex": { diff --git a/overseerr-api.yml b/overseerr-api.yml index 6a387a6b..06a7523d 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -188,6 +188,9 @@ components: defaultPermissions: type: number example: 32 + enableSpecialEpisodes: + type: boolean + example: false PlexLibrary: type: object properties: diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 61b82c0e..6cc808c3 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -58,6 +58,7 @@ export class MediaRequest { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); + const settings = getSettings(); let requestUser = user; @@ -258,7 +259,11 @@ export class MediaRequest { >; const requestedSeasons = requestBody.seasons === 'all' - ? tmdbMediaShow.seasons.map((season) => season.season_number) + ? settings.main.enableSpecialEpisodes + ? tmdbMediaShow.seasons.map((season) => season.season_number) + : tmdbMediaShow.seasons + .map((season) => season.season_number) + .filter((sn) => sn > 0) : (requestBody.seasons as number[]); let existingSeasons: number[] = []; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 29a81d5e..017eef85 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -37,6 +37,7 @@ export interface PublicSettingsResponse { originalLanguage: string; mediaServerType: number; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index e4af7a1f..9dee904a 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -277,8 +277,13 @@ class PlexScanner const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; + const settings = getSettings(); - for (const season of seasons) { + const filteredSeasons = settings.main.enableSpecialEpisodes + ? seasons + : seasons.filter((sn) => sn.season_number !== 0); + + for (const season of filteredSeasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number ); diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 5d28e014..88f6a324 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -102,9 +102,12 @@ class SonarrScanner } const tmdbId = tvShow.id; + const settings = getSettings(); - const filteredSeasons = sonarrSeries.seasons.filter((sn) => - tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) + const filteredSeasons = sonarrSeries.seasons.filter( + (sn) => + tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) && + (!settings.main.partialRequestsEnabled ? sn.seasonNumber !== 0 : true) ); for (const season of filteredSeasons) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 4613486f..cd8ebb97 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -131,6 +131,7 @@ export interface MainSettings { trustProxy: boolean; mediaServerType: number; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; locale: string; proxy: ProxySettings; } @@ -154,6 +155,7 @@ interface FullPublicSettings extends PublicSettings { jellyfinForgotPasswordUrl?: string; jellyfinServerName?: string; partialRequestsEnabled: boolean; + enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -343,6 +345,7 @@ class Settings { trustProxy: false, mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, + enableSpecialEpisodes: false, locale: 'en', proxy: { enabled: false, @@ -587,6 +590,7 @@ class Settings { originalLanguage: this.data.main.originalLanguage, mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, + enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index e737c733..7f08044e 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -5,6 +5,7 @@ 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 useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; @@ -219,6 +220,7 @@ interface RequestCardProps { } const RequestCard = ({ request, onTitleData }: RequestCardProps) => { + const settings = useSettings(); const { ref, inView } = useInView({ triggerOnce: true, }); @@ -411,7 +413,11 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { {intl.formatMessage(messages.seasons, { seasonCount: - title.seasons.length === request.seasons.length + (settings.currentSettings.enableSpecialEpisodes + ? title.seasons.length + : title.seasons.filter( + (season) => season.seasonNumber !== 0 + ).length) === request.seasons.length ? 0 : request.seasons.length, })} diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 7f64039c..0f8a5a24 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -5,6 +5,7 @@ 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 useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; @@ -294,6 +295,7 @@ interface RequestItemProps { } const RequestItem = ({ request, revalidateList }: RequestItemProps) => { + const settings = useSettings(); const { ref, inView } = useInView({ triggerOnce: true, }); @@ -481,7 +483,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { {intl.formatMessage(messages.seasons, { seasonCount: - title.seasons.length === request.seasons.length + (settings.currentSettings.enableSpecialEpisodes + ? title.seasons.length + : title.seasons.filter( + (season) => season.seasonNumber !== 0 + ).length) === request.seasons.length ? 0 : request.seasons.length, })} diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 10c9c7db..18579d64 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -253,9 +253,13 @@ const TvRequestModal = ({ }; const getAllSeasons = (): number[] => { - return (data?.seasons ?? []) - .filter((season) => season.episodeCount !== 0) - .map((season) => season.seasonNumber); + let allSeasons = (data?.seasons ?? []).filter( + (season) => season.episodeCount !== 0 + ); + if (!settings.currentSettings.partialRequestsEnabled) { + allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0); + } + return allSeasons.map((season) => season.seasonNumber); }; const getAllRequestedSeasons = (): number[] => { @@ -577,7 +581,12 @@ const TvRequestModal = ({ {data?.seasons - .filter((season) => season.episodeCount !== 0) + .filter( + (season) => + (!settings.currentSettings.enableSpecialEpisodes + ? season.seasonNumber !== 0 + : true) && season.episodeCount !== 0 + ) .map((season) => { const seasonRequest = getSeasonRequest( season.seasonNumber diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index e2c50cc1..d5f116c1 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -56,6 +56,7 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrl: 'You must provide a valid URL', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', + enableSpecialEpisodes: 'Allow Special Episodes Requests', locale: 'Display Language', proxyEnabled: 'HTTP(S) Proxy', proxyHostname: 'Proxy Hostname', @@ -158,6 +159,7 @@ const SettingsMain = () => { originalLanguage: data?.originalLanguage, streamingRegion: data?.streamingRegion, partialRequestsEnabled: data?.partialRequestsEnabled, + enableSpecialEpisodes: data?.enableSpecialEpisodes, trustProxy: data?.trustProxy, cacheImages: data?.cacheImages, proxyEnabled: data?.proxy?.enabled, @@ -188,6 +190,7 @@ const SettingsMain = () => { streamingRegion: values.streamingRegion, originalLanguage: values.originalLanguage, partialRequestsEnabled: values.partialRequestsEnabled, + enableSpecialEpisodes: values.enableSpecialEpisodes, trustProxy: values.trustProxy, cacheImages: values.cacheImages, proxy: { @@ -498,6 +501,47 @@ const SettingsMain = () => { />
    +
    + +
    + { + setFieldValue( + 'enableSpecialEpisodes', + !values.enableSpecialEpisodes + ); + }} + /> +
    +
    +
    +
    + + + +
    +
    )} -
    -
    - - - -
    -
    ); }} diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 4d50f1b8..77028595 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -301,7 +301,9 @@ const TvDetails = ({ tv }: TvDetailsProps) => { }; const showHasSpecials = data.seasons.some( - (season) => season.seasonNumber === 0 + (season) => + season.seasonNumber === 0 && + settings.currentSettings.partialRequestsEnabled ); const isComplete = @@ -799,6 +801,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => { {data.seasons .slice() .reverse() + .filter( + (season) => + settings.currentSettings.enableSpecialEpisodes || + season.seasonNumber !== 0 + ) .map((season) => { const show4k = settings.currentSettings.series4kEnabled && diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 5579940a..6a286d8a 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -21,6 +21,7 @@ const defaultSettings = { originalLanguage: '', mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, + enableSpecialEpisodes: false, cacheImages: false, vapidPublic: '', enablePushRegistration: false, diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 3fce7abd..ded40f9f 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -919,6 +919,7 @@ "components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)", "components.Settings.SettingsMain.discoverRegion": "Discover Region", "components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability", + "components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests", "components.Settings.SettingsMain.general": "General", "components.Settings.SettingsMain.generalsettings": "General Settings", "components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Jellyseerr.", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d0fbbfa9..facb3a44 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -199,6 +199,7 @@ CoreApp.getInitialProps = async (initialProps) => { originalLanguage: '', mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, + enableSpecialEpisodes: false, cacheImages: false, vapidPublic: '', enablePushRegistration: false, From 5fc4ae57c01fa90e61f6389346bc1449f2ee7773 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Mon, 30 Dec 2024 05:09:23 +0800 Subject: [PATCH 079/162] style(http-proxy): fix margin for responsive design (#1194) --- .../Settings/SettingsMain/index.tsx | 256 +++++++++--------- 1 file changed, 131 insertions(+), 125 deletions(-) diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index d5f116c1..8020b9fe 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -563,151 +563,157 @@ const SettingsMain = () => {
    {values.proxyEnabled && ( <> -
    -