Compare commits

...

25 Commits

Author SHA1 Message Date
fallenbagel
1955db5c35 docs: add documentation for unix socket postgres connection 2025-01-12 12:13:07 +08:00
fallenbagel
ee803b601c refactor: adds socket_path for unix socket support for postgres 2025-01-12 12:12:23 +08:00
Fallenbagel
99e2e17b8a style(overriderule): optimises the override rules tile for mobile (#1246) 2025-01-12 10:02:59 +08:00
Fallenbagel
0f14cd9247 style(logs): apply break-words for appDataPath (#1247)
This should make sure that the ui doesnt break for users who has long appDataPaths
2025-01-12 09:57:00 +08:00
Fallenbagel
50db3ea27b refactor: resolve missing dependency warning for validateLibraries in useEffect (#1245) 2025-01-12 09:52:13 +08:00
Fallenbagel
131a5a2b0b fix: resolve plex user mismatch due to caching issues (#1242)
* fix: resolve plex user mismatch due to caching issues

This commit addresses an issue where cached responses for PlexTV API
requests could return incorrect user data when multiple users were logged in.
This was caused by a shared cache key that did not account for differences
in auth tokens, i.e `X-Plex-Token`. The `serializeCacheKey` method should
now include headers in the cache key generation to ensure unique cache keys.
This should fix the plex user mismatch that occurred due to the same
cache key being used without accounting for the difference in auth
token.

fix #1227

* fix: adds the default params and optional params, add the headers in all methods

* refactor: apply review commits and rename params to options in cachekey
2025-01-12 09:51:41 +08:00
Wunderharke
7d08f58c76 Fix missing paramenter for PUT /overrideRule/{ruleId} in overseerr-api.yml (#1244)
I added a missing parameter definition to PUT /overrideRule/{ruleId}.
2025-01-11 15:32:46 +01:00
Fallenbagel
425f0c854e docs: remove pgloader volume from db config
this is not needed as we are using the command directly anyways
2025-01-10 19:37:12 +08:00
Fallenbagel
b8dbfaaed0 fix(setup): plex library setting validation (#1233)
* fix(setup): plex library setting validation

* fix(setup): fixes plex continue button and scroll-to-top when clicked issue in setup
2025-01-10 00:05:13 +01:00
Fallenbagel
24c6208a3c chore: update nodejs to 22 in an attempt to fix undici errors (#1229)
* chore: update nodejs to 22 in an attempt to fix undici errors

This is an attempt to fix the undici errors introduced after the switch
from axios to native fetch. The decision was made as it native fetch on
node 20 seems to be "experimental" and
> since native fetch is no longer experimental since Node 21

* chore: increase the required node version

* build: update nodejs version to 22

* chore: update nodejs version to 22

* chore: update @types/node to v22

* chore(gen-docs): update the gen-docs node engine requirement to 22
2025-01-09 18:24:38 +08:00
Gauthier
d71ee58302 fix: specify cached image type (#1237) 2025-01-08 18:53:14 +01:00
Joaquin Olivero
1755877e66 refactor: replace streaming providers' names with their equivalent logos (#932)
* refactor: replace streaming providers' name with their corresponding logo

re #919

* refactor: change default image opacity and the on hover opacity as well to match the overall ui/ux

---------

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2025-01-08 18:23:30 +01:00
Fallenbagel
f84d752bca docs: add in missing part in windows docker 2025-01-05 23:45:58 +08:00
Fallenbagel
0b331ca579 fix(setup): fix continue button disabled on refresh in setup 3 (#1211)
This commit resolves an issue where the continue button in setup step 3 remained disabled after a
page refresh even when libraries are toggled. This was happening because
`mediaServerSettingsComplete` state was reset on refresh and not correctly re-initialized.
2025-01-03 12:22:16 +01:00
Fallenbagel
656cd91c9c fix: optimize media status update to avoid lifecycle hook triggers (#1218)
This change optimises the media updates to avoid unneccessary lifecycle hook executions which
results in potential recursion for POSTGRESQL compatibility. This should prevent an issue where
after a TV request, the tv request would get sent to sonarr and notification for it would get sent
over and over and over again
2025-01-03 12:14:39 +01:00
Fallenbagel
81d7473c05 docs: make it clear 2025-01-03 01:07:28 +08:00
Gauthier
f718cec23f fix(externalapi): clear cache after a request is made (#1217)
This PR clears the Radarr/Sonarr cache after a request has been made, because the media status on
Radarr/Sonarr will no longer be good. It also resolves a bug that prevented the media from being
deleted after a request had been sent to Radarr/Sonarr.

fix #1207
2025-01-02 16:44:46 +01:00
Gauthier
ac908026db fix(jellyfinlogin): add proper error message when no admin user exists (#1216)
This PR adds an error message when the database has no admin user and Jellyseerr has already been
set up (i.e. settings.json is filled in), instead of having a generic error message.
2025-01-02 16:03:45 +01:00
Gauthier
d67ec571c5 fix: prevent TypeORM subscribers from calling itself over and over (#1215)
When a series is requested, an event is triggered by TypeORM after the request status has been
updated. The function executed by this event updated the request status to "PROCESSING", even if the
request already had this status. This triggered the same function once again, which repeated the
update, in an endless loop.
2025-01-02 15:46:57 +01:00
Fallenbagel
f3ebf6028b fix(users): correct request count query for PostgreSQL compatibility (#1213)
The request count subquery was causing issues with some PostgreSQL
configurations due to case sensitivity in column aliases. Modified the
query to use an explicit subquery with a properly named alias to ensure
consistent behavior across different database setups.
2025-01-01 19:18:36 +01:00
Fallenbagel
465d42dd60 style(request-list): consistent styling of sort button with the rest (#1212) 2025-01-01 19:17:23 +01:00
Gauthier
2f0e493257 fix(ui): resolve streaming region dropdown overlap (#1210)
fix #1206
2024-12-31 17:08:14 +01:00
Gauthier
ebe7d11a53 fix: correct typos for the special episodes setting (#1209)
Some typos were introduced by #1193, enableSpecialEpisodes and partialRequestsEnabled were mixed up.

fix #1208
2024-12-31 14:15:10 +01:00
Gauthier
7e94ad7210 fix(usersettings): fix the streaming region setting toggling itself (#1203)
When the streaming region is set to another value than the default one, the setting starts toggling
itself from the default value to the new value and vice-versa constantly

fix #1200
2024-12-30 21:45:51 +08:00
Fallenbagel
814a7357c0 fix: properly fetch sonarr/radarr specific override rules (#1199)
* fix: properly fetch sonarr/radarr specific override rules and fix its application

- This will fetch the proper sonarr/radarr specific override rule to apply.
- This will skip override rules for anime TV shows unless the `overrideRule`
explicitly includes the anime keyword.
- Apply the most specific override rule first (e.g., rules with multiple
conditions like `genre`, `language`, and `keywords`)
- Debug logs to for override rules

* fix(overriderules): apply overrides to "auto_approve" permission users but not "advaned_request"

This decision is done because it makes no sense to give advanced request users who gets to choose
what values to choose but then the minute they request, it gets overridden, rendering the whole
modal completely useless. In addition, admin/manage_request permission users who modify requests,
the minute they modify it will get overridden as well so it makes no sense to override their
requests

* fix: use default service instance for override rules

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-12-30 20:14:29 +08:00
37 changed files with 2052 additions and 653 deletions

View File

@@ -13,7 +13,7 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
container: node:20-alpine
container: node:22-alpine
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -17,7 +17,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 20.x)
- [NodeJS](https://nodejs.org/en/download/) (Node 22.x)
- [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads)

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS BUILD_IMAGE
FROM node:22-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -36,7 +36,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:20-alpine
FROM node:22-alpine
# Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
COPY . /app
WORKDIR /app

View File

@@ -15,7 +15,7 @@
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library.
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring additional support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
## Current Features

View File

@@ -9,6 +9,8 @@ Jellyseerr supports SQLite and PostgreSQL. The database connection can be config
## SQLite Options
If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used.
```dotenv
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
@@ -17,17 +19,35 @@ DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging.
## PostgreSQL Options
### TCP Connection
If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port.
```dotenv
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite". To use postgres, this needs to be set to "postgres"
DB_HOST="localhost" # (optional) The host (url) of the database. The default is "localhost".
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
DB_HOST="localhost" # (optional) The host (URL) of the database. The default is "localhost".
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
DB_USER= # (required) Username used to connect to the database
DB_PASS= # (required) Password of the user used to connect to the database
DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (required) Password of the user used to connect to the database.
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
### Unix Socket Connection
If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket.
```dotenv
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory.
DB_USER= # (required) Username used to connect to the database.
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
### SSL configuration
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
```dotenv
@@ -40,6 +60,7 @@ DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM f
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
```
---
### Migrating from SQLite to PostgreSQL
@@ -48,7 +69,7 @@ DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the p
3. Stop Jellyseerr
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
:::info
Edit the postgres connection string to match your setup
Edit the postgres connection string to match your setup.
If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
:::
@@ -56,6 +77,6 @@ If you don't have or don't want to use docker, you can build the working pgloade
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
:::
```bash
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
```
5. Start Jellyseerr

View File

@@ -6,13 +6,15 @@ sidebar_position: 2
# Build from Source (Advanced)
:::warning
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
:::
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### Prerequisites
- [Node.js 20.x](https://nodejs.org/en/download/)
- [Node.js 22.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)

View File

@@ -7,6 +7,8 @@ sidebar_position: 1
:::info
This is the recommended method for most users.
Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/).
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
:::
## Unix (Linux, macOS)
@@ -145,6 +147,16 @@ Then, create and start the Jellyseerr container:
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
@@ -167,6 +179,16 @@ services:
volumes:
jellyseerr-data:
external: true
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
</Tabs>
@@ -185,3 +207,6 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
:::

View File

@@ -6,6 +6,8 @@ sidebar_position: 3
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
import Admonition from '@theme/Admonition';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Nix Package Manager (Advanced)
:::info
@@ -13,22 +15,55 @@ This method is not recommended for most users. It is intended for advanced users
:::
export const VersionMismatchWarning = () => {
const jellyseerrVersion = JellyseerrVersion();
const nixpkgVersion = NixpkgVersion();
let jellyseerrVersion = null;
let nixpkgVersions = null;
try {
jellyseerrVersion = JellyseerrVersion();
nixpkgVersions = NixpkgVersion();
} catch (err) {
return (
<Admonition type="error">
Failed to load version information. Error: {err.message || JSON.stringify(err)}
</Admonition>
);
}
const isUpToDate = jellyseerrVersion === nixpkgVersion;
if (!nixpkgVersions || nixpkgVersions.error) {
return (
<Admonition type="error">
Failed to fetch Nixpkg versions: {nixpkgVersions?.error || 'Unknown error'}
</Admonition>
);
}
const isUnstableUpToDate = jellyseerrVersion === nixpkgVersions.unstable;
const isStableUpToDate = jellyseerrVersion === nixpkgVersions.stable;
return (
<>
{!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>.
</Admonition>
) : (
<Admonition type="success">
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 <b>up-to-date</b> with <b>Jellyseerr v{jellyseerrVersion}</b>.
</Admonition>
)}
<>
{!isStableUpToDate ? (
<Admonition type="warning">
The{' '}
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-24.11/pkgs/servers/jellyseerr/default.nix#L14">
upstream Jellyseerr Nix Package (v{nixpkgVersions.stable})
</a>{' '}
is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>,{' '}
{isUnstableUpToDate ? (
<>
consider using the{' '}
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix">
unstable package
</a>{' '}
instead.
</>
) : (
<>
you will need to{' '}
<a href="#overriding-the-package-derivation">override the package derivation</a>.
</>
)}
</Admonition>
) : null}
</>
);
};
@@ -48,6 +83,8 @@ To get up and running with jellyseerr using Nix, you can add the following to yo
If you want more advanced configuration options, you can use the following:
<Tabs groupId="nixpkg-methods" queryString>
<TabItem value="default" label="Default Configurations">
```nix
{ config, pkgs, ... }:
@@ -56,53 +93,20 @@ If you want more advanced configuration options, you can use the following:
enable = true;
port = 5055;
openFirewall = true;
package = pkgs.jellyseerr; # Use the unstable package if stable is not up-to-date
};
}
```
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
```bash
nixos-rebuild switch
```
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
```bash
systemctl status jellyseerr
```
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
:::
import CodeBlock from '@theme/CodeBlock';
## Overriding the package derivation
export const VersionMatch = () => {
const jellyseerrVersion = JellyseerrVersion();
const nixpkgVersion = NixpkgVersion();
const code = `{ config, pkgs, ... }:
</TabItem>
<TabItem value="custom" label="Database Configurations">
In order to use postgres, you will need to add override the default module of jellyseerr with the following as the current default module is not compatible with postgres:
```nix
{
nixpkgs.config.packageOverrides = pkgs: {
jellyseerr = pkgs.jellyseerr.overrideAttrs (oldAttrs: rec {
version = "${jellyseerrVersion}";
src = pkgs.fetchFromGitHub {
rev = "v\${version}";
sha256 = pkgs.lib.fakeSha256;
};
offlineCache = pkgs.fetchYarnDeps {
sha256 = pkgs.lib.fakeSha256;
};
});
};
}`;
const module = `{ config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
with lib;
let
cfg = config.services.jellyseerr;
@@ -113,28 +117,65 @@ in
disabledModules = [ "services/misc/jellyseerr.nix" ];
options.services.jellyseerr = {
enable = mkEnableOption (mdDoc ''Jellyseerr, a requests manager for Jellyfin'');
enable = mkEnableOption ''Jellyseerr, a requests manager for Jellyfin'';
openFirewall = mkOption {
type = types.bool;
default = false;
description = mdDoc ''Open port in the firewall for the Jellyseerr web interface.'';
description = ''Open port in the firewall for the Jellyseerr web interface.'';
};
port = mkOption {
type = types.port;
default = 5055;
description = mdDoc ''The port which the Jellyseerr web UI should listen to.'';
description = ''The port which the Jellyseerr web UI should listen to.'';
};
package = mkOption {
type = types.package;
default = pkgs.jellyseerr;
defaultText = literalExpression "pkgs.jellyseerr";
description = lib.mdDoc ''
Jellyseerr package to use.
'';
type = types.package;
default = pkgs.jellyseerr;
defaultText = literalExpression "pkgs.jellyseerr";
description = ''
Jellyseerr package to use.
'';
};
databaseConfig = mkOption {
type = types.attrsOf types.str;
default = {
type = "sqlite";
configDirectory = "config";
logQueries = "false";
};
description = ''
Database configuration. For "sqlite", only "type", "configDirectory", and "logQueries" are relevant.
For "postgres", include host, port, user, pass, name, and optionally socket.
Example:
{
type = "postgres";
socket = "/run/postgresql";
user = "jellyseerr";
name = "jellyseerr";
logQueries = "false";
}
or
{
type = "postgres";
host = "localhost";
port = "5432";
user = "dbuser";
pass = "password";
name = "jellyseerr";
logQueries = "false";
}
or
{
type = "sqlite";
configDirectory = "config";
logQueries = "false";
}
'';
};
};
config = mkIf cfg.enable {
@@ -142,14 +183,29 @@ in
description = "Jellyseerr, a requests manager for Jellyfin";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment.PORT = toString cfg.port;
environment =
let
dbConfig = cfg.databaseConfig;
in
{
PORT = toString cfg.port;
DB_TYPE = toString dbConfig.type;
CONFIG_DIRECTORY = toString dbConfig.configDirectory or "";
DB_LOG_QUERIES = toString dbConfig.logQueries;
DB_HOST = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.host or "" else "";
DB_PORT = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.port or "" else "";
DB_SOCKET_PATH = if dbConfig.type == "postgres" && hasAttr "socket" dbConfig then toString dbConfig.socket or "" else "";
DB_USER = if dbConfig.type == "postgres" then toString dbConfig.user or "" else "";
DB_PASS = if dbConfig.type == "postgres" then toString dbConfig.pass or "" else "";
DB_NAME = if dbConfig.type == "postgres" then toString dbConfig.name or "" else "";
};
serviceConfig = {
Type = "exec";
StateDirectory = "jellyseerr";
WorkingDirectory = "\${cfg.package}/libexec/jellyseerr/deps/jellyseerr";
WorkingDirectory = "${cfg.package}/libexec/jellyseerr";
DynamicUser = true;
ExecStart = "\${cfg.package}/bin/jellyseerr";
BindPaths = [ "/var/lib/jellyseerr/:\${cfg.package}/libexec/jellyseerr/deps/jellyseerr/config/" ];
ExecStart = "${cfg.package}/bin/jellyseerr";
BindPaths = [ "/var/lib/jellyseerr/:${cfg.package}/libexec/jellyseerr/config/" ];
Restart = "on-failure";
ProtectHome = true;
ProtectSystem = "strict";
@@ -169,57 +225,47 @@ in
};
};
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; };
};
}`;
const configuration = `{ config, pkgs, ... }:
}
```
Then, import the module into your `configuration.nix`:
```nix
{ config, pkgs, ... }:
{
imports = [ ./jellyseerr-module.nix ]
imports = [ ./modules/jellyseerr.nix ];
services.jellyseerr = {
enable = true;
port = 5055;
openFirewall = true;
package = (pkgs.callPackage (import ../../../pkgs/jellyseerr) { });
services.jellyseerr = {
enable = true;
port = 5055;
openFirewall = true;
package = pkgs.unstable.jellyseerr; # use the unstable package if stable is not up-to-date
databaseConfig = {
type = "postgres";
host = "localhost"; # or socket: "/run/postgresql"
port = "5432"; # if using socket, this is not needed
user = "jellyseerr";
pass = "jellyseerr";
name = "jellyseerr";
logQueries = "false";
};
}`;
}
}
```
</TabItem>
</Tabs>
const isUpToDate = jellyseerrVersion === nixpkgVersion;
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
return (
<>
{isUpToDate ? (
<>
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg package version <strong>({nixpkgVersion})</strong> is <strong>up-to-date</strong>.</p>
<p>There is no need to override the package derivation.</p>
</>
) : (
<>
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg version <strong>(v{nixpkgVersion})</strong> is <strong>out-of-date</strong>.
If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to override the package derivation.</p>
<p>In order to override the package derivation:</p>
<ol>
<li style={{ marginBottom: '1rem' }}>Grab the <a href="https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix">latest nixpkg derivation for Jellyseerr</a></li>
<li style={{ marginBottom: '1rem' }}>Grab the latest <a href="https://raw.githubusercontent.com/Fallenbagel/jellyseerr/main/package.json">package.json</a> for Jellyseerr</li>
<li style={{ marginBottom: '1rem' }}>Add it to the same directory as the nixpkg derivation</li>
<li style={{ marginBottom: '1rem' }}>Update the `src` and `offlineCache` attributes in the nixpkg derivation:</li>
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{code}</CodeBlock>
<Admonition type="tip" style={{ marginBottom: '1rem' }}>You can replace the <b>sha256</b> with the actual hash that <b>nixos-rebuild</b> outputs when you run the command.</Admonition>
<li style={{ marginBottom: '1rem' }}>Grab this module and import it in your `configuration.nix`</li>
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{module}</CodeBlock>
<Admonition type="tip" style={{ marginBottom: '1rem' }}>We are using a custom module because the upstream module does not have a package option.</Admonition>
<li style={{ marginBottom: '1rem' }}>Call the new package in your `configuration.nix`</li>
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{configuration}</CodeBlock>
</ol>
</>
)}
</>
);
```bash
nixos-rebuild switch
```
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
```bash
systemctl status jellyseerr
```
};
<VersionMatch />
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
:::

View File

@@ -47,6 +47,6 @@
]
},
"engines": {
"node": ">=18.0"
"node": ">=22.0"
}
}

View File

@@ -26,25 +26,37 @@ export const JellyseerrVersion = () => {
};
export const NixpkgVersion = () => {
const [version, setVersion] = useState(null);
const [versions, setVersions] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchVersion = async () => {
try {
const url =
'https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix';
const response = await fetch(url);
const data = await response.text();
const unstableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix';
const stableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-24.11/pkgs/servers/jellyseerr/default.nix';
const [unstableResponse, stableResponse] = await Promise.all([
fetch(unstableUrl),
fetch(stableUrl),
]);
const unstableData = await unstableResponse.text();
const stableData = await stableResponse.text();
const versionRegex = /version\s*=\s*"([^"]+)"/;
const match = data.match(versionRegex);
if (match && match[1]) {
setVersion(match[1]);
} else {
setError('0.0.0');
}
const unstableMatch = unstableData.match(versionRegex);
const stableMatch = stableData.match(versionRegex);
const unstableVersion =
unstableMatch && unstableMatch[1] ? unstableMatch[1] : '0.0.0';
const stableVersion =
stableMatch && stableMatch[1] ? stableMatch[1] : '0.0.0';
setVersions({ unstable: unstableVersion, stable: stableVersion });
setLoading(false);
} catch (err) {
setError(err.message);
@@ -63,5 +75,5 @@ export const NixpkgVersion = () => {
return { error };
}
return version;
return versions;
};

View File

@@ -7018,6 +7018,12 @@ paths:
description: Updates an Override Rule from the request body.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: 'Values were successfully updated'

View File

@@ -123,7 +123,7 @@
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node": "22.10.5",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
@@ -169,7 +169,7 @@
"typescript": "4.9.5"
},
"engines": {
"node": "^20.0.0",
"node": "^22.0.0",
"pnpm": "^9.0.0"
},
"overrides": {

1817
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,10 +69,13 @@ class ExternalAPI {
ttl?: number,
config?: RequestInit
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
@@ -81,10 +84,7 @@ class ExternalAPI {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
headers,
});
if (!response.ok) {
const text = await response.text();
@@ -111,10 +111,13 @@ class ExternalAPI {
ttl?: number,
config?: RequestInit
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
headers,
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
@@ -124,10 +127,7 @@ class ExternalAPI {
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
headers,
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
@@ -155,10 +155,13 @@ class ExternalAPI {
ttl?: number,
config?: RequestInit
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
@@ -168,10 +171,7 @@ class ExternalAPI {
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
@@ -227,9 +227,11 @@ class ExternalAPI {
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -244,10 +246,7 @@ class ExternalAPI {
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
headers,
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
@@ -270,10 +269,7 @@ class ExternalAPI {
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
headers,
});
if (!response.ok) {
const text = await response.text();
@@ -293,6 +289,14 @@ class ExternalAPI {
return data;
}
protected removeCache(endpoint: string, options?: Record<string, string>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...options,
});
this.cache?.del(cacheKey);
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
@@ -317,13 +321,13 @@ class ExternalAPI {
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
options?: Record<string, unknown>
) {
if (!params) {
if (!options) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
}
private async getDataFromResponse(response: Response) {

View File

@@ -230,6 +230,23 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
}
};
public clearCache = ({
tmdbId,
externalId,
}: {
tmdbId?: number | null;
externalId?: number | null;
}) => {
if (tmdbId) {
this.removeCache('/movie/lookup', {
term: `tmdb:${tmdbId}`,
});
}
if (externalId) {
this.removeCache(`/movie/${externalId}`);
}
};
}
export default RadarrAPI;

View File

@@ -353,6 +353,30 @@ class SonarrAPI extends ServarrBase<{
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
}
};
public clearCache = ({
tvdbId,
externalId,
title,
}: {
tvdbId?: number | null;
externalId?: number | null;
title?: string | null;
}) => {
if (tvdbId) {
this.removeCache('/series/lookup', {
term: `tvdb:${tvdbId}`,
});
}
if (externalId) {
this.removeCache(`/series/${externalId}`);
}
if (title) {
this.removeCache('/series/lookup', {
term: title,
});
}
};
}
export default SonarrAPI;

View File

@@ -4,6 +4,7 @@ export enum ApiErrorCode {
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN',
NoAdminUser = 'NO_ADMIN_USER',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN',

View File

@@ -68,8 +68,10 @@ const prodConfig: DataSourceOptions = {
const postgresDevConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
port: process.env.DB_SOCKET_PATH
? undefined
: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',
@@ -84,8 +86,10 @@ const postgresDevConfig: DataSourceOptions = {
const postgresProdConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
port: process.env.DB_SOCKET_PATH
? undefined
: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',

View File

@@ -7,6 +7,7 @@ import type {
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
@@ -207,28 +208,50 @@ export class MediaRequest {
}
}
// Apply overrides if the user is not an admin or has the "auto approve" permission
const useOverrides = !user.hasPermission(
[
requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
);
// Apply overrides if the user is not an admin or has the "advanced request" permission
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or',
});
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
if (useOverrides) {
const defaultRadarrId = requestBody.is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
const defaultSonarrId = requestBody.is4k
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: requestBody.serverId }
: { sonarrServiceId: requestBody.serverId },
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword =
'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
);
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
requestBody.mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users
@@ -257,31 +280,59 @@ export class MediaRequest {
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
const overrideRootFolder = appliedOverrideRules.find(
(rule) => rule.rootFolder
)?.rootFolder;
if (overrideRootFolder) {
rootFolder = overrideRootFolder;
}
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const overrideProfileId = appliedOverrideRules.find(
(rule) => rule.profileId
)?.profileId;
if (overrideProfileId) {
profileId = overrideProfileId;
}
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
const overrideTags = appliedOverrideRules.find((rule) => rule.tags)?.tags;
if (overrideTags) {
tags = [
...new Set([
...(tags || []),
...overrideTags.split(',').map((tag) => Number(tag)),
]),
];
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
}
}
@@ -335,14 +386,14 @@ export class MediaRequest {
const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
let requestedSeasons =
requestBody.seasons === 'all'
? settings.main.enableSpecialEpisodes
? tmdbMediaShow.seasons.map((season) => season.season_number)
: tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
? tmdbMediaShow.seasons.map((season) => season.season_number)
: (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
@@ -668,10 +719,15 @@ export class MediaRequest {
// Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
MediaStatus.PARTIALLY_AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
const statusField = this.is4k ? 'status4k' : 'status';
await mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.PROCESSING }
);
}
if (
@@ -954,6 +1010,14 @@ export class MediaRequest {
);
this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
radarr.clearCache({
tmdbId: movie.id,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
});
});
logger.info('Sent request to Radarr', {
label: 'Media Request',
@@ -1211,19 +1275,23 @@ export class MediaRequest {
throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
const updateFields = {
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
sonarrSeries.id,
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
sonarrSeries.titleSlug,
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
};
await mediaRepository.save(media);
await mediaRepository.update({ id: this.media.id }, updateFields);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
await requestRepository.save(this);
await requestRepository.update(
{ id: this.id },
{ status: MediaRequestStatus.FAILED }
);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -1236,6 +1304,15 @@ export class MediaRequest {
);
this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
sonarr.clearCache({
tvdbId,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
title: series.name,
});
});
logger.info('Sent request to Sonarr', {
label: 'Media Request',

View File

@@ -107,7 +107,7 @@ class SonarrScanner
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) =>
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) &&
(!settings.main.partialRequestsEnabled ? sn.seasonNumber !== 0 : true)
(!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true)
);
for (const season of filteredSeasons) {

View File

@@ -313,7 +313,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY
) {
throw new Error('select_server_type');
throw new ApiError(500, ApiErrorCode.NoAdminUser);
}
settings.main.mediaServerType = body.serverType;
@@ -533,6 +533,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
message: e.errorCode,
});
case ApiErrorCode.NoAdminUser:
logger.warn(
'Failed login attempt from user without admin permissions and no admin user exists',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default:
logger.error(e.message, { label: 'Auth' });
return next({

View File

@@ -70,11 +70,11 @@ router.get('/', async (req, res, next) => {
query = query
.addSelect((subQuery) => {
return subQuery
.select('COUNT(request.id)', 'requestCount')
.select('COUNT(request.id)', 'request_count')
.from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id');
}, 'requestCount')
.orderBy('requestCount', 'DESC');
}, 'request_count')
.orderBy('request_count', 'DESC');
break;
default:
query = query.orderBy('user.id', 'ASC');

View File

@@ -34,6 +34,7 @@ const messages = defineMessages('components.Login', {
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.',
noadminerror: 'No admin user found on the server.',
credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…',
@@ -157,6 +158,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror;
break;
case ApiErrorCode.NoAdminUser:
errorMessage = messages.noadminerror;
break;
default:
errorMessage = messages.loginerror;
break;
@@ -388,14 +392,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
email: values.username,
}),
});
if (!res.ok) throw new Error();
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null;
switch (errorData?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;
case ApiErrorCode.InvalidCredentials:
errorMessage = messages.credentialerror;
break;
case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror;
break;
case ApiErrorCode.NoAdminUser:
errorMessage = messages.noadminerror;
break;
default:
errorMessage = messages.loginerror;
break;
}
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
intl.formatMessage(errorMessage, mediaServerFormatValues),
{
autoDismiss: true,
appearance: 'error',

View File

@@ -1063,14 +1063,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</div>
)}
{!!streamingProviders.length && (
<div className="media-fact">
<div className="media-fact flex-col gap-1">
<span>{intl.formatMessage(messages.streamingproviders)}</span>
<span className="media-fact-value">
<span className="media-fact-value flex flex-row flex-wrap gap-5">
{streamingProviders.map((p) => {
return (
<span className="block" key={`provider-${p.id}`}>
{p.name}
</span>
<Tooltip content={p.name}>
<span
className="opacity-50 transition duration-300 hover:opacity-100"
key={`provider-${p.id}`}
>
<CachedImage
type="tmdb"
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
alt={p.name}
width={32}
height={32}
className="rounded-md"
/>
</span>
</Tooltip>
);
})}
</span>

View File

@@ -220,8 +220,8 @@ const RequestList = () => {
</select>
<Tooltip content={intl.formatMessage(messages.sortDirection)}>
<Button
buttonType="ghost"
className="z-40 mr-2 rounded-l-none"
buttonType="default"
className="z-40 mr-2 rounded-l-none border !border-gray-500 !bg-gray-800 !px-3 !text-gray-500 hover:!bg-gray-400 hover:!text-white"
buttonSize="md"
onClick={() =>
setCurrentSortDirection(
@@ -230,9 +230,9 @@ const RequestList = () => {
}
>
{currentSortDirection === 'asc' ? (
<ArrowUpIcon className="h-3" />
<ArrowUpIcon className="h-6 w-6" />
) : (
<ArrowDownIcon className="h-3" />
<ArrowDownIcon className="h-6 w-6" />
)}
</Button>
</Tooltip>

View File

@@ -256,8 +256,8 @@ const TvRequestModal = ({
let allSeasons = (data?.seasons ?? []).filter(
(season) => season.episodeCount !== 0
);
if (!settings.currentSettings.partialRequestsEnabled) {
allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0);
if (!settings.currentSettings.enableSpecialEpisodes) {
allSeasons = allSeasons.filter((season) => season.seasonNumber > 0);
}
return allSeasons.map((season) => season.seasonNumber);
};

View File

@@ -776,7 +776,7 @@ const RadarrModal = ({
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
{rules && (
<OverrideRuleTile
rules={rules}

View File

@@ -245,7 +245,9 @@ const SettingsLogs = () => {
<p className="description">
{intl.formatMessage(messages.logsDescription, {
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
<code className="whitespace-normal break-words bg-opacity-50">
{msg}
</code>
),
appDataPath: appData ? appData.appDataPath : '/app/config',
})}

View File

@@ -157,7 +157,7 @@ const SettingsMain = () => {
locale: data?.locale ?? 'en',
discoverRegion: data?.discoverRegion,
originalLanguage: data?.originalLanguage,
streamingRegion: data?.streamingRegion,
streamingRegion: data?.streamingRegion || 'US',
partialRequestsEnabled: data?.partialRequestsEnabled,
enableSpecialEpisodes: data?.enableSpecialEpisodes,
trustProxy: data?.trustProxy,
@@ -433,7 +433,7 @@ const SettingsMain = () => {
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<div className="form-input-field relative z-30">
<LanguageSelector
setFieldValue={setFieldValue}
value={values.originalLanguage}
@@ -449,9 +449,9 @@ const SettingsMain = () => {
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<div className="form-input-field relative z-20">
<RegionSelector
value={values.streamingRegion || 'US'}
value={values.streamingRegion}
name="streamingRegion"
onChange={setFieldValue}
regionType="streaming"

View File

@@ -350,6 +350,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
);
if (!res.ok) throw new Error();
}
if (onComplete) {
onComplete();
}
setIsSyncing(false);
revalidate();
};
@@ -435,10 +439,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
autoDismiss: true,
appearance: 'success',
});
if (onComplete) {
onComplete();
}
} catch (e) {
if (toastId) {
removeToast(toastId);

View File

@@ -1073,7 +1073,7 @@ const SonarrModal = ({
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
{intl.formatMessage(messages.overrideRules)}
</h3>
<ul className="grid grid-cols-2 gap-6">
<ul className="grid gap-x-4 gap-y-8 sm:grid-cols-3 sm:gap-x-6 sm:gap-y-6 lg:grid-cols-2">
{rules && (
<OverrideRuleTile
rules={rules}

View File

@@ -14,10 +14,12 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server';
import type { Library } from '@server/lib/settings';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import SetupLogin from './SetupLogin';
@@ -35,6 +37,8 @@ const messages = defineMessages('components.Setup', {
signin: 'Sign In',
configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services',
librarieserror:
'Validation failed. Please toggle the libraries again to continue.',
});
const Setup = () => {
@@ -49,6 +53,7 @@ const Setup = () => {
const router = useRouter();
const { locale } = useLocale();
const settings = useSettings();
const toasts = useToasts();
const finishSetup = async () => {
setIsUpdating(true);
@@ -77,6 +82,37 @@ const Setup = () => {
}
};
const validateLibraries = useCallback(async () => {
try {
const endpointMap: Record<MediaServerType, string> = {
[MediaServerType.JELLYFIN]: '/api/v1/settings/jellyfin',
[MediaServerType.EMBY]: '/api/v1/settings/jellyfin',
[MediaServerType.PLEX]: '/api/v1/settings/plex',
[MediaServerType.NOT_CONFIGURED]: '',
};
const endpoint = endpointMap[mediaServerType];
if (!endpoint) return;
const res = await fetch(endpoint);
if (!res.ok) throw new Error('Fetch failed');
const data = await res.json();
const hasEnabledLibraries = data?.libraries?.some(
(library: Library) => library.enabled
);
setMediaServerSettingsComplete(hasEnabledLibraries);
} catch (e) {
toasts.addToast(intl.formatMessage(messages.librarieserror), {
autoDismiss: true,
appearance: 'error',
});
setMediaServerSettingsComplete(false);
}
}, [intl, mediaServerType, toasts]);
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
@@ -87,19 +123,35 @@ const Setup = () => {
if (settings.currentSettings.initialized) {
router.push('/');
}
if (
settings.currentSettings.mediaServerType !==
MediaServerType.NOT_CONFIGURED
) {
setCurrentStep(3);
setMediaServerType(settings.currentSettings.mediaServerType);
if (currentStep < 3) {
setCurrentStep(3);
}
}
if (currentStep === 3) {
validateLibraries();
}
}, [
settings.currentSettings.mediaServerType,
settings.currentSettings.initialized,
router,
toasts,
intl,
currentStep,
mediaServerType,
validateLibraries,
]);
const handleComplete = () => {
validateLibraries();
};
if (settings.currentSettings.initialized) return <></>;
return (
@@ -225,14 +277,9 @@ const Setup = () => {
{currentStep === 3 && (
<div className="p-2">
{mediaServerType === MediaServerType.PLEX ? (
<SettingsPlex
onComplete={() => setMediaServerSettingsComplete(true)}
/>
<SettingsPlex onComplete={handleComplete} />
) : (
<SettingsJellyfin
isSetupSettings
onComplete={() => setMediaServerSettingsComplete(true)}
/>
<SettingsJellyfin isSetupSettings onComplete={handleComplete} />
)}
<div className="actions">
<div className="flex justify-end">

View File

@@ -303,7 +303,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const showHasSpecials = data.seasons.some(
(season) =>
season.seasonNumber === 0 &&
settings.currentSettings.partialRequestsEnabled
settings.currentSettings.enableSpecialEpisodes
);
const isComplete =
@@ -1243,14 +1243,26 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
)}
{!!streamingProviders.length && (
<div className="media-fact">
<div className="media-fact flex-col gap-1">
<span>{intl.formatMessage(messages.streamingproviders)}</span>
<span className="media-fact-value">
<span className="media-fact-value flex flex-row flex-wrap gap-5">
{streamingProviders.map((p) => {
return (
<span className="block" key={`provider-${p.id}`}>
{p.name}
</span>
<Tooltip content={p.name}>
<span
className="opacity-50 transition duration-300 hover:opacity-100"
key={`provider-${p.id}`}
>
<CachedImage
type="tmdb"
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
alt={p.name}
width={32}
height={32}
className="rounded-md"
/>
</span>
</Tooltip>
);
})}
</span>

View File

@@ -1137,6 +1137,7 @@
"components.Setup.continue": "Continue",
"components.Setup.finish": "Finish Setup",
"components.Setup.finishing": "Finishing…",
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign In",