Compare commits

..

25 Commits

Author SHA1 Message Date
Gauthier
dd2c752cf7 fix: redirect the setup page when Jellyseerr is already setup 2024-08-20 17:07:17 +02:00
gauthier-th
dc34d6c1f6 fix: move "scanning in background" tip next to the scanning section 2024-08-19 19:48:58 +02:00
gauthier-th
d868082b56 fix: go back to the last step when refresh the setup page 2024-08-19 19:38:15 +02:00
gauthier-th
e531765dac fix: remove old references to JELLYFIN_TYPE environment variable 2024-08-19 19:24:43 +02:00
gauthier-th
58eb91530b chore: merge develop 2024-08-19 19:23:01 +02:00
gauthier-th
2d802a5eff chore: merge develop 2024-07-29 22:04:36 +02:00
fallenbagel
e03edd2d1f refactor: use enums instead of numbers 2024-07-26 03:38:11 +05:00
fallenbagel
2d9530c0ed refactor(i18n): fix a typo 2024-07-25 22:02:34 +05:00
fallenbagel
1cc43cee93 refactor: use title case for servertype i18n message 2024-07-25 22:00:20 +05:00
fallenbagel
5bcbc810d6 style: decrease emby logo size in setup screen 2024-07-25 21:58:04 +05:00
fallenbagel
b8042ab700 refactor: change emby logo to full logo 2024-07-25 19:44:16 +05:00
fallenbagel
c3a6c7d4b2 fix: emby media server type migration 2024-07-25 19:39:25 +05:00
fallenbagel
6636aeef45 fix: scan jobs not running when media server type is emby 2024-07-25 19:09:03 +05:00
fallenbagel
6207a6a26d feat: migrate existing emby setups to use emby mediaServerType 2024-07-25 18:36:11 +05:00
fallenbagel
5875b2b5c2 Merge branch 'develop' into feat-server-type-setup 2024-07-25 18:24:35 +05:00
Gauthier
635a5f019c chore: merge develop 2024-07-15 18:34:58 +02:00
Gauthier
900e6110ad feat: add server type step to setup 2024-06-04 12:25:39 +02:00
fallenbagel
19e20749c1 revert: removed conditional render of the auto-request permission
reverts the conditional render toshow the auto-request permission if the mediaServerType was set to
Plex as this should be handled in a different PR and Cypress tests should be modified
accordingly(currently cypress test would fail if this conditional check is there)
2024-03-14 04:07:03 +05:00
fallenbagel
1aac3c5f09 refactor: cleaner way of handling serverType change using MediaServerType instead of strings
instead of using strings now it will use MediaServerType enums for serverType
2024-03-14 03:44:34 +05:00
fallenbagel
1bc4bd3d69 fix: issue page formatMessage for 4k media 2024-03-14 02:41:46 +05:00
fallenbagel
7a505813d5 refactor(auth): jellyfin/emby authentication to set MediaServerType 2024-03-14 01:19:09 +05:00
fallenbagel
45dbf84d7e refactor: use enums for serverType and rename selectedservice to serverType 2024-03-14 01:18:03 +05:00
fallenbagel
a4f1f1203a feat(api): add severType to the api
BREAKING CHANGE: This adds a serverType to the `/auth/jellyfin` which requires a serverType to be
set (`jellyfin`/`emby`)
2024-03-14 00:52:16 +05:00
fallenbagel
504d8bd5fe Merge branch 'develop' into feat-server-type-setup 2024-03-14 00:48:00 +05:00
fallenbagel
395a91c2f0 feat: add Media Server Selection to Setup Page
Introduce the ability to select the media server type on the setup page. Users can now choose their
preferred media server (e.g., Plex through the Plex sign-in or Emby/Jellyfin sign-in to select
either Emby or Jellyfin). The selected media server type is then reflected in the application
settings. This enhancement provides users with increased flexibility and customization options
during the initial setup process, eliminating the need to rely on environment variables (which
cannot be set if using platforms like snaps). Existing Emby users, who use the environment variable,
should log out and log back in after updating to set their mediaServerType to Emby.

BREAKING CHANGE: This commit deprecates the JELLYFIN_TYPE variable to identify Emby media server and
instead rely on the mediaServerType that is set in the `settings.json`. Existing environment
variable users can log out and log back in to set the mediaServerType to `3` (Emby).
2023-11-18 02:20:05 +05:00
143 changed files with 929 additions and 4219 deletions

View File

@@ -439,15 +439,6 @@
"contributions": [
"code"
]
},
{
"login": "M0NsTeRRR",
"name": "Ludovic Ortega",
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
"profile": "https://github.com/M0NsTeRRR",
"contributions": [
"security"
]
}
]
}

View File

@@ -1,6 +1,6 @@
name: 🐛 Bug Report
description: Report a problem
labels: ['bug', 'awaiting triage']
labels: ['type:bug', 'awaiting-triage']
body:
- type: markdown
attributes:

View File

@@ -1,6 +1,6 @@
name: ✨ Feature Request
description: Suggest an idea
labels: ['enhancement', 'awaiting triage']
labels: ['type:enhancement', 'awaiting-triage']
body:
- type: markdown
attributes:

1
.gitignore vendored
View File

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

View File

@@ -8,10 +8,10 @@
<p align="center">
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="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="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/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-48-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library.
@@ -146,7 +146,6 @@ 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

@@ -1,92 +0,0 @@
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,10 +6,6 @@ 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,12 +12,49 @@ 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
@@ -40,6 +77,8 @@ pnpm build
```bash
pnpm start
```
</TabItem>
</Tabs>
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
@@ -195,6 +234,33 @@ 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
@@ -218,6 +284,8 @@ pnpm build
```powershell
pnpm start
```
</TabItem>
</Tabs>
:::tip
You can add the environment variables to a `.env` file in the Jellyseerr directory.
@@ -245,7 +313,6 @@ 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

@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
<>
{!isUpToDate ? (
<Admonition type="warning">
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>.
</Admonition>
) : (
<Admonition type="success">
@@ -95,12 +95,12 @@ export const VersionMatch = () => {
};
offlineCache = pkgs.fetchYarnDeps {
sha256 = pkgs.lib.fakeSha256;
sha256 = pkgs.lib.fakeSha256;
};
});
});
};
}`;
const module = `{ config, pkgs, lib, ... }:
with lib;

View File

@@ -12,8 +12,6 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key.
## Application Title
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!

View File

@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
### Discover Region & Discover Language
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences.
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
### Movie Request Limit & Series Request Limit

View File

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

View File

@@ -38,8 +38,6 @@ tags:
description: Endpoints related to getting service (Radarr/Sonarr) details.
- name: watchlist
description: Collection of media to watch later
- name: blacklist
description: Blacklisted media from discovery page.
servers:
- url: '{server}/api/v1'
variables:
@@ -48,19 +46,6 @@ servers:
components:
schemas:
Blacklist:
type: object
properties:
tmdbId:
type: number
example: 1
title:
type: string
media:
$ref: '#/components/schemas/MediaInfo'
userId:
type: number
example: 1
Watchlist:
type: object
properties:
@@ -400,12 +385,6 @@ components:
serverID:
type: string
readOnly: true
TvdbSettings:
type: object
properties:
use:
type: boolean
example: true
TautulliSettings:
type: object
properties:
@@ -1994,9 +1973,6 @@ paths:
appDataPath:
type: string
example: /app/config
appDataPermissions:
type: boolean
example: true
/settings/main:
get:
summary: Get main settings
@@ -2367,60 +2343,6 @@ 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
@@ -2853,15 +2775,6 @@ paths:
imageCount:
type: number
example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
@@ -4129,94 +4042,6 @@ paths:
restricted:
type: boolean
example: false
/blacklist:
get:
summary: Returns blacklisted items
description: Returns list of all blacklisted media
tags:
- settings
parameters:
- in: query
name: take
schema:
type: number
nullable: true
example: 25
- in: query
name: skip
schema:
type: number
nullable: true
example: 0
- in: query
name: search
schema:
type: string
nullable: true
example: dune
responses:
'200':
description: Blacklisted items returned
content:
application/json:
schema:
type: object
properties:
pageInfo:
$ref: '#/components/schemas/PageInfo'
results:
type: array
items:
type: object
properties:
user:
$ref: '#/components/schemas/User'
createdAt:
type: string
example: 2024-04-21T01:55:44.000Z
id:
type: number
example: 1
mediaType:
type: string
example: movie
title:
type: string
example: Dune
tmdbId:
type: number
example: 438631
post:
summary: Add media to blacklist
tags:
- blacklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Blacklist'
responses:
'201':
description: Item succesfully blacklisted
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
delete:
summary: Remove media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'204':
description: Succesfully removed media item
/watchlist:
post:
summary: Add media to watchlist
@@ -5039,11 +4864,6 @@ paths:
schema:
type: string
example: 8|9
- in: query
name: status
schema:
type: string
example: 3|4
responses:
'200':
description: Results
@@ -5969,7 +5789,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonNumber}:
/tv/{tvId}/season/{seasonId}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -5983,11 +5803,11 @@ paths:
type: number
example: 76479
- in: path
name: seasonNumber
name: seasonId
required: true
schema:
type: number
example: 123456
example: 1
- in: query
name: language
schema:

View File

@@ -62,7 +62,6 @@
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
@@ -93,8 +92,7 @@
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"typeorm": "0.3.11",
"undici": "^6.20.1",
"typeorm": "0.3.12",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
@@ -121,7 +119,6 @@
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",

90
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.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)))
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)))
cookie-parser:
specifier: 1.4.6
version: 1.4.6
@@ -98,9 +98,6 @@ importers:
lodash:
specifier: 4.17.21
version: 4.17.21
mime:
specifier: '3'
version: 3.0.0
next:
specifier: ^14.2.4
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -192,11 +189,8 @@ importers:
specifier: 2.2.5
version: 2.2.5(react@18.3.1)
typeorm:
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
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))
web-push:
specifier: 3.5.0
version: 3.5.0
@@ -270,9 +264,6 @@ importers:
'@types/lodash':
specifier: 4.14.191
version: 4.14.191
'@types/mime':
specifier: '3'
version: 3.0.4
'@types/node':
specifier: 20.14.8
version: 20.14.8
@@ -2857,9 +2848,6 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mime@3.0.4':
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -4267,6 +4255,10 @@ 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==}
@@ -5388,8 +5380,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.5:
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
https-proxy-agent@7.0.4:
resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==}
engines: {node: '>= 14'}
human-signals@1.1.1:
@@ -6553,6 +6545,11 @@ 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'}
@@ -7724,6 +7721,9 @@ 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'}
@@ -8661,8 +8661,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.11:
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
typeorm@0.3.12:
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
engines: {node: '>= 12.9.0'}
hasBin: true
peerDependencies:
@@ -8673,7 +8673,7 @@ packages:
ioredis: ^5.0.4
mongodb: ^3.6.0
mssql: ^7.3.0
mysql2: ^2.2.5
mysql2: ^2.2.5 || ^3.0.1
oracledb: ^5.1.0
pg: ^8.5.1
pg-native: ^3.0.0
@@ -8759,10 +8759,6 @@ 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'}
@@ -10840,7 +10836,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.3.8
semver: 7.6.2
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -10915,13 +10911,13 @@ snapshots:
'@npmcli/fs@1.1.1':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.3.8
semver: 7.6.2
optional: true
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.3.8
semver: 7.6.2
'@npmcli/move-file@1.1.2':
dependencies:
@@ -12305,7 +12301,7 @@ snapshots:
fs-extra: 11.2.0
globby: 11.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
https-proxy-agent: 7.0.4
issue-parser: 6.0.0
lodash: 4.17.21
mime: 3.0.0
@@ -12330,7 +12326,7 @@ snapshots:
read-pkg: 5.2.0
registry-auth-token: 5.0.2
semantic-release: 19.0.5(encoding@0.1.13)
semver: 7.3.8
semver: 7.6.2
tempy: 1.0.1
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
@@ -12674,8 +12670,6 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/mime@3.0.4': {}
'@types/minimatch@3.0.5': {}
'@types/minimist@1.2.5': {}
@@ -12893,7 +12887,7 @@ snapshots:
debug: 4.3.5(supports-color@8.1.1)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.3.8
semver: 7.6.2
tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies:
typescript: 4.9.5
@@ -13819,13 +13813,13 @@ snapshots:
ini: 1.3.8
proto-list: 1.2.4
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))):
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))):
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.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))
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))
transitivePeerDependencies:
- supports-color
@@ -14176,6 +14170,10 @@ 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: {}
@@ -15730,7 +15728,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.5:
https-proxy-agent@7.0.4:
dependencies:
agent-base: 7.1.1
debug: 4.3.5(supports-color@8.1.1)
@@ -17140,6 +17138,8 @@ snapshots:
mkdirp@1.0.4: {}
mkdirp@2.1.6: {}
modify-values@1.0.1: {}
moment@2.30.1: {}
@@ -17269,7 +17269,7 @@ snapshots:
nopt: 5.0.0
npmlog: 6.0.2
rimraf: 3.0.2
semver: 7.3.8
semver: 7.6.2
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -17348,7 +17348,7 @@ snapshots:
dependencies:
hosted-git-info: 4.1.0
is-core-module: 2.14.0
semver: 7.3.8
semver: 7.6.2
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
@@ -18361,6 +18361,8 @@ snapshots:
reflect-metadata@0.1.13: {}
reflect-metadata@0.1.14: {}
reflect.getprototypeof@1.0.6:
dependencies:
call-bind: 1.0.7
@@ -19418,23 +19420,23 @@ snapshots:
typedarray@0.0.6: {}
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)):
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)):
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.29.3
date-fns: 2.30.0
debug: 4.3.5(supports-color@8.1.1)
dotenv: 16.4.5
glob: 7.2.3
glob: 8.1.0
js-yaml: 4.1.0
mkdirp: 1.0.4
reflect-metadata: 0.1.13
mkdirp: 2.1.6
reflect-metadata: 0.1.14
sha.js: 2.4.11
tslib: 2.6.3
uuid: 8.3.2
uuid: 9.0.1
xml2js: 0.4.23
yargs: 17.7.2
optionalDependencies:
@@ -19473,8 +19475,6 @@ 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;
export interface ExternalAPIOptions {
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
@@ -32,28 +32,13 @@ class ExternalAPI {
this.fetch = fetch;
}
const url = new URL(baseUrl);
this.baseUrl = baseUrl;
this.params = params;
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;
}
@@ -91,7 +76,7 @@ class ExternalAPI {
}
const data = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
@@ -135,7 +120,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
@@ -179,7 +164,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}

View File

@@ -1,23 +0,0 @@
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,246 +0,0 @@
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

@@ -1,80 +0,0 @@
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 from the Jellyfin server: ${e.message}`,
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);

View File

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

View File

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

View File

@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
includeEpisode: 'true',
},
0
);
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
includeEpisode: 'true',
});
return data.records;
} catch (e) {
@@ -197,24 +193,15 @@ 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,
},
{},
0
);
await this.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
});
try {
await this.runCommand('MissingEpisodeSearch', { seriesId });
await this.runCommand('SeriesSearch', { seriesId });
} catch (e) {
logger.error(
'Something went wrong while executing Sonarr missing episode search.',
'Something went wrong while executing Sonarr series search.',
{
label: 'Sonarr API',
errorMessage: e.message,

View File

@@ -1,5 +1,4 @@
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 {
@@ -96,10 +95,9 @@ interface DiscoverTvOptions {
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
}
class TheMovieDb extends ExternalAPI implements TvShowIndexer {
class TheMovieDb extends ExternalAPI {
private region?: string;
private originalLanguage?: string;
constructor({
@@ -309,12 +307,6 @@ class TheMovieDb extends ExternalAPI implements TvShowIndexer {
}
);
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}`);
@@ -531,7 +523,6 @@ class TheMovieDb extends ExternalAPI implements TvShowIndexer {
voteCountLte,
watchProviders,
watchRegion,
withStatus,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
@@ -579,7 +570,6 @@ class TheMovieDb extends ExternalAPI implements TvShowIndexer {
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
});
return data;

View File

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

View File

@@ -2,7 +2,6 @@ export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',

View File

@@ -16,5 +16,4 @@ export enum MediaStatus {
PROCESSING,
PARTIALLY_AVAILABLE,
AVAILABLE,
BLACKLISTED,
}

View File

@@ -1,95 +0,0 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
export class Blacklist implements BlacklistItem {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ nullable: true, type: 'varchar' })
title?: string;
@Column()
@Index()
public tmdbId: number;
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;
@CreateDateColumn()
public createdAt: Date;
constructor(init?: Partial<Blacklist>) {
Object.assign(this, init);
}
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = getRepository(this);
await blacklistRepository.save(blacklist);
if (!media) {
media = new Media({
tmdbId: blacklistRequest.tmdbId,
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;
await mediaRepository.save(media);
}
}
}

View File

@@ -3,7 +3,6 @@ import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker';
@@ -18,7 +17,6 @@ import {
Entity,
Index,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@@ -68,7 +66,7 @@ class Media {
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType: mediaType },
where: { tmdbId: id, mediaType },
relations: { requests: true, issues: true },
});
@@ -118,11 +116,6 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn()
public createdAt: Date;
@@ -231,7 +224,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.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
}
}
}

View File

@@ -1,5 +1,3 @@
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 {
@@ -7,6 +5,8 @@ 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,
@@ -40,7 +40,6 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
@@ -144,16 +143,6 @@ export class MediaRequest {
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.BLACKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
label: 'Media Request',
});
throw new BlacklistedMediaError('This media is blacklisted.');
}
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING;
}

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
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

@@ -19,11 +19,8 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { 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';
@@ -53,12 +50,6 @@ 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 () => {
@@ -75,11 +66,6 @@ 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 &&
@@ -188,7 +174,7 @@ app
},
store: new TypeormStore({
cleanupLimit: 2,
ttl: 60 * 60 * 24 * 30,
ttl: 1000 * 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store,
})
);
@@ -216,7 +202,6 @@ app
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -1,14 +0,0 @@
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user: User;
}
export interface BlacklistResultsResponse extends PaginatedResponse {
results: BlacklistItem[];
}

View File

@@ -58,7 +58,7 @@ export interface CacheItem {
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse {

View File

@@ -227,9 +227,6 @@ export const startJobs = (): void => {
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}),
});

View File

@@ -8,8 +8,7 @@ export type AvailableCacheIds =
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv'
| 'tvdb';
| 'plextv';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -69,10 +68,6 @@ 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,7 +85,6 @@ class DownloadTracker {
});
try {
await radarr.refreshMonitoredDownloads();
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -163,7 +162,6 @@ class DownloadTracker {
});
try {
await sonarr.refreshMonitoredDownloads();
const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({

View File

@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path';
type ImageResponse = {
@@ -12,7 +11,7 @@ type ImageResponse = {
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string | null;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
@@ -28,45 +27,29 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
try {
const files = await promises.readdir(cacheDirectory);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath), {
recursive: true,
});
deletedImages += 1;
}
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
@@ -86,56 +69,39 @@ class ImageProxy {
}
private static async getDirectorySize(dir: string): Promise<number> {
try {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
return size;
}
}
return 0;
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
try {
const files = await promises.readdir(dir);
const files = await promises.readdir(dir);
return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
return files.length;
}
private fetch: typeof fetch;
private cacheVersion;
private key;
private baseUrl;
private headers: HeadersInit | null = null;
constructor(
key: string,
@@ -143,7 +109,6 @@ class ImageProxy {
options: {
cacheVersion?: number;
rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
@@ -157,13 +122,9 @@ class ImageProxy {
} else {
this.fetch = fetch;
}
this.headers = options.headers || null;
}
public async getImage(
path: string,
fallbackPath?: string
): Promise<ImageResponse> {
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
@@ -172,11 +133,7 @@ class ImageProxy {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
if (fallbackPath) {
return await this.getImage(fallbackPath);
} else {
throw new Error('Failed to load image');
}
throw new Error('Failed to load image');
}
return newImage;
@@ -190,27 +147,6 @@ class ImageProxy {
return imageResponse;
}
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
@@ -251,30 +187,16 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(this.baseUrl.endsWith('/') ? '' : '/') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href, {
headers: this.headers || undefined,
});
if (!response.ok) {
return null;
}
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension(
response.headers.get('content-type') ?? ''
);
let maxAge = Number(
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
@@ -310,7 +232,7 @@ class ImageProxy {
private async writeToCacheDir(
dir: string,
extension: string | null,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,

View File

@@ -27,8 +27,6 @@ export enum Permission {
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLACKLIST = 268435456,
VIEW_BLACKLIST = 1073741824,
}
export interface PermissionCheckOptions {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
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 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;
await settings.save();
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/indexer/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieDetails,
TmdbMovieResult,
@@ -9,7 +9,7 @@ import type {
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/indexer/themoviedb/interfaces';
} from '@server/api/themoviedb/interfaces';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,

View File

@@ -1,11 +1,8 @@
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/promises';
import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
@@ -102,17 +99,6 @@ 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;
@@ -133,7 +119,6 @@ export interface MainSettings {
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
proxy: ProxySettings;
}
interface PublicSettings {
@@ -306,7 +291,6 @@ export interface AllSettings {
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
tvdb: boolean;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -341,16 +325,6 @@ 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: '',
@@ -372,7 +346,6 @@ class Settings {
apiKey: '',
},
tautulli: {},
tvdb: false,
radarr: [],
sonarr: [],
public: {
@@ -506,6 +479,10 @@ class Settings {
}
get main(): MainSettings {
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
this.save();
}
return this.data.main;
}
@@ -537,14 +514,6 @@ 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;
}
@@ -615,28 +584,42 @@ 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 async regenerateApiKey(): Promise<MainSettings> {
public regenerateApiKey(): MainSettings {
this.main.apiKey = this.generateApiKey();
await this.save();
this.save();
return this.main;
}
private generateApiKey(): string {
if (process.env.API_KEY) {
return process.env.API_KEY;
} else {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
}
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();
}
}
@@ -654,51 +637,24 @@ class Settings {
return this;
}
let data;
try {
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
} catch {
await this.save();
if (!fs.existsSync(SETTINGS_PATH)) {
this.save();
}
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
const parsedJson = JSON.parse(data);
const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
}
this.data = await runMigrations(parsedJson);
// 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();
}
this.data = merge(this.data, parsedJson);
this.save();
}
return this;
}
public async save(): Promise<void> {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
public save(): void {
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
}
}
@@ -712,13 +668,4 @@ 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,14 +1,15 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
if (settings.jellyfin?.hostname) {
const { hostname } = settings.jellyfin;
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
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 settings.jellyfin.hostname;
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
@@ -20,7 +21,9 @@ const migrateHostname = (settings: any): AllSettings => {
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};

View File

@@ -3,6 +3,7 @@ import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldMediaServerType = settings.main.mediaServerType;
console.log('Migrating media server type', oldMediaServerType);
if (
oldMediaServerType === MediaServerType.JELLYFIN &&
process.env.JELLYFIN_TYPE === 'emby'

View File

@@ -27,14 +27,8 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
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."
);
}
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
}
return settings;
};

View File

@@ -1,100 +1,30 @@
import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs/promises';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = async (
settings: AllSettings,
SETTINGS_PATH: string
settings: AllSettings
): 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) {
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());
migrated = await migration(migrated);
}
} 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,20 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklist1699901142442 implements MigrationInterface {
name = 'AddBlacklist1699901142442';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
}
}

View File

@@ -1,4 +1,4 @@
import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces';
import type { TmdbCollection } from '@server/api/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/indexer/themoviedb/interfaces';
} from '@server/api/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/indexer/themoviedb/interfaces';
} from '@server/api/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/indexer/themoviedb/interfaces';
} from '@server/api/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/indexer/themoviedb/interfaces';
} from '@server/api/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_count,
voteCount: episode.vote_cuont,
stillPath: episode.still_path,
});

View File

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

View File

@@ -14,6 +14,7 @@ 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();
@@ -87,7 +88,7 @@ authRoutes.post('/plex', async (req, res, next) => {
});
settings.main.mediaServerType = MediaServerType.PLEX;
await settings.save();
settings.save();
startJobs();
await userRepository.save(user);
@@ -260,6 +261,8 @@ 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 },
@@ -277,6 +280,11 @@ 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;
@@ -326,10 +334,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
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,
}),
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
@@ -340,10 +352,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
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,
}),
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
@@ -366,7 +382,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
await settings.save();
settings.save();
startJobs();
await userRepository.save(user);
@@ -389,7 +405,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name,
}
);
user.avatar = `/avatarproxy/${account.User.Id}`;
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else {
user.avatar = gravatarUrl(user.email || account.User.Name, {
default: 'mm',
size: 200,
});
}
user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) {
@@ -427,13 +451,17 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${account.User.Id}`,
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,
}),
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0;
if (passedExplicitPassword) {

View File

@@ -1,86 +0,0 @@
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();
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 {
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}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -1,148 +0,0 @@
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
export const blacklistAdd = z.object({
tmdbId: z.coerce.number(),
mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(),
user: z.coerce.number(),
});
blacklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user');
if (search.length > 0) {
query = query.where('blacklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
.take(pageSize)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(itemsCount / pageSize),
pageSize,
results: itemsCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);
} catch (error) {
logger.error('Something went wrong while retrieving blacklisted items', {
label: 'Blacklist',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Unable to retrieve blacklisted items.',
});
}
}
);
blacklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const values = blacklistAdd.parse(req.body);
await Blacklist.addToBlacklist({
blacklistRequest: values,
});
return res.status(201).send();
} catch (error) {
if (!(error instanceof Error)) {
return;
}
if (error instanceof QueryFailedError) {
switch (error.driverError.errno) {
case 19:
return next({ status: 412, message: 'Item already blacklisted' });
default:
logger.warn('Something wrong with data blacklist', {
tmdbId: req.body.tmdbId,
mediaType: req.body.mediaType,
label: 'Blacklist',
});
return next({ status: 409, message: 'Something wrong' });
}
}
return next({ status: 500, message: error.message });
}
}
);
blacklistRoutes.delete(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await blacklisteRepository.remove(blacklistItem);
const mediaRepository = getRepository(Media);
const mediaItem = await mediaRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await mediaRepository.remove(mediaItem);
return res.status(204).send();
} catch (e) {
if (e instanceof NotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
export default blacklistRoutes;

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/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';
@@ -71,7 +71,6 @@ const QueryFilterOptions = z.object({
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
status: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
@@ -386,7 +385,6 @@ discoverRoutes.get('/tv', async (req, res, next) => {
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
withStatus: query.status,
});
const media = await Media.getRelatedMedia(

View File

@@ -1,10 +1,10 @@
import GithubAPI from '@server/api/github';
import TheMovieDb from '@server/api/indexer/themoviedb';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/indexer/themoviedb/interfaces';
import PushoverAPI from '@server/api/pushover';
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
@@ -17,17 +17,12 @@ 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,
appDataPermissions,
appDataStatus,
} from '@server/utils/appDataVolume';
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
import blacklistRoutes from './blacklist';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
@@ -97,7 +92,6 @@ router.get('/status/appdata', (_req, res) => {
return res.status(200).json({
appData: appDataStatus(),
appDataPath: appDataPath(),
appDataPermissions: appDataPermissions(),
});
});
@@ -150,7 +144,6 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);

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/indexer/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import {

View File

@@ -8,7 +8,6 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -244,8 +243,6 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
case BlacklistedMediaError:
return next({ status: 403, message: error.message });
default:
return next({ status: 500, message: error.message });
}

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/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,13 +123,9 @@ 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 =
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({

View File

@@ -32,6 +32,7 @@ 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';
@@ -40,7 +41,6 @@ import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import tvdbRoutes from './tvdb';
const settingsRoutes = Router();
@@ -48,7 +48,6 @@ settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/tvdb', tvdbRoutes);
const filteredMainSettings = (
user: User,
@@ -71,19 +70,19 @@ settingsRoutes.get('/main', (req, res, next) => {
res.status(200).json(filteredMainSettings(req.user, settings.main));
});
settingsRoutes.post('/main', async (req, res) => {
settingsRoutes.post('/main', (req, res) => {
const settings = getSettings();
settings.main = merge(settings.main, req.body);
await settings.save();
settings.save();
return res.status(200).json(settings.main);
});
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
settingsRoutes.post('/main/regenerate', (req, res, next) => {
const settings = getSettings();
const main = await settings.regenerateApiKey();
const main = settings.regenerateApiKey();
if (!req.user) {
return next({ status: 500, message: 'User missing from request.' });
@@ -120,7 +119,7 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settings.plex.machineId = result.MediaContainer.machineIdentifier;
settings.plex.name = result.MediaContainer.friendlyName;
await settings.save();
settings.save();
} catch (e) {
logger.error('Something went wrong testing Plex connection', {
label: 'API',
@@ -233,7 +232,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library,
enabled: enabledLibraries.includes(library.id),
}));
await settings.save();
settings.save();
return res.status(200).json(settings.plex.libraries);
});
@@ -284,7 +283,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
await settings.save();
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
@@ -372,12 +371,17 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library,
enabled: enabledLibraries.includes(library.id),
}));
await settings.save();
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({
@@ -396,7 +400,9 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const users = resp.users.map((user) => ({
username: user.Name,
id: user.Id,
thumb: `/avatarproxy/${user.Id}`,
thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name,
}));
@@ -436,7 +442,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported');
}
await settings.save();
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
@@ -697,7 +703,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule',
async (req, res, next) => {
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId
);
@@ -711,7 +717,7 @@ settingsRoutes.post<{ jobId: JobId }>(
if (result) {
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
await settings.save();
settings.save();
scheduledJob.cronSchedule = req.body.schedule;
@@ -740,13 +746,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
});
});
@@ -768,11 +772,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post(
'/initialize',
isAuthenticated(Permission.ADMIN),
async (_req, res) => {
(_req, res) => {
const settings = getSettings();
settings.public.initialized = true;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/discord', (req, res) => {
const settings = getSettings();
settings.notifications.agents.discord = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/slack', (req, res) => {
const settings = getSettings();
settings.notifications.agents.slack = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/telegram', (req, res) => {
const settings = getSettings();
settings.notifications.agents.telegram = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/pushbullet', (req, res) => {
const settings = getSettings();
settings.notifications.agents.pushbullet = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/pushover', (req, res) => {
const settings = getSettings();
settings.notifications.agents.pushover = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/email', (req, res) => {
const settings = getSettings();
settings.notifications.agents.email = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/webpush', (req, res) => {
const settings = getSettings();
settings.notifications.agents.webpush = req.body;
await settings.save();
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', async (req, res, next) => {
notificationRoutes.post('/webhook', (req, res, next) => {
const settings = getSettings();
try {
JSON.parse(req.body.options.jsonPayload);
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
authHeader: req.body.options.authHeader,
},
};
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/lunasea', (req, res) => {
const settings = getSettings();
settings.notifications.agents.lunasea = req.body;
await settings.save();
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', async (req, res) => {
notificationRoutes.post('/gotify', (req, res) => {
const settings = getSettings();
settings.notifications.agents.gotify = req.body;
await settings.save();
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('/', async (req, res) => {
radarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
@@ -31,7 +31,7 @@ radarrRoutes.post('/', async (req, res) => {
}
settings.radarr = [...settings.radarr, newRadarr];
await settings.save();
settings.save();
return res.status(201).json(newRadarr);
});
@@ -76,7 +76,7 @@ radarrRoutes.post<
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id',
async (req, res, next) => {
(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;
await settings.save();
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', async (req, res, next) => {
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
}
const removed = settings.radarr.splice(radarrIndex, 1);
await settings.save();
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('/', async (req, res) => {
sonarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
@@ -31,7 +31,7 @@ sonarrRoutes.post('/', async (req, res) => {
}
settings.sonarr = [...settings.sonarr, newSonarr];
await settings.save();
settings.save();
return res.status(201).json(newSonarr);
});
@@ -43,14 +43,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const systemStatus = await sonarr.getSystemStatus();
const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]);
const urlBase = systemStatus.urlBase;
const urlBase = await sonarr
.getSystemStatus()
.then((value) => value.urlBase)
.catch(() => req.body.baseUrl);
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles =
sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null;
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
@@ -73,7 +72,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
}
});
sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
@@ -101,12 +100,12 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
await settings.save();
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
@@ -120,7 +119,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
await settings.save();
settings.save();
return res.status(200).json(removed[0]);
});

View File

@@ -1,48 +0,0 @@
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,10 +1,9 @@
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';
@@ -13,10 +12,9 @@ import { Router } from 'express';
const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const indexer = getIndexer();
const tmdb = new TheMovieDb();
try {
const tv = await indexer.getTvShow({
const tv = await tmdb.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
@@ -36,9 +34,7 @@ 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 indexer.getTvShow({
tvId: Number(req.params.id),
});
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
@@ -57,12 +53,13 @@ tvRoutes.get('/:id', async (req, res, next) => {
});
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
try {
const indexer = getIndexer();
const tmdb = new TheMovieDb();
const season = await indexer.getTvSeason({
try {
const season = await tmdb.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

@@ -2,7 +2,6 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -516,6 +515,12 @@ 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();
@@ -539,11 +544,13 @@ router.post(
).toString('base64'),
email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${jellyfinUser?.Id}`,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN,
});
await userRepository.save(newUser);

View File

@@ -1,4 +1,3 @@
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
@@ -10,7 +9,6 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { Router } from 'express';
import { canMakePermissionsChange } from '.';
@@ -100,18 +98,10 @@ userSettingsRoutes.post<
}
user.username = req.body.username;
const oldEmail = user.email;
if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email;
}
const existingUser = await userRepository.findOne({
where: { email: user.email },
});
if (oldEmail !== user.email && existingUser) {
throw new ApiError(400, ApiErrorCode.InvalidEmail);
}
// Update quota values only if the user has the correct permissions
if (
!user.hasPermission(Permission.MANAGE_USERS) &&
@@ -155,14 +145,7 @@ userSettingsRoutes.post<
email: savedUser.email,
});
} catch (e) {
if (e.errorCode) {
return next({
status: e.statusCode,
message: e.errorCode,
});
} else {
return next({ status: 500, message: e.message });
}
next({ status: 500, message: e.message });
}
});

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/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/indexer/themoviedb';
import TheMovieDb from '@server/api/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/indexer/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import {
MediaRequestStatus,
MediaStatus,

View File

@@ -1,4 +1,4 @@
import { accessSync, existsSync } from 'fs';
import { existsSync } from 'fs';
import path from 'path';
const CONFIG_PATH = process.env.CONFIG_DIRECTORY
@@ -14,12 +14,3 @@ 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

@@ -1,111 +0,0 @@
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,8 +13,7 @@ class RestartFlag {
return (
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy ||
this.settings.proxy.enabled !== settings.proxy.enabled
this.settings.trustProxy !== settings.trustProxy
);
}
}

View File

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

View File

@@ -1,420 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import type {
BlacklistItem,
BlacklistResultsResponse,
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Blacklist', {
blacklistsettings: 'Blacklist Settings',
blacklistSettingsDescription: 'Manage blacklisted media.',
mediaName: 'Name',
mediaType: 'Type',
mediaTmdbId: 'tmdb Id',
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const router = useRouter();
const intl = useIntl();
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const {
data,
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}
&skip=${pageIndex * currentPageSize}
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
// check if there's no data and no errors in the table
// so as to show a spinner inside the table and not refresh the whole component
if (!data && error) {
return <Error statusCode={500} />;
}
const searchItem = (e: ChangeEvent<HTMLInputElement>) => {
// Remove the "page" query param from the URL
// so that the "skip" query param on line 62 is empty
// and the search returns results without skipping items
if (router.query.page) router.replace(router.basePath);
setSearchFilter(e.target.value as string);
};
const hasNextPage = data && data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
{!data ? (
<LoadingSpinner />
) : data.results.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center py-24 text-white">
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
</div>
) : (
data.results.map((item: BlacklistItem) => {
return (
<div className="py-2" key={`request-list-${item.tmdbId}`}>
<BlacklistedItem item={item} revalidateList={revalidate} />
</div>
);
})
)}
<div className="actions">
<nav
className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data &&
(data?.results.length ?? 0) > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: (msg: React.ReactNode) => (
<span className="font-medium">{msg}</span>
),
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="-mt-3 items-center truncate text-sm sm:mt-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="short inline"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};
export default Blacklist;
interface BlacklistedItemProps {
item: BlacklistItem;
revalidateList: () => void;
}
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { addToast } = useToasts();
const { ref, inView } = useInView({
triggerOnce: true,
});
const intl = useIntl();
const { hasPermission } = useUser();
const url =
item.mediaType === 'movie'
? `/api/v1/movie/${item.tmdbId}`
: `/api/v1/tv/${item.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
if (!title && !error) {
return (
<div
className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
ref={ref}
/>
);
}
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
revalidateList();
setIsUpdating(false);
};
return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{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' }}
fill
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
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}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600}
height={900}
/>
</Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{title &&
(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
>
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{title && (isMovie(title) ? title.title : title.name)}
</span>
</Link>
</div>
</div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
</div>
{item.createdAt && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.blacklisted)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.blacklistedby, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(item.createdAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<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"
width={20}
height={20}
style={{ objectFit: 'cover' }}
/>
<span className="ml-1 truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{item.user.displayName}
</span>
</span>
</Link>
),
})}
</span>
</div>
)}
<div className="card-field">
{item.mediaType === 'movie' ? (
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.movie)}
</div>
</div>
) : (
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
)}
</div>
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{hasPermission(Permission.MANAGE_BLACKLIST) && (
<ConfirmButton
onClick={() =>
removeFromBlacklist(
item.tmdbId,
title && (isMovie(title) ? title.title : title.name)
)
}
confirmText={intl.formatMessage(
isUpdating ? globalMessages.deleting : globalMessages.areyousure
)}
className={`w-full ${
isUpdating ? 'pointer-events-none opacity-50' : ''
}`}
>
<TrashIcon />
<span>
{intl.formatMessage(globalMessages.removefromBlacklist)}
</span>
</ConfirmButton>
)}
</div>
</div>
);
};

View File

@@ -1,129 +0,0 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
blacklistdate: 'Blacklisted date',
});
interface BlacklistBlockProps {
blacklistItem: Blacklist;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
blacklistItem,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
const { user } = useUser();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
onUpdate && onUpdate();
onDelete && onDelete();
setIsUpdating(false);
};
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
blacklistItem.user.id === user?.id
? '/profile'
: `/users/${blacklistItem.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{blacklistItem.user.displayName}
</span>
</Link>
</span>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">
<Tooltip
content={intl.formatMessage(globalMessages.removefromBlacklist)}
>
<Button
buttonType="danger"
onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
</Button>
</Tooltip>
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="mr-6 flex items-center text-sm leading-5">
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(blacklistItem.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
</div>
);
};
export default BlacklistBlock;

View File

@@ -1,79 +0,0 @@
import Modal from '@app/components/Common/Modal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
interface BlacklistModalProps {
tmdbId: number;
type: 'movie' | 'tv' | 'collection';
show: boolean;
onComplete?: () => void;
onCancel?: () => void;
isUpdating?: boolean;
}
const messages = defineMessages('component.BlacklistModal', {
blacklisting: 'Blacklisting',
});
const isMovie = (
movie: MovieDetails | TvDetails | undefined
): movie is MovieDetails => {
if (!movie) return false;
return (movie as MovieDetails).title !== undefined;
};
const BlacklistModal = ({
tmdbId,
type,
show,
onComplete,
onCancel,
isUpdating,
}: BlacklistModalProps) => {
const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>(
`/api/v1/${type}/${tmdbId}`
);
return (
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
loading={!data && !error}
backgroundClickable
title={`${intl.formatMessage(globalMessages.blacklist)} ${
isMovie(data)
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)
}`}
subTitle={`${isMovie(data) ? data.title : data?.name}`}
onCancel={onCancel}
onOk={onComplete}
okText={
isUpdating
? intl.formatMessage(messages.blacklisting)
: intl.formatMessage(globalMessages.blacklist)
}
okButtonType="danger"
okDisabled={isUpdating}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
/>
</Transition>
);
};
export default BlacklistModal;

View File

@@ -183,11 +183,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
);
}
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<div
className="media-page"
@@ -198,7 +193,6 @@ 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' }}
@@ -229,7 +223,6 @@ 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}`
@@ -342,26 +335,20 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
sliderKey="collection-movies"
isLoading={false}
isEmpty={data.parts.length === 0}
items={data.parts
.filter((title) => {
if (!blacklistVisibility)
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
return title;
})
.map((title) => (
<TitleCard
key={`collection-movie-${title.id}`}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
/>
))}
items={data.parts.map((title) => (
<TitleCard
key={`collection-movie-${title.id}`}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
/>
))}
/>
<div className="extra-bottom-space relative" />
</div>

View File

@@ -4,31 +4,21 @@ 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, type, ...props }: CachedImageProps) => {
const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings();
let imageUrl: string;
let imageUrl = src;
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;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;

View File

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

View File

@@ -1,10 +1,8 @@
import PersonCard from '@app/components/PersonCard';
import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
CollectionResult,
@@ -34,14 +32,7 @@ const ListView = ({
mutateParent,
}: ListViewProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
const blacklistVisibility = hasPermission(
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
{ type: 'or' }
);
return (
<>
{isEmpty && (
@@ -64,89 +55,76 @@ const ListView = ({
</li>
);
})}
{items
?.filter((title) => {
if (!blacklistVisibility)
return (
(title as TvResult | MovieResult).mediaInfo?.status !==
MediaStatus.BLACKLISTED
{items?.map((title, index) => {
let titleCard: React.ReactNode;
switch (title.mediaType) {
case 'movie':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
return title;
})
.map((title, index) => {
let titleCard: React.ReactNode;
break;
case 'tv':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard
personId={title.id}
name={title.name}
profilePath={title.profilePath}
canExpand
/>
);
break;
}
switch (title.mediaType) {
case 'movie':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length ?? 0
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'tv':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length ?? 0
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard
personId={title.id}
name={title.name}
profilePath={title.profilePath}
canExpand
/>
);
break;
}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{isLoading &&
!isReachingEnd &&
[...Array(20)].map((_item, i) => (

View File

@@ -123,7 +123,6 @@ 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

@@ -1,11 +1,6 @@
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import {
BellIcon,
ClockIcon,
EyeSlashIcon,
MinusSmallIcon,
} from '@heroicons/react/24/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
@@ -49,10 +44,6 @@ const StatusBadgeMini = ({
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.BLACKLISTED:
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
indicatorIcon = <EyeSlashIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'

View File

@@ -33,7 +33,6 @@ 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/indexer/themoviedb/interfaces';
} from '@server/api/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/indexer/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/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/indexer/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/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/indexer/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/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/indexer/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

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