Compare commits

...

41 Commits

Author SHA1 Message Date
THOMAS B
2ee5535819 Merge branch 'develop' into feat-add-tvdb-indexer 2024-11-03 13:15:11 +01:00
Fallenbagel
cf59102ef9 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
2024-11-03 14:35:20 +08:00
Gauthier
ca838a00fa 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
2024-10-31 16:10:45 +01:00
Gauthier
f2ed101e52 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
2024-10-31 15:51:57 +01:00
TOomaAh
67a846cd58 test(tvdb): change 'use' to 'tvdb' condition check 2024-10-29 23:28:28 +01:00
TOomaAh
a5e8320e8a refactor(tmdb): reduce still path condition 2024-10-29 23:20:41 +01:00
TOomaAh
3f16176667 refactor(settings): replace tvdb object to boolean type 2024-10-29 23:10:35 +01:00
TOomaAh
85aeeb084e refactor(indexer): remove unused getSeasonIdentifier method 2024-10-28 18:37:52 +01:00
TOomaAh
b865d65fad style(tvdb): rename pokemon to correct tv show 2024-10-27 13:06:27 +01:00
TOomaAh
47eece9c44 test(tvdb): add tvdb tests 2024-10-27 11:23:05 +01:00
TOomaAh
72277ea983 fix(test): wrong url tv-details 2024-10-27 01:48:07 +02:00
TOomaAh
57e2f7b374 fix(test): fix discover test 2024-10-27 01:18:27 +02:00
TOomaAh
6f8d4bf00a style: tvdb.login to tvdb.test 2024-10-26 16:13:52 +02:00
TOomaAh
61ecf74b28 style: replace avalaible to available 2024-10-26 15:52:40 +02:00
Gauthier
4b4eeb6ec7 feat: proxy setting (#1031)
* feat: add a proxy option into settings

* feat: add a proxy option into settings

* fix: use undici proxy agent
2024-10-26 12:19:42 +02:00
Gauthier
d331798b28 fix: remove language profiles dropdown for Sonarr v4 (#1000)
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
2024-10-24 18:34:01 +02:00
Gauthier
f2b63156d1 feat: add a warning if permissions are missing from config folder (#1030) 2024-10-24 18:13:11 +02:00
Gauthier
326001c3ec 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
2024-10-24 18:12:42 +02:00
Gauthier
0bbcfcbd5e 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
2024-10-24 18:11:25 +02:00
TOomaAh
976781d470 fix: wrong language with tmdb indexer 2024-10-23 12:36:37 +02:00
TOomaAh
422012f7b5 refactor: clean tvdb indexer code 2024-10-22 22:57:54 +02:00
Fallenbagel
32e0b129fe docs(aur): add disclaimer about being maintained by third-party (#1044) 2024-10-22 05:20:14 +08:00
TOomaAh
32f500a4e7 fix: error if tmdb poster is null 2024-10-20 16:27:08 +02:00
TOomaAh
7b07004c5b fix: error during get episodes 2024-10-20 16:17:42 +02:00
TOomaAh
87253e8bb7 refactor(tvdb): replace tvdb api by skyhook 2024-10-20 01:46:08 +02:00
TOomaAh
7bcda9521e Merge branch 'develop' into feat-add-tvdb-indexer
# Conflicts:
#	server/routes/tv.ts
#	src/components/TvDetails/index.tsx
2024-10-19 23:49:26 +02:00
Gauthier
a2b3408c9a feat: exit Jellyseerr when migration fails (#1026) 2024-10-18 18:24:29 +08:00
Fallenbagel
cbb1a74526 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
2024-10-18 06:28:42 +08:00
Fallenbagel
26c37ec067 docs(buildfromsource): remove latest/develop tabs and update instructions to support 2.0.0 (#1021)
re #1020
2024-10-17 23:12:41 +08:00
Gauthier
4e48fdf2cb 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
2024-10-17 21:24:15 +08:00
Gauthier
a351264b87 fix: handle non-existent rottentomatoes rating (#1018)
This fixes a bug where some media don't have any rottentomatoes ratings.
2024-10-17 18:37:19 +08:00
allcontributors[bot]
9de304d17a 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>
2024-10-17 07:25:36 +08:00
Fallenbagel
4945b54298 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
2024-10-17 07:25:06 +08:00
Fallenbagel
a0f80fe764 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
2024-10-16 03:50:21 +08:00
Gauthier
92ba26207d 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
2024-10-10 11:37:08 +02:00
Gauthier
96e1d40304 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
2024-10-02 20:59:35 +02:00
Thomas Loubiou
a5d22ba5b8 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
2024-09-30 18:56:25 +02:00
Gauthier
f390da4866 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
2024-09-25 21:25:44 +02:00
TOomaAh
2d51b16694 fix(usersettings): remove unused column tvdbtoken 2024-07-27 01:28:28 +02:00
TOomaAh
2a0bcdf41c fix: fix rate limiter index tvdb indexer 2024-07-27 00:11:22 +02:00
TOomaAh
79e542ef12 feat(tvdb): get tv seasons/episodes with tvdb 2024-07-26 23:56:22 +02:00
109 changed files with 1890 additions and 512 deletions

View File

@@ -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"
]
}
]
}

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ yarn-error.log*
# database
config/db/*.sqlite3*
config/settings.json
config/settings.old.json
# logs
config/logs/*.log*

View File

@@ -11,7 +11,7 @@
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**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
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,92 @@
describe('TVDB Integration', () => {
// Constants for routes and selectors
const ROUTES = {
home: '/',
tvdbSettings: '/settings/tvdb',
tomorrowIsOursTvShow: '/tv/72879',
monsterTvShow: '/tv/225634',
};
const SELECTORS = {
sidebarToggle: '[data-testid=sidebar-toggle]',
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
tvdbEnable: 'input[data-testid="tvdb-enable"]',
tvdbSaveButton: '[data-testid=tvbd-save-button]',
heading: '.heading',
season1: 'Season 1',
season2: 'Season 2',
};
// Reusable commands
const toggleTVDBSetting = () => {
cy.intercept('/api/v1/settings/tvdb').as('tvdbRequest');
cy.get(SELECTORS.tvdbSaveButton).click();
return cy.wait('@tvdbRequest');
};
const verifyTVDBResponse = (response, expectedUseValue) => {
expect(response.statusCode).to.equal(200);
expect(response.body.tvdb).to.equal(expectedUseValue);
};
beforeEach(() => {
// Perform login
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Navigate to TVDB settings
cy.visit(ROUTES.home);
cy.get(SELECTORS.sidebarToggle).click();
cy.get(SELECTORS.sidebarSettingsMobile).click();
cy.get(
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.tvdbSettings}"]`
).click();
// Verify heading
cy.get(SELECTORS.heading).should('contain', 'Tvdb');
// Configure TVDB settings
cy.get(SELECTORS.tvdbEnable).then(($checkbox) => {
const isChecked = $checkbox.is(':checked');
if (!isChecked) {
// If disabled, enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
} else {
// If enabled, disable then re-enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, false);
});
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
}
});
});
it('should display "Tomorrow is Ours" show information correctly (1 season on TMDB >1 seasons on TVDB)', () => {
cy.visit(ROUTES.tomorrowIsOursTvShow);
cy.contains(SELECTORS.season2)
.should('be.visible')
.scrollIntoView()
.click();
});
it('Should display "Monster" show information correctly (Not existing on TVDB)', () => {
cy.visit(ROUTES.monsterTvShow);
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
cy.contains(SELECTORS.season1)
.should('be.visible')
.scrollIntoView()
.click();
cy.wait('@season1');
cy.contains('9 - Hang Men').should('be.visible');
});
});

View File

@@ -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.
:::

View File

@@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### Prerequisites
<Tabs groupId="versions" queryString>
<TabItem value="latest" label="Latest">
- [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)
</TabItem>
<TabItem value="develop" label="Develop">
- [Node.js 20.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)
</TabItem>
</Tabs>
## Unix (Linux, macOS)
### Installation
<Tabs groupId="versions" queryString>
<TabItem value="latest" label="latest">
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
```
</TabItem>
<TabItem value="develop" label="develop">
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
```
</TabItem>
</Tabs>
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
@@ -234,33 +195,6 @@ pm2 status jellyseerr
## Windows
### Installation
<Tabs groupId="versions" queryString>
<TabItem value="latest" label="latest">
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
```
</TabItem>
<TabItem value="develop" label="develop">
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
```
</TabItem>
</Tabs>
:::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.

View File

@@ -10,6 +10,7 @@ module.exports = {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: 'artworks.thetvdb.com' },
],
},
webpack(config) {

View File

@@ -400,6 +400,12 @@ components:
serverID:
type: string
readOnly: true
TvdbSettings:
type: object
properties:
use:
type: boolean
example: true
TautulliSettings:
type: object
properties:
@@ -1988,6 +1994,9 @@ paths:
appDataPath:
type: string
example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main:
get:
summary: Get main settings
@@ -2358,6 +2367,60 @@ paths:
type: string
thumb:
type: string
/settings/tvdb:
get:
summary: Get TVDB settings
description: Retrieves current TVDB settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
put:
summary: Update TVDB settings
description: Updates TVDB settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
/settings/tvdb/test:
post:
summary: Test TVDB configuration
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
responses:
'200':
description: Succesfully connected to TVDB
content:
application/json:
schema:
type: object
properties:
languages:
type: array
items:
type: object
properties:
id:
type: number
name:
type: string
/settings/tautulli:
get:
summary: Get Tautulli settings
@@ -5906,7 +5969,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonId}:
/tv/{tvId}/season/{seasonNumber}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -5920,11 +5983,11 @@ paths:
type: number
example: 76479
- in: path
name: seasonId
name: seasonNumber
required: true
schema:
type: number
example: 1
example: 123456
- in: query
name: language
schema:

View File

@@ -93,7 +93,8 @@
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"typeorm": "0.3.12",
"typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",

65
pnpm-lock.yaml generated
View File

@@ -49,7 +49,7 @@ importers:
version: 2.11.0
connect-typeorm:
specifier: 1.1.4
version: 1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)))
cookie-parser:
specifier: 1.4.6
version: 1.4.6
@@ -192,8 +192,11 @@ importers:
specifier: 2.2.5
version: 2.2.5(react@18.3.1)
typeorm:
specifier: 0.3.12
version: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
specifier: 0.3.11
version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
undici:
specifier: ^6.20.1
version: 6.20.1
web-push:
specifier: 3.5.0
version: 3.5.0
@@ -4264,10 +4267,6 @@ packages:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dateformat@3.0.3:
resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==}
@@ -5389,8 +5388,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
https-proxy-agent@7.0.5:
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
engines: {node: '>= 14'}
human-signals@1.1.1:
@@ -6554,11 +6553,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
mkdirp@2.1.6:
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
engines: {node: '>=10'}
hasBin: true
modify-values@1.0.1:
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
engines: {node: '>=0.10.0'}
@@ -7730,9 +7724,6 @@ packages:
reflect-metadata@0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
reflect-metadata@0.1.14:
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
reflect.getprototypeof@1.0.6:
resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==}
engines: {node: '>= 0.4'}
@@ -8670,8 +8661,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.12:
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
typeorm@0.3.11:
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
engines: {node: '>= 12.9.0'}
hasBin: true
peerDependencies:
@@ -8682,7 +8673,7 @@ packages:
ioredis: ^5.0.4
mongodb: ^3.6.0
mssql: ^7.3.0
mysql2: ^2.2.5 || ^3.0.1
mysql2: ^2.2.5
oracledb: ^5.1.0
pg: ^8.5.1
pg-native: ^3.0.0
@@ -8768,6 +8759,10 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici@6.20.1:
resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==}
engines: {node: '>=18.17'}
unicode-canonical-property-names-ecmascript@2.0.0:
resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
@@ -12310,7 +12305,7 @@ snapshots:
fs-extra: 11.2.0
globby: 11.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.4
https-proxy-agent: 7.0.5
issue-parser: 6.0.0
lodash: 4.17.21
mime: 3.0.0
@@ -13824,13 +13819,13 @@ snapshots:
ini: 1.3.8
proto-list: 1.2.4
connect-typeorm@1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))):
dependencies:
'@types/debug': 0.0.31
'@types/express-session': 1.17.6
debug: 4.3.5(supports-color@8.1.1)
express-session: 1.18.0
typeorm: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))
transitivePeerDependencies:
- supports-color
@@ -14181,10 +14176,6 @@ snapshots:
date-fns@2.29.3: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.24.7
dateformat@3.0.3: {}
dayjs@1.11.11: {}
@@ -15739,7 +15730,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.4:
https-proxy-agent@7.0.5:
dependencies:
agent-base: 7.1.1
debug: 4.3.5(supports-color@8.1.1)
@@ -17149,8 +17140,6 @@ snapshots:
mkdirp@1.0.4: {}
mkdirp@2.1.6: {}
modify-values@1.0.1: {}
moment@2.30.1: {}
@@ -18372,8 +18361,6 @@ snapshots:
reflect-metadata@0.1.13: {}
reflect-metadata@0.1.14: {}
reflect.getprototypeof@1.0.6:
dependencies:
call-bind: 1.0.7
@@ -19431,23 +19418,23 @@ snapshots:
typedarray@0.0.6: {}
typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)):
dependencies:
'@sqltools/formatter': 1.2.5
app-root-path: 3.1.0
buffer: 6.0.3
chalk: 4.1.2
cli-highlight: 2.1.11
date-fns: 2.30.0
date-fns: 2.29.3
debug: 4.3.5(supports-color@8.1.1)
dotenv: 16.4.5
glob: 8.1.0
glob: 7.2.3
js-yaml: 4.1.0
mkdirp: 2.1.6
reflect-metadata: 0.1.14
mkdirp: 1.0.4
reflect-metadata: 0.1.13
sha.js: 2.4.11
tslib: 2.6.3
uuid: 9.0.1
uuid: 8.3.2
xml2js: 0.4.23
yargs: 17.7.2
optionalDependencies:
@@ -19486,6 +19473,8 @@ snapshots:
undici-types@5.26.5: {}
undici@6.20.1: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}
unicode-emoji-utils@1.2.0:

View File

@@ -8,7 +8,7 @@ const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
@@ -32,13 +32,28 @@ 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;
}
@@ -76,7 +91,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 +135,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 +179,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);
}

View File

@@ -0,0 +1,23 @@
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
export interface TvShowIndexer {
getTvShow({
tvId,
language,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails>;
getTvSeason({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes>;
}

View File

@@ -1,4 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
@@ -98,7 +99,7 @@ interface DiscoverTvOptions {
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
}
class TheMovieDb extends ExternalAPI {
class TheMovieDb extends ExternalAPI implements TvShowIndexer {
private region?: string;
private originalLanguage?: string;
constructor({
@@ -308,6 +309,12 @@ class TheMovieDb extends ExternalAPI {
}
);
data.episodes = data.episodes.map((episode) => {
if (episode.still_path) {
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
}
return episode;
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);

View File

@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
vote_count: number;
}
export interface TmdbTvSeasonResult {

View File

@@ -0,0 +1,246 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
import type {
TvdbEpisode,
TvdbLoginResponse,
TvdbSeason,
TvdbTvShowDetail,
} from '@server/api/indexer/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import logger from '@server/logger';
interface TvdbConfig {
baseUrl: string;
maxRequestsPerSecond: number;
cachePrefix: AvailableCacheIds;
}
const DEFAULT_CONFIG: TvdbConfig = {
baseUrl: 'https://skyhook.sonarr.tv/v1/tvdb/shows',
maxRequestsPerSecond: 50,
cachePrefix: 'tvdb' as const,
};
const enum TvdbIdStatus {
INVALID = -1,
}
type TvdbId = number;
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
class Tvdb extends ExternalAPI implements TvShowIndexer {
private readonly tmdb: TheMovieDb;
private static readonly DEFAULT_CACHE_TTL = 43200;
private static readonly DEFAULT_LANGUAGE = 'en';
constructor(config: Partial<TvdbConfig> = {}) {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
super(
finalConfig.baseUrl,
{},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
rateLimit: {
maxRPS: finalConfig.maxRequestsPerSecond,
id: finalConfig.cachePrefix,
},
}
);
this.tmdb = new TheMovieDb();
}
public async test(): Promise<TvdbLoginResponse> {
try {
return await this.get<TvdbLoginResponse>('/en/445009', {});
} catch (error) {
this.handleError('Login failed', error);
throw error;
}
}
public async getTvShow({
tvId,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (this.isValidTvdbId(tvdbId)) {
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
}
return tmdbTvShow;
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
throw error;
}
}
public async getTvSeason({
tvId,
seasonNumber,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> {
if (seasonNumber === 0) {
return this.createEmptySeasonResponse(tvId);
}
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (!this.isValidTvdbId(tvdbId)) {
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId);
} catch (error) {
logger.error(
`[TVDB] Failed to fetch TV season details: ${error.message}`
);
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
}
private async enrichTmdbShowWithTvdbData(
tmdbTvShow: TmdbTvDetails,
tvdbId: ValidTvdbId
): Promise<TmdbTvDetails> {
try {
const tvdbData = await this.fetchTvdbShowData(tvdbId);
const seasons = this.processSeasons(tvdbData);
return { ...tmdbTvShow, seasons };
} catch (error) {
logger.error(
`Failed to enrich TMDB show with TVDB data: ${error.message}`
);
return tmdbTvShow;
}
}
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvShowDetail> {
return await this.get<TvdbTvShowDetail>(
`/en/${tvdbId}`,
{},
Tvdb.DEFAULT_CACHE_TTL
);
}
private processSeasons(tvdbData: TvdbTvShowDetail): any[] {
return tvdbData.seasons
.filter((season) => season.seasonNumber !== 0)
.map((season) => this.createSeasonData(season, tvdbData));
}
private createSeasonData(
season: TvdbSeason,
tvdbData: TvdbTvShowDetail
): any {
if (!season.seasonNumber) return null;
const episodeCount = tvdbData.episodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber
).length;
return {
id: tvdbData.tvdbId,
episode_count: episodeCount,
name: `${season.seasonNumber}`,
overview: '',
season_number: season.seasonNumber,
poster_path: '',
air_date: '',
image: '',
};
}
private async getTvdbSeasonData(
tvdbId: number,
seasonNumber: number,
tvId: number
): Promise<TmdbSeasonWithEpisodes> {
const tvdbSeason = await this.fetchTvdbShowData(tvdbId);
const episodes = this.processEpisodes(tvdbSeason, seasonNumber, tvId);
return {
episodes,
external_ids: { tvdb_id: tvdbSeason.tvdbId },
name: '',
overview: '',
id: tvdbSeason.tvdbId,
air_date: tvdbSeason.firstAired,
season_number: episodes.length,
};
}
private processEpisodes(
tvdbSeason: TvdbTvShowDetail,
seasonNumber: number,
tvId: number
): any[] {
return tvdbSeason.episodes
.filter((episode) => episode.seasonNumber === seasonNumber)
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
}
private createEpisodeData(
episode: TvdbEpisode,
index: number,
tvId: number
): any {
return {
id: episode.tvdbId,
air_date: episode.airDate,
episode_number: episode.episodeNumber,
name: episode.title || `Episode ${index + 1}`,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',
show_id: tvId,
still_path: episode.image || '',
vote_average: 1,
vote_count: 1,
};
}
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
return {
episodes: [],
external_ids: { tvdb_id: tvId },
name: '',
overview: '',
id: 0,
air_date: '',
season_number: 0,
};
}
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
}
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
return tvdbId !== TvdbIdStatus.INVALID;
}
private handleError(context: string, error: Error): void {
throw new Error(`[TVDB] ${context}: ${error.message}`);
}
}
export default Tvdb;

View File

@@ -0,0 +1,80 @@
export interface TvdbBaseResponse<T> {
data: T;
errors: string;
}
export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> {
data: { token: string };
}
export interface TvdbTvShowDetail {
tvdbId: number;
title: string;
overview: string;
slug: string;
originalCountry: string;
originalLanguage: string;
language: string;
firstAired: string;
lastAired: string;
tvMazeId: number;
tmdbId: number;
imdbId: string;
lastUpdated: string;
status: string;
runtime: number;
timeOfDay: TvdbTimeOfDay;
originalNetwork: string;
network: string;
genres: string[];
alternativeTitles: TvdbAlternativeTitle[];
actors: TvdbActor[];
images: TvdbImage[];
seasons: TvdbSeason[];
episodes: TvdbEpisode[];
}
export interface TvdbTimeOfDay {
hours: number;
minutes: number;
}
export interface TvdbAlternativeTitle {
title: string;
}
export interface TvdbActor {
name: string;
character: string;
image?: string;
}
export interface TvdbImage {
coverType: string;
url: string;
}
export interface TvdbSeason {
seasonNumber: number;
}
export interface TvdbEpisode {
tvdbShowId: number;
tvdbId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
title?: string;
airDate: string;
airDateUtc: string;
runtime?: number;
overview?: string;
image?: string;
}
export interface TvdbEpisodeTranslation
extends TvdbBaseResponse<TvdbEpisodeTranslation> {
name: string;
overview: string;
language: string;
}

View File

@@ -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' }
);

View File

@@ -180,7 +180,7 @@ class PlexAPI {
settings.plex.libraries = [];
}
settings.save();
await settings.save();
}
public async getLibraryContents(

View File

@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
);
}
if (!tvshow) {
if (!tvshow || !tvshow.rottenTomatoes) {
return null;
}

View File

@@ -157,9 +157,13 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
includeEpisode: 'true',
});
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
includeEpisode: 'true',
},
0
);
return data.records;
} catch (e) {
@@ -193,15 +197,24 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
}
};
async refreshMonitoredDownloads(): Promise<void> {
await this.runCommand('RefreshMonitoredDownloads', {});
}
protected async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
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}`);
}

View File

@@ -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}`;
}
}
}

View File

@@ -1,3 +1,5 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
@@ -5,8 +7,6 @@ import type {
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -21,7 +21,9 @@ 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 createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out';
@@ -51,6 +53,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 () => {
@@ -67,6 +75,11 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types
if (
settings.plex.libraries.length > 1 &&
@@ -175,7 +188,7 @@ app
},
store: new TypeormStore({
cleanupLimit: 2,
ttl: 1000 * 60 * 60 * 24 * 30,
ttl: 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store,
})
);

View File

@@ -8,7 +8,8 @@ export type AvailableCacheIds =
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
| 'plextv'
| 'tvdb';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -68,6 +69,10 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -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) => ({

View File

@@ -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<ImageResponse> {
public async getImage(
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
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);

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';

View File

@@ -1,7 +1,7 @@
import animeList from '@server/api/animelist';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';
@@ -129,7 +129,7 @@ class PlexScanner
});
settings.plex.libraries = newLibraries;
settings.save();
await settings.save();
}
} else {
for (const library of this.libraries) {

View File

@@ -1,6 +1,6 @@
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieDetails,
TmdbMovieResult,
@@ -9,7 +9,7 @@ import type {
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,

View File

@@ -1,8 +1,11 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Tvdb from '@server/api/indexer/tvdb';
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';
@@ -99,6 +102,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,6 +133,7 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
proxy: ProxySettings;
}
interface PublicSettings {
@@ -291,6 +306,7 @@ export interface AllSettings {
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
tvdb: boolean;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -325,6 +341,16 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
},
plex: {
name: '',
@@ -346,6 +372,7 @@ class Settings {
apiKey: '',
},
tautulli: {},
tvdb: false,
radarr: [],
sonarr: [],
public: {
@@ -479,10 +506,6 @@ class Settings {
}
get main(): MainSettings {
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
this.save();
}
return this.data.main;
}
@@ -514,6 +537,14 @@ class Settings {
this.data.tautulli = data;
}
get tvdb(): boolean {
return this.data.tvdb;
}
set tvdb(data: boolean) {
this.data.tvdb = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
@@ -584,29 +615,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<MainSettings> {
this.main.apiKey = this.generateApiKey();
this.save();
await this.save();
return this.main;
}
@@ -618,15 +640,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
*
@@ -641,30 +654,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);
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<void> {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
}
}
@@ -678,4 +712,13 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
return settings;
};
export const getIndexer = (): TvShowIndexer => {
const settings = getSettings();
if (settings.tvdb) {
return new Tvdb();
} else {
return new TheMovieDb();
}
};
export default Settings;

View File

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

View File

@@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
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;
};

View File

@@ -1,30 +1,100 @@
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');
export const runMigrations = async (
settings: AllSettings
settings: AllSettings,
SETTINGS_PATH: string
): Promise<AllSettings> => {
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: string | null = null;
try {
oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
} 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(structuredClone(migrated));
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
logger.debug(`Migration '${migration}' has been applied.`, {
label: 'Settings Migrator',
});
}
migrated = newSettings;
} catch (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();
}
}
const settingsAfter = JSON.stringify(migrated);
if (settingsBefore !== settingsAfter) {
// a migration occured
// we check that the new config will be saved
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) {
// we stop jellyseerr if the migration failed
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
{
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this issue is a permission error of your configuration folder.',
{
label: 'Settings Migrator',
}
);
process.exit();
}
return migrated;

View File

@@ -1,4 +1,4 @@
import type { TmdbCollection } from '@server/api/themoviedb/interfaces';
import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
import { sortBy } from 'lodash';

View File

@@ -2,7 +2,7 @@ import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,

View File

@@ -2,7 +2,7 @@ import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetails,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
export interface PersonDetails {

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';

View File

@@ -5,7 +5,7 @@ import type {
TmdbTvEpisodeResult,
TmdbTvRatingResult,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
seasonNumber: episode.season_number,
showId: episode.show_id,
voteAverage: episode.vote_average,
voteCount: episode.vote_cuont,
voteCount: episode.vote_count,
stillPath: episode.still_path,
});

View File

@@ -7,7 +7,7 @@ import type {
TmdbVideoResult,
TmdbWatchProviderDetails,
TmdbWatchProviders,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type { Video } from '@server/models/Movie';
export interface ProductionCompany {

View File

@@ -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();
@@ -89,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);
@@ -262,8 +260,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 +277,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;
@@ -335,12 +326,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/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,
});
@@ -354,12 +340,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/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,
});
@@ -385,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);
@@ -408,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 = `${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 {
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) {
@@ -466,12 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/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

View File

@@ -1,16 +1,71 @@
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) => {
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
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 imageData = await avatarImageProxy.getImage(imagePath);
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
const mediaServerType = getSettings().main.mediaServerType;
throw new Error(
`Provided URL is not ${
mediaServerType === MediaServerType.JELLYFIN
? 'a Jellyfin'
: 'an Emby'
} avatar.`
);
}
const avatarImageCache = await initAvatarImageProxy();
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}`,
@@ -23,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,
});
}

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';

View File

@@ -1,7 +1,7 @@
import type { SortOptions } from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,10 +1,10 @@
import GithubAPI from '@server/api/github';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import PushoverAPI from '@server/api/pushover';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
@@ -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(),
});
});

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli';
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import {

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
import logger from '@server/logger';

View File

@@ -1,6 +1,6 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
@@ -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({

View File

@@ -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';
@@ -41,6 +40,7 @@ import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import tvdbRoutes from './tvdb';
const settingsRoutes = Router();
@@ -48,6 +48,7 @@ settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/tvdb', tvdbRoutes);
const filteredMainSettings = (
user: User,
@@ -70,19 +71,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.' });
@@ -119,7 +120,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',
@@ -232,7 +233,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);
});
@@ -283,7 +284,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', {
@@ -371,17 +372,12 @@ 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);
});
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({
@@ -400,9 +396,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const users = resp.users.map((user) => ({
username: user.Name,
id: user.Id,
thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
thumb: `/avatarproxy/${user.Id}`,
email: user.Name,
}));
@@ -442,7 +436,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',
@@ -703,7 +697,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
);
@@ -717,7 +711,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;
@@ -774,11 +768,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);
}

View File

@@ -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);
});

View File

@@ -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]);
});

View File

@@ -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);
});
@@ -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({
@@ -72,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(
@@ -100,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(
@@ -119,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]);
});

View File

@@ -0,0 +1,48 @@
import Tvdb from '@server/api/indexer/tvdb';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const tvdbRoutes = Router();
export interface TvdbSettings {
tvdb: boolean;
}
tvdbRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.put('/', (req, res) => {
const settings = getSettings();
const body = req.body as TvdbSettings;
settings.tvdb = body.tvdb ?? settings.tvdb ?? false;
settings.save();
return res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = new Tvdb();
await tvdb.test();
return res.status(200).json({ message: 'Successfully connected to Tvdb' });
} catch (e) {
logger.error('Failed to test Tvdb', {
label: 'Tvdb',
message: e.message,
});
return next({ status: 500, message: 'Failed to connect to Tvdb' });
}
});
export default tvdbRoutes;

View File

@@ -1,9 +1,10 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import { getIndexer } from '@server/lib/settings';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -12,9 +13,10 @@ import { Router } from 'express';
const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
const indexer = getIndexer();
try {
const tv = await tmdb.getTvShow({
const tv = await indexer.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
@@ -34,7 +36,9 @@ tvRoutes.get('/:id', async (req, res, next) => {
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
const tvEnglish = await indexer.getTvShow({
tvId: Number(req.params.id),
});
data.overview = tvEnglish.overview;
}
@@ -53,13 +57,12 @@ tvRoutes.get('/:id', async (req, res, next) => {
});
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const season = await tmdb.getTvSeason({
const indexer = getIndexer();
const season = await indexer.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
});
return res.status(200).json(mapSeasonWithEpisodes(season));

View File

@@ -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();
@@ -545,12 +539,7 @@ router.post(
).toString('base64'),
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/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

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import Issue from '@server/entity/Issue';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import {
MediaRequestStatus,
MediaStatus,

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ class RestartFlag {
return (
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy
this.settings.trustProxy !== settings.trustProxy ||
this.settings.proxy.enabled !== settings.proxy.enabled
);
}
}

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
export const isMovie = (
movie:

View File

@@ -268,6 +268,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
{title && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -293,6 +294,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
src={
title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -355,6 +357,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
type="avatar"
src={item.user.avatar}
alt=""
className="avatar-sm ml-1.5"

View File

@@ -198,6 +198,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -228,6 +229,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`

View File

@@ -4,24 +4,31 @@ import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => 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 (if any)
imageUrl = src;
} else {
return null;
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

@@ -61,6 +61,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{...props}
>
<CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full"
alt=""
src={imageUrl}

View File

@@ -123,6 +123,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage
type="tmdb"
alt=""
src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -33,6 +33,7 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
>
<div className="relative h-full w-full">
<CachedImage
type="tmdb"
src={image}
alt={name}
className="relative z-40 h-full w-full"

View File

@@ -9,7 +9,7 @@ import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -36,6 +36,7 @@ const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
tabIndex={0}
>
<CachedImage
type="tmdb"
src={image}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -1,7 +1,7 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { TmdbGenre } from '@server/api/indexer/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {

View File

@@ -89,7 +89,8 @@ const IssueComment = ({
</Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<CachedImage
src={`${comment.user.avatar}`}
type="avatar"
src={comment.user.avatar}
alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={40}

View File

@@ -217,6 +217,7 @@ const IssueDetails = () => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -235,6 +236,7 @@ const IssueDetails = () => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -287,7 +289,8 @@ const IssueDetails = () => {
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<CachedImage
src={`${issueData.createdBy.avatar}`}
type="avatar"
src={issueData.createdBy.avatar}
alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
width={20}

View File

@@ -112,6 +112,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -137,6 +138,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -226,7 +228,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
className="group flex items-center truncate"
>
<CachedImage
src={'/avatarproxy/' + issue.createdBy.avatar}
type="avatar"
src={issue.createdBy.avatar}
alt=""
className="avatar-sm ml-1.5 object-cover"
width={20}

View File

@@ -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: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/,
},
{
href: '/blacklist',
content: intl.formatMessage(menuMessages.blacklist),
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
activeRegExp: /^\/blacklist/,
requiredPermission: [
Permission.MANAGE_BLACKLIST,
Permission.VIEW_BLACKLIST,
],
permissionType: 'or',
},
{
href: '/issues',
content: intl.formatMessage(menuMessages.issues),

View File

@@ -57,6 +57,7 @@ const UserDropdown = () => {
data-testid="user-menu"
>
<CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''}
alt=""
@@ -80,6 +81,7 @@ const UserDropdown = () => {
<div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<CachedImage
type="avatar"
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user ? user.avatar : ''}
alt=""

View File

@@ -369,6 +369,7 @@ const ManageSlideOver = ({
content={user.displayName}
>
<CachedImage
type="avatar"
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
@@ -530,6 +531,7 @@ const ManageSlideOver = ({
content={user.displayName}
>
<CachedImage
type="avatar"
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"

View File

@@ -448,6 +448,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -494,6 +495,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
@@ -741,6 +743,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
<div className="absolute inset-0 z-0">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
alt=""
style={{

View File

@@ -51,6 +51,7 @@ const PersonCard = ({
{profilePath ? (
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt=""
style={{

View File

@@ -227,6 +227,7 @@ const PersonDetails = () => {
{data.profilePath && (
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}

View File

@@ -116,6 +116,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -345,6 +346,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -390,6 +392,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
>
<span className="avatar-sm">
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -602,6 +605,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
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"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`

View File

@@ -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',
});
@@ -190,6 +191,7 @@ const RequestItemError = ({
>
<span className="avatar-sm ml-1.5">
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -249,6 +251,7 @@ const RequestItemError = ({
>
<span className="avatar-sm ml-1.5">
<CachedImage
type="avatar"
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -341,6 +344,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);
@@ -405,6 +420,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
@@ -430,6 +446,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
@@ -557,6 +574,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
>
<span className="avatar-sm ml-1.5">
<CachedImage
type="avatar"
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
@@ -616,7 +634,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
>
<span className="avatar-sm ml-1.5">
<CachedImage
src={requestData.requestedBy.avatar}
type="avatar"
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -666,14 +685,28 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
<>
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
<ConfirmButton
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>
{intl.formatMessage(messages.removearr, {
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</ConfirmButton>
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -562,6 +562,7 @@ const AdvancedRequester = ({
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="flex items-center">
<CachedImage
type="avatar"
src={selectedUser.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
@@ -614,6 +615,7 @@ const AdvancedRequester = ({
} flex items-center`}
>
<CachedImage
type="avatar"
src={user.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"

View File

@@ -437,6 +437,7 @@ const CollectionRequestModal = ({
>
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
<CachedImage
type="tmdb"
src={
part.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`

View File

@@ -9,7 +9,7 @@ import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest';

View File

@@ -11,7 +11,7 @@ import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type {
Keyword,
@@ -452,6 +452,7 @@ export const WatchProviderSelector = ({
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
@@ -497,6 +498,7 @@ export const WatchProviderSelector = ({
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{

View File

@@ -37,6 +37,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/users',
regex: /^\/settings\/users/,
},
{
text: 'Tvdb',
route: '/settings/tvdb',
regex: /^\/settings\/tvdb/,
},
settings.currentSettings.mediaServerType === MediaServerType.PLEX
? {
text: intl.formatMessage(messages.menuPlexSettings),

View File

@@ -55,6 +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',
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 = () => {
@@ -82,6 +93,12 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
});
const regenerate = async () => {
@@ -137,6 +154,14 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
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}
@@ -158,6 +183,16 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
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();
@@ -437,6 +472,176 @@ const SettingsMain = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.proxyEnabled)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyEnabled"
name="proxyEnabled"
onChange={() => {
setFieldValue('proxyEnabled', !values.proxyEnabled);
}}
/>
</div>
</div>
{values.proxyEnabled && (
<>
<div className="form-row">
<label htmlFor="proxyHostname" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyHostname)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyHostname"
name="proxyHostname"
type="text"
/>
</div>
{errors.proxyHostname &&
touched.proxyHostname &&
typeof errors.proxyHostname === 'string' && (
<div className="error">{errors.proxyHostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPort" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPort)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyPort" name="proxyPort" type="text" />
</div>
{errors.proxyPort &&
touched.proxyPort &&
typeof errors.proxyPort === 'string' && (
<div className="error">{errors.proxyPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxySsl" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxySsl)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxySsl"
name="proxySsl"
onChange={() => {
setFieldValue('proxySsl', !values.proxySsl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyUser" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyUser)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyUser" name="proxyUser" type="text" />
</div>
{errors.proxyUser &&
touched.proxyUser &&
typeof errors.proxyUser === 'string' && (
<div className="error">{errors.proxyUser}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPassword" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPassword)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyPassword"
name="proxyPassword"
type="password"
/>
</div>
{errors.proxyPassword &&
touched.proxyPassword &&
typeof errors.proxyPassword === 'string' && (
<div className="error">{errors.proxyPassword}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassFilter"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyBypassFilter)}
</span>
<span className="label-tip ml-4">
{intl.formatMessage(messages.proxyBypassFilterTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyBypassFilter"
name="proxyBypassFilter"
type="text"
/>
</div>
{errors.proxyBypassFilter &&
touched.proxyBypassFilter &&
typeof errors.proxyBypassFilter === 'string' && (
<div className="error">
{errors.proxyBypassFilter}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassLocalAddresses"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(
messages.proxyBypassLocalAddresses
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyBypassLocalAddresses"
name="proxyBypassLocalAddresses"
onChange={() => {
setFieldValue(
'proxyBypassLocalAddresses',
!values.proxyBypassLocalAddresses
);
}}
/>
</div>
</div>
</>
)}
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -0,0 +1,198 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { TvdbSettings } from '@server/routes/settings/tvdb';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Settings', {
general: 'General',
settings: 'Settings',
apikey: 'API Key',
pin: 'PIN',
enable: 'Enable',
enableTip:
'Enable Tvdb (only for season and episode).' +
' Due to a limitation of the api used, only English is available.',
});
const SettingsTvdb = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
const { addToast } = useToasts();
const testConnection = async () => {
const response = await fetch('/api/v1/settings/tvdb/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to test Tvdb');
}
};
const saveSettings = async (value: TvdbSettings) => {
const response = await fetch('/api/v1/settings/tvdb', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tvdb: value.tvdb,
}),
});
if (!response.ok) {
throw new Error('Failed to save Tvdb settings');
}
};
const { data, error } = useSWR<TvdbSettings>('/api/v1/settings/tvdb');
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.general),
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">{'Tvdb'}</h3>
<p className="description">{'Settings for Tvdb'}</p>
</div>
<div className="section">
<Formik
initialValues={{
enable: data?.tvdb ?? false,
}}
onSubmit={async (values) => {
try {
setIsTesting(true);
await testConnection();
setIsTesting(false);
} catch (e) {
addToast('Tvdb connection error, check your credentials', {
appearance: 'error',
});
return;
}
try {
await saveSettings({
tvdb: values.enable ?? false,
});
if (data) {
data.tvdb = values.enable;
}
} catch (e) {
addToast('Failed to save Tvdb settings', { appearance: 'error' });
return;
}
addToast('Tvdb settings saved', { appearance: 'success' });
}}
>
{({ isSubmitting, isValid, values, setFieldValue }) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.enable)}
</span>
<SettingsBadge badgeType="experimental" />
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
</span>
</label>
<div className="form-input-area">
<Field
data-testid="tvdb-enable"
type="checkbox"
id="enable"
name="enable"
onChange={() => {
setFieldValue('enable', !values.enable);
addToast('Tvdb connection successful', {
appearance: 'success',
});
}}
/>
</div>
<div className="error"></div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
type="button"
disabled={isSubmitting || !isValid}
onClick={async () => {
setIsTesting(true);
try {
await testConnection();
addToast('Tvdb connection successful', {
appearance: 'success',
});
} catch (e) {
addToast(
'Tvdb connection error, check your credentials',
{ appearance: 'error' }
);
}
setIsTesting(false);
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
data-testid="tvbd-save-button"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
</>
);
};
export default SettingsTvdb;

View File

@@ -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<TestResponse>({
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) => {
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeLanguageProfileId"
name="activeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
</div>
{errors.activeLanguageProfileId &&
touched.activeLanguageProfileId && (
<div className="error">
{errors.activeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
@@ -863,53 +869,55 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
{testResponse.languageProfiles && (
<div className="form-row">
<label
htmlFor="activeAnimeLanguageProfileId"
className="text-label"
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeAnimeLanguageProfileId"
name="activeAnimeLanguageProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(
messages.loadinglanguageprofiles
)
: !isValidated
? intl.formatMessage(
messages.testFirstLanguageProfiles
)
: intl.formatMessage(
messages.selectLanguageProfile
)}
</option>
{testResponse.languageProfiles.length > 0 &&
testResponse.languageProfiles.map((language) => (
<option
key={`loaded-profile-${language.id}`}
value={language.id}
>
{language.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
</div>
{errors.activeAnimeLanguageProfileId &&
touched.activeAnimeLanguageProfileId && (
<div className="error">
{errors.activeAnimeLanguageProfileId}
</div>
)}
</div>
</div>
)}
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}

View File

@@ -346,6 +346,7 @@ const TitleCard = ({
>
<div className="absolute inset-0 h-full w-full overflow-hidden">
<CachedImage
type="tmdb"
className="absolute inset-0 h-full w-full"
alt=""
src={

Some files were not shown because too many files have changed in this diff Show More