Compare commits

...

49 Commits

Author SHA1 Message Date
fallenbagel
830ae90d81 chore: update @types/node to v22 2025-01-07 03:24:52 +08:00
fallenbagel
1b28043f56 chore: update nodejs version to 22 2025-01-07 02:46:03 +08:00
fallenbagel
51126ac1dc build: update nodejs version to 22 2025-01-07 02:11:17 +08:00
fallenbagel
4242754d61 chore: increase the required node version 2025-01-07 01:57:07 +08:00
fallenbagel
d210d43361 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
2025-01-07 01:49:17 +08: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
Fallenbagel
f8a8ebdf76 fix(overriderules): apply override rules to tv shows during request (#1198)
Forgot to apply override rules to tv shows on #1197

fix #1195
2024-12-30 11:24:28 +08:00
Fallenbagel
8da4870997 fix(overriderules): apply override rules during request only for non-admin/non-auto-approve users (#1197)
Updated the logic of override rules to be applied during the request phase and not during
send-to-arr phase. In addition, override rules will only apply for non-admin/non-auto-approve users.

fix #1195
2024-12-30 10:25:02 +08:00
fallenbagel
c98becf936 docs(db): postgres is now supported in latest stable & some styling fixes 2024-12-30 10:21:27 +08:00
Fallenbagel
9739e18949 refactor(settings): fix save button position (#1196)
We dont talk about this.
2024-12-30 08:16:50 +08:00
Fallenbagel
5fc4ae57c0 style(http-proxy): fix margin for responsive design (#1194) 2024-12-30 05:09:23 +08:00
Gauthier
b6e2e6ce61 feat: add a setting for special episodes (#1193)
* feat: add a setting for special episodes

This PR adds a separate setting for special episodes and disables them by default, to avoid unwanted
library status updates.

* refactor(settings): re-order setting for allow specials request

---------

Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-12-30 05:03:49 +08:00
Gauthier
66948b420f refactor(i18n): add better types to our custom defineMessages (#1192) 2024-12-29 06:22:26 +08:00
Gauthier
9a595296db feat: override rules (#945)
* feat: create the basis for the override rules

* feat: add support for sonarr and keywords to override rules

* feat: apply override rules in the media request

* feat: add users to override rules

* fix: save the settings modified by the override rules

* fix: resolve type errors

* style: run prettier

* fix: add missing migration

* fix: correct sonarr override rules

* fix: create PostgreSQL migration and fix SQLite migration

* fix: resolve type naming and fix i18n issue

* fix: remove unrelated changes to the PR
2024-12-29 05:20:35 +08:00
Fallenbagel
8da02d01b2 docs: add note for database config 2024-12-28 03:19:08 +08:00
Gauthier
8a097d5195 docs: add explainations to generates migrations with TypeORM (#1177) 2024-12-23 15:49:55 +08:00
Gauthier
5345207940 fix(ui): display Rotten Tomatoes for 0% ratings (#1178)
The frontend was coercing the zero value to false.

fix #1166
2024-12-23 15:48:45 +08:00
Gauthier
f5055035b6 docs: change the OpenAPI parameter from jellyfinUserIds (#1179)
The OpenAPI spec had the wrong parameter name, which was fixed in this PR.

fix #1161
2024-12-23 15:48:06 +08:00
Gauthier
59c22ccc08 fix(requestlist): use default value of sort direction only if valid (#1174)
The Sort Direction was loaded with values from the localStorage, but `undefined` was assigned if no
previous Sort Direction existed, causing the client to send undefined as a string for the Sort
Direction.

fix #1147
2024-12-22 05:56:22 +01:00
Gauthier
13d15d1dcf fix: remove non-null requirement for some fields (#1175)
PR #628 changed some fields to disallow null values, causing issues with some older SQLite database
having null values. This PR reverts this change.
2024-12-22 05:56:08 +01:00
Fallenbagel
59b7859f7f docs(buildfromsource): remove references to develop (#1173) 2024-12-22 01:39:29 +08:00
Gauthier
0491a04ef1 fix: fix PostgreSQL migrations and TelegramMessageThreadId migration (#1171)
* fix: fix PostgreSQL migrations and TelegramMessageThreadId migration

* fix: add missing migration to SQLite introduced by PostgreSQL
2024-12-21 16:35:07 +01:00
astro
d76d794411 feat(notifications): added telegram thread id's (#1145)
* feat(notifications): added telegram thread id's

* undid unwanted formatting

* chore: remove manual translations

* style: conformed formatting

* fix: add missing migration

* fix: corrected erroneous migration
2024-12-21 14:54:55 +08:00
GkhnGRBZ
1da2f258a7 Turkish language added (#1165)
* Add files via upload

* Add files via upload
2024-12-20 22:37:46 +01:00
Ben Haney
347a24a97b fix: handle non-existent rottentomatoes rating for movies (#1169)
This fixes a bug where some movies don't have any rottentomatoes
ratings, which causes ratings from other services to not show
2024-12-20 18:23:14 +08:00
Guillaume Chau
66a5ab41ab feat(requestlist): sort direction (#1147)
* feat(requestlist): sort direction

* style: quoted attributes

* style: quoted attributes
2024-12-17 10:59:03 +01:00
Gauthier
7c734bc873 fix(emby): change default value of Accept-Encoding header (#1157) 2024-12-17 00:15:37 +08:00
Gauthier
de6e591bae fix(discover): resolve a typing issue with the WatchlistItem interface (#1156) 2024-12-16 17:03:16 +01:00
allcontributors[bot]
7daea46eaa docs: add gageorsburn as a contributor for code (#1155)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-16 23:42:15 +08:00
Gauthier
fa443c05be fix(discover): display recent requests even if there is an error with *arr (#1141)
If Jellyseerr can't connect to Radarr/Sonarr, the "Recent Requests" slider will not load because of
the error throwed when trying to get the Quality Profile. This PR catch this error so the recent
requests are well displayed.
2024-12-16 16:41:59 +01:00
Gauthier
b01f98f7e2 fix(blacklist): remove a "undefined" appearing when the blacklist modal closes (#1142) 2024-12-16 16:41:49 +01:00
Gage Orsburn
39dbb7f7e5 fix(usediscover hook): fixing duplicate movies (#708)
fixing duplicate movies that can be returned from the tmdb api
2024-12-16 23:41:11 +08:00
allcontributors[bot]
e96159d3a5 docs: add dr-carrot as a contributor for code (#1154)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-16 22:02:03 +08:00
allcontributors[bot]
59a713d174 docs: add guillaumearnx as a contributor for code (#1153)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-16 22:01:06 +08:00
allcontributors[bot]
19450b46ef docs: add C4J3 as a contributor for doc (#1152)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-16 22:00:14 +08:00
allcontributors[bot]
bc755d3ad3 docs: add Zariel as a contributor for code (#1150)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-16 21:56:11 +08:00
dr-carrot
44a9221a9d feat: add postgres support + migrations (#628)
* chore(release): 1.4.0

* chore(release): 1.4.1

* chore(release): 1.5.0

* chore(release): 1.6.0

* chore(release): 1.7.0

* feat: support for postgresql

* test(pgsql): disable root certificate verification

* test(ci): temporarily change CI for local repo

* fix: don't use SQLite idiom when using PgSQL

* feat(db): add flag to toggle TLS for Postgres

* feat(postgres and migrations): added migrations for postgres & imporved ssl for postgres config

#186

* fix: restored workflow actions

* fix: access order

* fix: added pushover sound migration tto initial migration

* fix: added option to log queries

* fix: issue with session migration

* chore: relocate pushover sound migration

* feat: added logging option to other datasources

* chore: small tweaks for the datasource. Added docs for db setup

* chore: cleanup logs

* fix: added default dates to postgres migration

* fix: removed psql specific relation checks

* chore: added some debug sanity checks

* chore: added some more debug sanity checks

* chore: added some more additional debug sanity checks

* chore: added some more+ additional debug sanity checks

* chore: mild log cleanup

* chore: more log cleanup

* chore: finish log cleanup

* fix: added not null to migration so typeorm doesn't delete ids

* chore: cleanup extra psql code

* fix: remove eager load

* docs: added documentation for migration to postgres

* docs: added database option to bug template

* feat: created docker-compose postgres file

* fix: updated ts schema to align with change to migration

* fix: switch timestamp to include timezone

* fix: fixed indentation in psql docker-compose

* fix: changed version to 0.1.0 to remove ui notification

* style: fixed prettier in docker-compose.pastgres.yaml

* chore: restored CHANGELOG.md

* chore: revverted ts commit

* fix: update pnpm lock with pg package

* chore(pnpm-lock.yaml): updated pnpm-lock

* docs: update docs to add psql set up info

* refactor: clean up code from cr comments

* feat: migrate blacklist

* fix: fix issue with cypress tests

* docs: update psql docs

* fix: fix psql issue in user page; fix tiny psql error when selecting by empty list

* fix: incorrect current date function

* fix: null contraint with mediaAddedAt; fix psql col type

* refactor: removed unnecessary import

* feat: add postgres migration for streaming region

---------

Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
Co-authored-by: zackhow <zackhow@gmail.com>
Co-authored-by: Ryan Algar <me@ralgar.dev>
Co-authored-by: Ryan Algar <59636191+ralgar@users.noreply.github.com>
2024-12-16 14:02:33 +01:00
116 changed files with 4344 additions and 676 deletions

View File

@@ -511,6 +511,51 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "Zariel",
"name": "Chris Bannister",
"avatar_url": "https://avatars.githubusercontent.com/u/2213?v=4",
"profile": "https://github.com/Zariel",
"contributions": [
"code"
]
},
{
"login": "C4J3",
"name": "Joe",
"avatar_url": "https://avatars.githubusercontent.com/u/13005453?v=4",
"profile": "https://github.com/C4J3",
"contributions": [
"doc"
]
},
{
"login": "guillaumearnx",
"name": "Guillaume ARNOUX",
"avatar_url": "https://avatars.githubusercontent.com/u/37373941?v=4",
"profile": "https://me.garnx.fr",
"contributions": [
"code"
]
},
{
"login": "dr-carrot",
"name": "dr-carrot",
"avatar_url": "https://avatars.githubusercontent.com/u/17272571?v=4",
"profile": "https://github.com/dr-carrot",
"contributions": [
"code"
]
},
{
"login": "gageorsburn",
"name": "Gage Orsburn",
"avatar_url": "https://avatars.githubusercontent.com/u/4692734?v=4",
"profile": "https://github.com/gageorsburn",
"contributions": [
"code"
]
} }
] ]
} }

View File

@@ -55,6 +55,14 @@ body:
- tablet - tablet
validations: validations:
required: true required: true
- type: dropdown
id: database
attributes:
options:
- SQLite (default)
- PostgreSQL
label: Database
description: Which database backend are you using?
- type: input - type: input
id: device id: device
attributes: attributes:

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - 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 - HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install. - [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) - [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -101,6 +101,46 @@ We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-f
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a> <a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
## Migrations
If you are adding a new feature that requires a database migration, you will need to create 2 migrations: one for SQLite and one for PostgreSQL. Here is how you could do it:
1. Create a PostgreSQL database or use an existing one:
```bash
sudo docker run --name postgres-jellyseerr -e POSTGRES_PASSWORD=postgres -d -p 127.0.0.1:5432:5432/tcp postgres:latest
```
2. Reset the SQLite database and the PostgreSQL database:
```bash
rm config/db/db.*
rm config/settings.*
PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "DROP DATABASE IF EXISTS jellyseerr;"
PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE jellyseerr;"
```
3. Checkout the `develop` branch and create the original database for SQLite and PostgreSQL so that TypeORM can automatically generate the migrations:
```bash
git checkout develop
pnpm i
rm -r .next dist; pnpm build
pnpm start
DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm start
```
(You can shutdown the server once the message "Server ready on 5055" appears)
4. Let TypeORM generate the migrations:
```bash
git checkout -b your-feature-branch
pnpm i
pnpm migration:generate server/migration/sqlite/YourMigrationName
DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate server/migration/postgres/YourMigrationName
```
## Attribution ## Attribution
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides. This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides.

View File

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

View File

@@ -11,11 +11,11 @@
<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://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <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 --> <!-- 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-60-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **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 ## Current Features
@@ -147,6 +147,22 @@ 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/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/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/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> <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>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -22,6 +22,7 @@
"trustProxy": false, "trustProxy": false,
"mediaServerType": 1, "mediaServerType": 1,
"partialRequestsEnabled": true, "partialRequestsEnabled": true,
"enableSpecialEpisodes": false,
"locale": "en" "locale": "en"
}, },
"plex": { "plex": {
@@ -100,6 +101,7 @@
"options": { "options": {
"botAPI": "", "botAPI": "",
"chatId": "", "chatId": "",
"messageThreadId": "",
"sendSilently": false "sendSilently": false
} }
}, },

View File

@@ -0,0 +1,38 @@
---
version: '3.8'
services:
jellyseerr:
build:
context: .
dockerfile: Dockerfile.local
ports:
- '5055:5055'
environment:
DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres"
DB_HOST: 'postgres' # The host (url) of the database
DB_PORT: '5432' # The port to connect to
DB_USER: 'jellyseerr' # Username used to connect to the database
DB_PASS: 'jellyseerr' # Password of the user used to connect to the database
DB_NAME: 'jellyseerr' # The name of the database to connect to
DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging
DB_USE_SSL: 'false' # Whether to enable ssl for database connection
volumes:
- .:/app:rw,cached
- /app/node_modules
- /app/.next
depends_on:
- postgres
links:
- postgres
postgres:
image: postgres
environment:
POSTGRES_USER: jellyseerr
POSTGRES_PASSWORD: jellyseerr
POSTGRES_DB: jellyseerr
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres:

View File

@@ -17,6 +17,7 @@ Welcome to the Jellyseerr Documentation.
- **Mobile-friendly design**, for when you need to approve requests on the go. - **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system. - Granular permission system.
- Localization into other languages. - Localization into other languages.
- Support for PostgreSQL and SQLite databases.
- More features to come! - More features to come!
## Motivation ## Motivation

View File

@@ -0,0 +1,61 @@
---
title: Configuring the Database (Advanced)
description: Configure the database for Jellyseerr
sidebar_position: 2
---
# Configuring the Database
Jellyseerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables:
## SQLite Options
```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".
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
```
## PostgreSQL Options
```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_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_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
DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false".
DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true".
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
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
1. Set up your PostgreSQL database and configure Jellyseerr to use it
2. Run Jellyseerr to create the tables in the PostgreSQL database
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
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.
:::
:::caution
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}}
```
5. Start Jellyseerr

View File

@@ -12,7 +12,7 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem'; import TabItem from '@theme/TabItem';
### Prerequisites ### 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) - [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -26,7 +26,7 @@ sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
```bash ```bash
git clone https://github.com/Fallenbagel/jellyseerr.git git clone https://github.com/Fallenbagel/jellyseerr.git
cd jellyseerr cd jellyseerr
git checkout develop # by default, you are on the develop branch so this step is not necessary git checkout main
``` ```
3. Install the dependencies: 3. Install the dependencies:
```bash ```bash
@@ -58,9 +58,6 @@ PORT=5055
## specify on which interface to listen, by default jellyseerr listens on all interfaces ## specify on which interface to listen, by default jellyseerr listens on all interfaces
#HOST=127.0.0.1 #HOST=127.0.0.1
## Uncomment if your media server is emby instead of jellyfin.
# JELLYFIN_TYPE=emby
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only) ## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
# FORCE_IPV4_FIRST=true # FORCE_IPV4_FIRST=true
``` ```
@@ -203,7 +200,7 @@ cd C:\jellyseerr
2. Clone the Jellyseerr repository and checkout the develop branch: 2. Clone the Jellyseerr repository and checkout the develop branch:
```powershell ```powershell
git clone https://github.com/Fallenbagel/jellyseerr.git . git clone https://github.com/Fallenbagel/jellyseerr.git .
git checkout develop # by default, you are on the develop branch so this step is not necessary git checkout main
``` ```
3. Install the dependencies: 3. Install the dependencies:
```powershell ```powershell

View File

@@ -145,6 +145,16 @@ Then, create and start the Jellyseerr container:
<TabItem value="docker-cli" label="Docker CLI"> <TabItem value="docker-cli" label="Docker CLI">
```bash ```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 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> </TabItem>
@@ -167,6 +177,16 @@ services:
volumes: volumes:
jellyseerr-data: jellyseerr-data:
external: true 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> </TabItem>
</Tabs> </Tabs>
@@ -185,3 +205,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.) **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. 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

@@ -188,6 +188,9 @@ components:
defaultPermissions: defaultPermissions:
type: number type: number
example: 32 example: 32
enableSpecialEpisodes:
type: boolean
example: false
PlexLibrary: PlexLibrary:
type: object type: object
properties: properties:
@@ -1338,6 +1341,8 @@ components:
type: string type: string
chatId: chatId:
type: string type: string
messageThreadId:
type: string
sendSilently: sendSilently:
type: boolean type: boolean
PushbulletSettings: PushbulletSettings:
@@ -1821,6 +1826,9 @@ components:
telegramChatId: telegramChatId:
type: string type: string
nullable: true nullable: true
telegramMessageThreadId:
type: string
nullable: true
telegramSendSilently: telegramSendSilently:
type: boolean type: boolean
nullable: true nullable: true
@@ -1934,6 +1942,11 @@ components:
type: string type: string
native_name: native_name:
type: string type: string
OverrideRule:
type: object
properties:
id:
type: string
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey
@@ -3757,6 +3770,11 @@ paths:
type: string type: string
enum: [created, updated, requests, displayname] enum: [created, updated, requests, displayname]
default: created default: created
- in: query
name: q
required: false
schema:
type: string
responses: responses:
'200': '200':
description: A JSON array of all users description: A JSON array of all users
@@ -3873,7 +3891,7 @@ paths:
schema: schema:
type: object type: object
properties: properties:
jellyfinIds: jellyfinUserIds:
type: array type: array
items: items:
type: string type: string
@@ -5438,6 +5456,13 @@ paths:
type: string type: string
enum: [added, modified] enum: [added, modified]
default: added default: added
- in: query
name: sortDirection
schema:
type: string
enum: [asc, desc]
nullable: true
default: desc
- in: query - in: query
name: requestedBy name: requestedBy
schema: schema:
@@ -6958,6 +6983,68 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/WatchProviderDetails' $ref: '#/components/schemas/WatchProviderDetails'
/overrideRule:
get:
summary: Get override rules
description: Returns a list of all override rules with their conditions and settings
tags:
- overriderule
responses:
'200':
description: Override rules returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
post:
summary: Create override rule
description: Creates a new Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully created'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/{ruleId}:
put:
summary: Update override rule
description: Updates an Override Rule from the request body.
tags:
- overriderule
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
delete:
summary: Delete override rule by ID
description: Deletes the override rule with the provided ruleId.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: Override rule successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
security: security:
- cookieAuth: [] - cookieAuth: []
- apiKey: [] - apiKey: []

View File

@@ -69,6 +69,7 @@
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodemailer": "6.9.1", "nodemailer": "6.9.1",
"openpgp": "5.7.0", "openpgp": "5.7.0",
"pg": "8.11.0",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.2", "pug": "3.0.2",
"react": "^18.3.1", "react": "^18.3.1",
@@ -122,7 +123,7 @@
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/mime": "3", "@types/mime": "3",
"@types/node": "20.14.8", "@types/node": "22.10.5",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
@@ -168,7 +169,7 @@
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "engines": {
"node": "^20.0.0", "node": "^22.0.0",
"pnpm": "^9.0.0" "pnpm": "^9.0.0"
}, },
"overrides": { "overrides": {

1935
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import { getSettings } from '@server/lib/settings';
import type { RateLimitOptions } from '@server/utils/rateLimit'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache'; import type NodeCache from 'node-cache';
@@ -34,6 +36,8 @@ class ExternalAPI {
const url = new URL(baseUrl); const url = new URL(baseUrl);
const settings = getSettings();
this.defaultHeaders = { this.defaultHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
@@ -42,6 +46,9 @@ class ExternalAPI {
`${url.username}:${url.password}` `${url.username}:${url.password}`
).toString('base64')}`, ).toString('base64')}`,
}), }),
...(settings.main.mediaServerType === MediaServerType.EMBY && {
'Accept-Encoding': 'gzip',
}),
...options.headers, ...options.headers,
}; };
@@ -286,6 +293,14 @@ class ExternalAPI {
return data; return data;
} }
protected removeCache(endpoint: string, params?: Record<string, string>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
this.cache?.del(cacheKey);
}
private formatUrl( private formatUrl(
endpoint: string, endpoint: string,
params?: Record<string, string>, params?: Record<string, string>,

View File

@@ -128,7 +128,7 @@ class RottenTomatoes extends ExternalAPI {
movie = contentResults.hits.find((movie) => movie.title === name); movie = contentResults.hits.find((movie) => movie.title === name);
} }
if (!movie) { if (!movie?.rottenTomatoes) {
return null; return null;
} }

View File

@@ -230,6 +230,23 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`); 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; export default RadarrAPI;

View File

@@ -353,6 +353,30 @@ class SonarrAPI extends ServarrBase<{
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`); 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; export default SonarrAPI;

View File

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

View File

@@ -1,7 +1,43 @@
import 'reflect-metadata'; import fs from 'fs';
import type { TlsOptions } from 'tls';
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
const DB_SSL_PREFIX = 'DB_SSL_';
function boolFromEnv(envVar: string, defaultVal = false) {
if (process.env[envVar]) {
return process.env[envVar]?.toLowerCase() === 'true';
}
return defaultVal;
}
function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined {
if (process.env[envVar]) {
return process.env[envVar];
}
const filePath = process.env[`${envVar}_FILE`];
if (filePath) {
return fs.readFileSync(filePath);
}
return undefined;
}
function buildSslConfig(): TlsOptions | undefined {
if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') {
return undefined;
}
return {
rejectUnauthorized: boolFromEnv(
`${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`,
true
),
ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`),
key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`),
cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`),
};
}
const devConfig: DataSourceOptions = { const devConfig: DataSourceOptions = {
type: 'sqlite', type: 'sqlite',
database: process.env.CONFIG_DIRECTORY database: process.env.CONFIG_DIRECTORY
@@ -9,10 +45,10 @@ const devConfig: DataSourceOptions = {
: 'config/db/db.sqlite3', : 'config/db/db.sqlite3',
synchronize: true, synchronize: true,
migrationsRun: false, migrationsRun: false,
logging: false, logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true, enableWAL: true,
entities: ['server/entity/**/*.ts'], entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'], migrations: ['server/migration/sqlite/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'],
}; };
@@ -23,16 +59,56 @@ const prodConfig: DataSourceOptions = {
: 'config/db/db.sqlite3', : 'config/db/db.sqlite3',
synchronize: false, synchronize: false,
migrationsRun: false, migrationsRun: false,
logging: false, logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true, enableWAL: true,
entities: ['dist/entity/**/*.js'], entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'], migrations: ['dist/migration/sqlite/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'], subscribers: ['dist/subscriber/**/*.js'],
}; };
const dataSource = new DataSource( const postgresDevConfig: DataSourceOptions = {
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig type: 'postgres',
); host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',
ssl: buildSslConfig(),
synchronize: false,
migrationsRun: true,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/postgres/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
const postgresProdConfig: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME ?? 'jellyseerr',
ssl: buildSslConfig(),
synchronize: false,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/postgres/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
export const isPgsql = process.env.DB_TYPE === 'postgres';
function getDataSource(): DataSourceOptions {
if (process.env.NODE_ENV === 'production') {
return isPgsql ? postgresProdConfig : prodConfig;
} else {
return isPgsql ? postgresDevConfig : devConfig;
}
}
const dataSource = new DataSource(getDataSource());
export const getRepository = <Entity extends object>( export const getRepository = <Entity extends object>(
target: EntityTarget<Entity> target: EntityTarget<Entity>

View File

@@ -10,6 +10,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import { import {
AfterLoad, AfterLoad,
@@ -42,6 +43,10 @@ class Media {
finalIds = tmdbIds; finalIds = tmdbIds;
} }
if (finalIds.length === 0) {
return [];
}
const media = await mediaRepository const media = await mediaRepository
.createQueryBuilder('media') .createQueryBuilder('media')
.leftJoinAndSelect( .leftJoinAndSelect(
@@ -127,10 +132,23 @@ class Media {
@UpdateDateColumn() @UpdateDateColumn()
public updatedAt: Date; public updatedAt: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) /**
* The `lastSeasonChange` column stores the date and time when the media was added to the library.
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
*/
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date; public lastSeasonChange: Date;
@Column({ type: 'datetime', nullable: true }) /**
* The `mediaAddedAt` column stores the date and time when the media was added to the library.
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
* This column is nullable because it can be null when the media is not yet synced to the library.
*/
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: true,
})
public mediaAddedAt: Date; public mediaAddedAt: Date;
@Column({ nullable: true, type: 'int' }) @Column({ nullable: true, type: 'int' })

View File

@@ -7,12 +7,14 @@ import type {
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
MediaType, MediaType,
} from '@server/constants/media'; } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications'; import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
@@ -57,6 +59,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User); const userRepository = getRepository(User);
const settings = getSettings();
let requestUser = user; let requestUser = user;
@@ -205,6 +208,134 @@ export class MediaRequest {
} }
} }
// 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: 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
.split(',')
.some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres
.map((genre) => genre.id)
.includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
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;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// 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,
});
}
}
if (requestBody.mediaType === MediaType.MOVIE) { if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media); await mediaRepository.save(media);
@@ -243,9 +374,9 @@ export class MediaRequest {
: undefined, : undefined,
is4k: requestBody.is4k, is4k: requestBody.is4k,
serverId: requestBody.serverId, serverId: requestBody.serverId,
profileId: requestBody.profileId, profileId: profileId,
rootFolder: requestBody.rootFolder, rootFolder: rootFolder,
tags: requestBody.tags, tags: tags,
isAutoRequest: options.isAutoRequest ?? false, isAutoRequest: options.isAutoRequest ?? false,
}); });
@@ -255,10 +386,14 @@ export class MediaRequest {
const tmdbMediaShow = tmdbMedia as Awaited< const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow> ReturnType<typeof tmdb.getTvShow>
>; >;
const requestedSeasons = let requestedSeasons =
requestBody.seasons === 'all' requestBody.seasons === 'all'
? tmdbMediaShow.seasons.map((season) => season.season_number) ? tmdbMediaShow.seasons.map((season) => season.season_number)
: (requestBody.seasons as number[]); : (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
let existingSeasons: number[] = []; let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were // We need to check existing requests on this title to make sure we don't double up on seasons that were
@@ -344,10 +479,10 @@ export class MediaRequest {
: undefined, : undefined,
is4k: requestBody.is4k, is4k: requestBody.is4k,
serverId: requestBody.serverId, serverId: requestBody.serverId,
profileId: requestBody.profileId, profileId: profileId,
rootFolder: requestBody.rootFolder, rootFolder: rootFolder,
languageProfileId: requestBody.languageProfileId, languageProfileId: requestBody.languageProfileId,
tags: requestBody.tags, tags: tags,
seasons: finalSeasons.map( seasons: finalSeasons.map(
(sn) => (sn) =>
new SeasonRequest({ new SeasonRequest({
@@ -584,10 +719,15 @@ export class MediaRequest {
// Do not update the status if the item is already partially available or available // 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.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !== 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; const statusField = this.is4k ? 'status4k' : 'status';
mediaRepository.save(media);
await mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.PROCESSING }
);
} }
if ( if (
@@ -857,7 +997,7 @@ export class MediaRequest {
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED; this.status = MediaRequestStatus.FAILED;
requestRepository.save(this); await requestRepository.save(this);
logger.warn( logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED', 'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -870,6 +1010,14 @@ export class MediaRequest {
); );
this.sendNotification(media, Notification.MEDIA_FAILED); 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', { logger.info('Sent request to Radarr', {
label: 'Media Request', label: 'Media Request',
@@ -1127,18 +1275,23 @@ export class MediaRequest {
throw new Error('Media data not found'); throw new Error('Media data not found');
} }
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = const updateFields = {
sonarrSeries.id; [this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = sonarrSeries.id,
sonarrSeries.titleSlug; [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; sonarrSeries.titleSlug,
await mediaRepository.save(media); [this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
};
await mediaRepository.update({ id: this.media.id }, updateFields);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED; await requestRepository.update(
requestRepository.save(this); { id: this.id },
{ status: MediaRequestStatus.FAILED }
);
logger.warn( logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED', 'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -1151,6 +1304,15 @@ export class MediaRequest {
); );
this.sendNotification(media, Notification.MEDIA_FAILED); 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', { logger.info('Sent request to Sonarr', {
label: 'Media Request', label: 'Media Request',

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class OverrideRule {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int', nullable: true })
public radarrServiceId?: number;
@Column({ type: 'int', nullable: true })
public sonarrServiceId?: number;
@Column({ nullable: true })
public users?: string;
@Column({ nullable: true })
public genre?: string;
@Column({ nullable: true })
public language?: string;
@Column({ nullable: true })
public keywords?: string;
@Column({ type: 'int', nullable: true })
public profileId?: number;
@Column({ nullable: true })
public rootFolder?: string;
@Column({ nullable: true })
public tags?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<OverrideRule>) {
Object.assign(this, init);
}
}
export default OverrideRule;

View File

@@ -23,7 +23,9 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus; public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) @ManyToOne(() => Media, (media) => media.seasons, {
onDelete: 'CASCADE',
})
public media: Promise<Media>; public media: Promise<Media>;
@CreateDateColumn() @CreateDateColumn()

View File

@@ -60,6 +60,9 @@ export class UserSettings {
@Column({ nullable: true }) @Column({ nullable: true })
public telegramChatId?: string; public telegramChatId?: string;
@Column({ nullable: true })
public telegramMessageThreadId?: string;
@Column({ nullable: true }) @Column({ nullable: true })
public telegramSendSilently?: boolean; public telegramSendSilently?: boolean;

View File

@@ -1,5 +1,5 @@
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource'; import dataSource, { getRepository, isPgsql } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider'; import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session'; import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
@@ -66,9 +66,13 @@ app
// Run migrations in production // Run migrations in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
await dbConnection.query('PRAGMA foreign_keys=OFF'); if (isPgsql) {
await dbConnection.runMigrations(); await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON'); } else {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
} }
// Load Settings // Load Settings

View File

@@ -5,6 +5,7 @@ export interface GenreSliderItem {
} }
export interface WatchlistItem { export interface WatchlistItem {
id: number;
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId: number;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';

View File

@@ -0,0 +1,3 @@
import type OverrideRule from '@server/entity/OverrideRule';
export type OverrideRuleResultsResponse = OverrideRule[];

View File

@@ -37,6 +37,7 @@ export interface PublicSettingsResponse {
originalLanguage: string; originalLanguage: string;
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
cacheImages: boolean; cacheImages: boolean;
vapidPublic: string; vapidPublic: string;
enablePushRegistration: boolean; enablePushRegistration: boolean;

View File

@@ -34,6 +34,7 @@ export interface UserSettingsNotificationsResponse {
telegramEnabled?: boolean; telegramEnabled?: boolean;
telegramBotUsername?: string; telegramBotUsername?: string;
telegramChatId?: string; telegramChatId?: string;
telegramMessageThreadId?: string;
telegramSendSilently?: boolean; telegramSendSilently?: boolean;
webPushEnabled?: boolean; webPushEnabled?: boolean;
notificationTypes: Partial<NotificationAgentTypes>; notificationTypes: Partial<NotificationAgentTypes>;

View File

@@ -17,6 +17,7 @@ interface TelegramMessagePayload {
text: string; text: string;
parse_mode: string; parse_mode: string;
chat_id: string; chat_id: string;
message_thread_id: string;
disable_notification: boolean; disable_notification: boolean;
} }
@@ -25,6 +26,7 @@ interface TelegramPhotoPayload {
caption: string; caption: string;
parse_mode: string; parse_mode: string;
chat_id: string; chat_id: string;
message_thread_id: string;
disable_notification: boolean; disable_notification: boolean;
} }
@@ -182,6 +184,7 @@ class TelegramAgent
body: JSON.stringify({ body: JSON.stringify({
...notificationPayload, ...notificationPayload,
chat_id: settings.options.chatId, chat_id: settings.options.chatId,
message_thread_id: settings.options.messageThreadId,
disable_notification: !!settings.options.sendSilently, disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } as TelegramMessagePayload | TelegramPhotoPayload),
}); });
@@ -233,6 +236,8 @@ class TelegramAgent
body: JSON.stringify({ body: JSON.stringify({
...notificationPayload, ...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId, chat_id: payload.notifyUser.settings.telegramChatId,
message_thread_id:
payload.notifyUser.settings.telegramMessageThreadId,
disable_notification: disable_notification:
!!payload.notifyUser.settings.telegramSendSilently, !!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } as TelegramMessagePayload | TelegramPhotoPayload),
@@ -296,6 +301,7 @@ class TelegramAgent
body: JSON.stringify({ body: JSON.stringify({
...notificationPayload, ...notificationPayload,
chat_id: user.settings.telegramChatId, chat_id: user.settings.telegramChatId,
message_thread_id: user.settings.telegramMessageThreadId,
disable_notification: !!user.settings?.telegramSendSilently, disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload), } as TelegramMessagePayload | TelegramPhotoPayload),
}); });

View File

@@ -277,8 +277,13 @@ class PlexScanner
const seasons = tvShow.seasons; const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = []; const processableSeasons: ProcessableSeason[] = [];
const settings = getSettings();
for (const season of seasons) { const filteredSeasons = settings.main.enableSpecialEpisodes
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find( const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number (md) => Number(md.index) === season.season_number
); );

View File

@@ -102,9 +102,12 @@ class SonarrScanner
} }
const tmdbId = tvShow.id; const tmdbId = tvShow.id;
const settings = getSettings();
const filteredSeasons = sonarrSeries.seasons.filter((sn) => const filteredSeasons = sonarrSeries.seasons.filter(
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) (sn) =>
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) &&
(!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true)
); );
for (const season of filteredSeasons) { for (const season of filteredSeasons) {

View File

@@ -76,6 +76,7 @@ export interface DVRSettings {
syncEnabled: boolean; syncEnabled: boolean;
preventSearch: boolean; preventSearch: boolean;
tagRequests: boolean; tagRequests: boolean;
overrideRule: number[];
} }
export interface RadarrSettings extends DVRSettings { export interface RadarrSettings extends DVRSettings {
@@ -130,6 +131,7 @@ export interface MainSettings {
trustProxy: boolean; trustProxy: boolean;
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
locale: string; locale: string;
proxy: ProxySettings; proxy: ProxySettings;
} }
@@ -153,6 +155,7 @@ interface FullPublicSettings extends PublicSettings {
jellyfinForgotPasswordUrl?: string; jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
cacheImages: boolean; cacheImages: boolean;
vapidPublic: string; vapidPublic: string;
enablePushRegistration: boolean; enablePushRegistration: boolean;
@@ -213,6 +216,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig {
botUsername?: string; botUsername?: string;
botAPI: string; botAPI: string;
chatId: string; chatId: string;
messageThreadId: string;
sendSilently: boolean; sendSilently: boolean;
}; };
} }
@@ -341,6 +345,7 @@ class Settings {
trustProxy: false, trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED, mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true, partialRequestsEnabled: true,
enableSpecialEpisodes: false,
locale: 'en', locale: 'en',
proxy: { proxy: {
enabled: false, enabled: false,
@@ -423,6 +428,7 @@ class Settings {
options: { options: {
botAPI: '', botAPI: '',
chatId: '', chatId: '',
messageThreadId: '',
sendSilently: false, sendSilently: false,
}, },
}, },
@@ -584,6 +590,7 @@ class Settings {
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType, mediaServerType: this.main.mediaServerType,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
enableSpecialEpisodes: this.data.main.enableSpecialEpisodes,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic, vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled, enablePushRegistration: this.data.notifications.agents.webpush.enabled,

View File

@@ -0,0 +1,195 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialMigration1734786061496 implements MigrationInterface {
name = 'InitialMigration1734786061496';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "title" character varying, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "PK_04dc42a96bf0914cda31b579702" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(
`CREATE TABLE "season_request" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestId" integer, CONSTRAINT "PK_4811e502081543bf620f1fa4328" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "media_request" ("id" SERIAL NOT NULL, "status" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying NOT NULL, "is4k" boolean NOT NULL DEFAULT false, "serverId" integer, "profileId" integer, "rootFolder" character varying, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT false, "mediaId" integer NOT NULL, "requestedById" integer, "modifiedById" integer, CONSTRAINT "PK_f8334500e8e12db87536558c66c" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "season" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer NOT NULL, CONSTRAINT "PK_8ac0d081dbdb7ab02d166bcda9f" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "media" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" character varying, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "lastSeasonChange" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "mediaAddedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" character varying, "externalServiceSlug4k" character varying, "ratingKey" character varying, "ratingKey4k" character varying, "jellyfinMediaId" character varying, "jellyfinMediaId4k" character varying, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "PK_f4e0fcac36e050de337b670d8bd" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE TABLE "watchlist" ("id" SERIAL NOT NULL, "ratingKey" character varying NOT NULL, "mediaType" character varying NOT NULL, "title" character varying NOT NULL, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestedById" integer, "mediaId" integer NOT NULL, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "PK_0c8c0dbcc8d379117138e71ad5b" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" SERIAL NOT NULL, "endpoint" character varying NOT NULL, "p256dh" character varying NOT NULL, "auth" character varying NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "PK_397020e7be9a4086cc798e0bb63" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" SERIAL NOT NULL, "locale" character varying NOT NULL DEFAULT '', "discoverRegion" character varying, "streamingRegion" character varying, "originalLanguage" character varying, "pgpKey" character varying, "discordId" character varying, "pushbulletAccessToken" character varying, "pushoverApplicationToken" character varying, "pushoverUserKey" character varying, "pushoverSound" character varying, "telegramChatId" character varying, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "PK_00f004f5922a0744d174530d639" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying NOT NULL, "plexUsername" character varying, "jellyfinUsername" character varying, "username" character varying, "password" character varying, "resetPasswordGuid" character varying, "recoveryLinkExpirationDate" date, "userType" integer NOT NULL DEFAULT '1', "plexId" integer, "jellyfinUserId" character varying, "jellyfinDeviceId" character varying, "jellyfinAuthToken" character varying, "plexToken" character varying, "permissions" integer NOT NULL DEFAULT '0', "avatar" character varying NOT NULL, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "issue_comment" ("id" SERIAL NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "issueId" integer, CONSTRAINT "PK_2ad05784e2ae661fa409e5e0248" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "issue" ("id" SERIAL NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "problemSeason" integer NOT NULL DEFAULT '0', "problemEpisode" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "PK_f80e086c249b9f3f3ff2fd321b7" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "discover_slider" ("id" SERIAL NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT false, "enabled" boolean NOT NULL DEFAULT true, "title" character varying, "data" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_20a71a098d04bae448e4d51db23" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE TABLE "session" ("expiredAt" bigint NOT NULL, "id" character varying(255) NOT NULL, "json" text NOT NULL, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`CREATE INDEX "IDX_28c5d1d16da7908c97c9bc2f74" ON "session" ("expiredAt") `
);
await queryRunner.query(
`ALTER TABLE "blacklist" ADD CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "blacklist" ADD CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "season_request" ADD CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "media_request" ADD CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "media_request" ADD CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "user_settings" ADD CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "issue" ADD CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "issue" ADD CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "issue" ADD CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "issue" DROP CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5"`
);
await queryRunner.query(
`ALTER TABLE "issue" DROP CONSTRAINT "FK_10b17b49d1ee77e7184216001e0"`
);
await queryRunner.query(
`ALTER TABLE "issue" DROP CONSTRAINT "FK_276e20d053f3cff1645803c95d8"`
);
await queryRunner.query(
`ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_180710fead1c94ca499c57a7d42"`
);
await queryRunner.query(
`ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_707b033c2d0653f75213614789d"`
);
await queryRunner.query(
`ALTER TABLE "user_settings" DROP CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78"`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "FK_03f7958328e311761b0de675fbe"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7"`
);
await queryRunner.query(
`ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"`
);
await queryRunner.query(
`ALTER TABLE "media_request" DROP CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15"`
);
await queryRunner.query(
`ALTER TABLE "media_request" DROP CONSTRAINT "FK_6997bee94720f1ecb7f31137095"`
);
await queryRunner.query(
`ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"`
);
await queryRunner.query(
`ALTER TABLE "season_request" DROP CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a"`
);
await queryRunner.query(
`ALTER TABLE "blacklist" DROP CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99"`
);
await queryRunner.query(
`ALTER TABLE "blacklist" DROP CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e"`
);
await queryRunner.query(
`DROP INDEX "public"."IDX_28c5d1d16da7908c97c9bc2f74"`
);
await queryRunner.query(`DROP TABLE "session"`);
await queryRunner.query(`DROP TABLE "discover_slider"`);
await queryRunner.query(`DROP TABLE "issue"`);
await queryRunner.query(`DROP TABLE "issue_comment"`);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_939f205946256cc0d2a1ac51a8"`
);
await queryRunner.query(`DROP TABLE "watchlist"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_7ff2d11f6a83cb52386eaebe74"`
);
await queryRunner.query(
`DROP INDEX "public"."IDX_41a289eb1fa489c1bc6f38d9c3"`
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7157aad07c73f6a6ae3bbd5ef5"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`DROP TABLE "season"`);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(`DROP TABLE "season_request"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_6bbafa28411e6046421991ea21"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
}
}

View File

@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734786596045
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734786596045';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "telegramMessageThreadId" character varying`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "telegramMessageThreadId"`
);
}
}

View File

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOverrideRules1734805738349 implements MigrationInterface {
name = 'AddOverrideRules1734805738349';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "override_rule"`);
}
}

View File

@@ -0,0 +1,65 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class FixNullFields1734809898562 implements MigrationInterface {
name = 'FixNullFields1734809898562';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" DROP NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"`
);
await queryRunner.query(
`ALTER TABLE "media_request" ALTER COLUMN "mediaId" DROP NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"`
);
await queryRunner.query(
`ALTER TABLE "season" ALTER COLUMN "mediaId" DROP NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"`
);
await queryRunner.query(
`ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"`
);
await queryRunner.query(
`ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"`
);
await queryRunner.query(
`ALTER TABLE "season" ALTER COLUMN "mediaId" SET NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "media_request" ALTER COLUMN "mediaId" SET NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ALTER COLUMN "mediaId" SET NOT NULL`
);
await queryRunner.query(
`ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
}
}

View File

@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734287582736
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734287582736';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

View File

@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOverrideRules1734805733535 implements MigrationInterface {
name = 'AddOverrideRules1734805733535';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "override_rule"`);
}
}

View File

@@ -313,7 +313,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
body.serverType !== MediaServerType.JELLYFIN && body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY body.serverType !== MediaServerType.EMBY
) { ) {
throw new Error('select_server_type'); throw new ApiError(500, ApiErrorCode.NoAdminUser);
} }
settings.main.mediaServerType = body.serverType; settings.main.mediaServerType = body.serverType;
@@ -533,6 +533,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
message: e.errorCode, 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: default:
logger.error(e.message, { label: 'Auth' }); logger.error(e.message, { label: 'Auth' });
return next({ return next({

View File

@@ -875,6 +875,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize, totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({ results: watchlist.items.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie', mediaType: item.type === 'show' ? 'tv' : 'movie',

View File

@@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common'; import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie'; import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv'; import { mapNetwork } from '@server/models/Tv';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings'; import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist'; import watchlistRoutes from '@server/routes/watchlist';
import { import {
@@ -160,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.use(
'/overrideRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => { router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();

View File

@@ -0,0 +1,136 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const overrideRuleRoutes = Router();
overrideRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rules = await overrideRuleRepository.find({});
return res.status(200).json(rules as OverrideRuleResultsResponse);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
overrideRuleRoutes.post<
Record<string, string>,
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = new OverrideRule({
users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
await overrideRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default overrideRuleRoutes;

View File

@@ -94,6 +94,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
} }
let sortFilter: string; let sortFilter: string;
let sortDirection: 'ASC' | 'DESC';
switch (req.query.sort) { switch (req.query.sort) {
case 'modified': case 'modified':
@@ -103,6 +104,14 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
sortFilter = 'request.id'; sortFilter = 'request.id';
} }
switch (req.query.sortDirection) {
case 'asc':
sortDirection = 'ASC';
break;
default:
sortDirection = 'DESC';
}
let query = getRepository(MediaRequest) let query = getRepository(MediaRequest)
.createQueryBuilder('request') .createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media') .leftJoinAndSelect('request.media', 'media')
@@ -113,7 +122,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
requestStatus: statusFilter, requestStatus: statusFilter,
}) })
.andWhere( .andWhere(
'((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', '((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))',
{ {
mediaStatus: mediaStatusFilter, mediaStatus: mediaStatusFilter,
} }
@@ -142,7 +151,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
} }
const [requests, requestCount] = await query const [requests, requestCount] = await query
.orderBy(sortFilter, 'DESC') .orderBy(sortFilter, sortDirection)
.take(pageSize) .take(pageSize)
.skip(skip) .skip(skip)
.getManyAndCount(); .getManyAndCount();
@@ -159,7 +168,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
return { return {
id: sonarrSetting.id, id: sonarrSetting.id,
profiles: await sonarr.getProfiles(), profiles: await sonarr.getProfiles().catch(() => undefined),
}; };
}) })
); );
@@ -174,7 +183,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
return { return {
id: radarrSetting.id, id: radarrSetting.id,
profiles: await radarr.getProfiles(), profiles: await radarr.getProfiles().catch(() => undefined),
}; };
}) })
); );
@@ -185,7 +194,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
case MediaType.MOVIE: { case MediaType.MOVIE: {
const profileName = radarrServers const profileName = radarrServers
.find((serverr) => serverr.id === r.serverId) .find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name; ?.profiles?.find((profile) => profile.id === r.profileId)?.name;
return { return {
...r, ...r,
@@ -197,7 +206,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
...r, ...r,
profileName: sonarrServers profileName: sonarrServers
.find((serverr) => serverr.id === r.serverId) .find((serverr) => serverr.id === r.serverId)
?.profiles.find((profile) => profile.id === r.profileId)?.name, ?.profiles?.find((profile) => profile.id === r.profileId)?.name,
}; };
} }
} }

View File

@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
try { try {
const pageSize = req.query.take ? Number(req.query.take) : 10; const pageSize = req.query.take ? Number(req.query.take) : 10;
const skip = req.query.skip ? Number(req.query.skip) : 0; const skip = req.query.skip ? Number(req.query.skip) : 0;
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
let query = getRepository(User).createQueryBuilder('user'); let query = getRepository(User).createQueryBuilder('user');
if (q) {
query = query.where(
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
{ q: `%${q}%` }
);
}
switch (req.query.sort) { switch (req.query.sort) {
case 'updated': case 'updated':
query = query.orderBy('user.updatedAt', 'DESC'); query = query.orderBy('user.updatedAt', 'DESC');
@@ -45,7 +53,7 @@ router.get('/', async (req, res, next) => {
`CASE WHEN (user.username IS NULL OR user.username = '') THEN ( `CASE WHEN (user.username IS NULL OR user.username = '') THEN (
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
user.email "user"."email"
ELSE ELSE
LOWER(user.jellyfinUsername) LOWER(user.jellyfinUsername)
END) END)
@@ -62,11 +70,11 @@ router.get('/', async (req, res, next) => {
query = query query = query
.addSelect((subQuery) => { .addSelect((subQuery) => {
return subQuery return subQuery
.select('COUNT(request.id)', 'requestCount') .select('COUNT(request.id)', 'request_count')
.from(MediaRequest, 'request') .from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id'); .where('request.requestedBy.id = user.id');
}, 'requestCount') }, 'request_count')
.orderBy('requestCount', 'DESC'); .orderBy('request_count', 'DESC');
break; break;
default: default:
query = query.orderBy('user.id', 'ASC'); query = query.orderBy('user.id', 'ASC');
@@ -764,6 +772,7 @@ router.get<{ id: string }, WatchlistResponse>(
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize, totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({ results: watchlist.items.map((item) => ({
id: item.tmdbId,
ratingKey: item.ratingKey, ratingKey: item.ratingKey,
title: item.title, title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie', mediaType: item.type === 'show' ? 'tv' : 'movie',

View File

@@ -323,6 +323,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
telegramEnabled: settings.telegram.enabled, telegramEnabled: settings.telegram.enabled,
telegramBotUsername: settings.telegram.options.botUsername, telegramBotUsername: settings.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId, telegramChatId: user.settings?.telegramChatId,
telegramMessageThreadId: user.settings?.telegramMessageThreadId,
telegramSendSilently: user.settings?.telegramSendSilently, telegramSendSilently: user.settings?.telegramSendSilently,
webPushEnabled: settings.webpush.enabled, webPushEnabled: settings.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {}, notificationTypes: user.settings?.notificationTypes ?? {},
@@ -365,6 +366,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
pushoverApplicationToken: req.body.pushoverApplicationToken, pushoverApplicationToken: req.body.pushoverApplicationToken,
pushoverUserKey: req.body.pushoverUserKey, pushoverUserKey: req.body.pushoverUserKey,
telegramChatId: req.body.telegramChatId, telegramChatId: req.body.telegramChatId,
telegramMessageThreadId: req.body.telegramMessageThreadId,
telegramSendSilently: req.body.telegramSendSilently, telegramSendSilently: req.body.telegramSendSilently,
notificationTypes: req.body.notificationTypes, notificationTypes: req.body.notificationTypes,
}); });
@@ -377,6 +379,8 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.pushoverUserKey = req.body.pushoverUserKey;
user.settings.pushoverSound = req.body.pushoverSound; user.settings.pushoverSound = req.body.pushoverSound;
user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramMessageThreadId =
req.body.telegramMessageThreadId;
user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.notificationTypes = Object.assign( user.settings.notificationTypes = Object.assign(
{}, {},
@@ -395,6 +399,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
pushoverUserKey: user.settings.pushoverUserKey, pushoverUserKey: user.settings.pushoverUserKey,
pushoverSound: user.settings.pushoverSound, pushoverSound: user.settings.pushoverSound,
telegramChatId: user.settings.telegramChatId, telegramChatId: user.settings.telegramChatId,
telegramMessageThreadId: user.settings.telegramMessageThreadId,
telegramSendSilently: user.settings.telegramSendSilently, telegramSendSilently: user.settings.telegramSendSilently,
notificationTypes: user.settings.notificationTypes, notificationTypes: user.settings.notificationTypes,
}); });

View File

@@ -0,0 +1,20 @@
import { isPgsql } from '@server/datasource';
import type { ColumnOptions, ColumnType } from 'typeorm';
import { Column } from 'typeorm';
const pgTypeMapping: { [key: string]: ColumnType } = {
datetime: 'timestamp with time zone',
};
export function resolveDbType(pgType: ColumnType): ColumnType {
if (isPgsql && pgType.toString() in pgTypeMapping) {
return pgTypeMapping[pgType.toString()];
}
return pgType;
}
export function DbAwareColumn(columnOptions: ColumnOptions) {
if (columnOptions.type) {
columnOptions.type = resolveDbType(columnOptions.type);
}
return Column(columnOptions);
}

View File

@@ -4,8 +4,8 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr';
interface BlacklistModalProps { interface BlacklistModalProps {
tmdbId: number; tmdbId: number;
@@ -21,7 +21,7 @@ const messages = defineMessages('component.BlacklistModal', {
}); });
const isMovie = ( const isMovie = (
movie: MovieDetails | TvDetails | undefined movie: MovieDetails | TvDetails | null
): movie is MovieDetails => { ): movie is MovieDetails => {
if (!movie) return false; if (!movie) return false;
return (movie as MovieDetails).title !== undefined; return (movie as MovieDetails).title !== undefined;
@@ -36,10 +36,25 @@ const BlacklistModal = ({
isUpdating, isUpdating,
}: BlacklistModalProps) => { }: BlacklistModalProps) => {
const intl = useIntl(); const intl = useIntl();
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
const [error, setError] = useState(null);
const { data, error } = useSWR<TvDetails | MovieDetails>( useEffect(() => {
show ? `/api/v1/${type}/${tmdbId}` : null (async () => {
); if (!show) return;
try {
setError(null);
const response = await fetch(`/api/v1/${type}/${tmdbId}`);
if (!response.ok) {
throw new Error();
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
}
})();
}, [show, tmdbId, type]);
return ( return (
<Transition <Transition

View File

@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import React, { Fragment, useRef } from 'react'; import React, { Fragment, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
@@ -66,8 +66,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook
useEffect(() => {
backgroundClickableRef.current = backgroundClickable;
}, [backgroundClickable]);
useClickOutside(modalRef, () => { useClickOutside(modalRef, () => {
if (onCancel && backgroundClickable) { if (onCancel && backgroundClickableRef.current) {
onCancel(); onCancel();
} }
}); });

View File

@@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search'; import type { TvResult } from '@server/models/Search';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages('components.DiscoverTvUpcoming', {}); const messages = defineMessages('components.DiscoverTvUpcoming', {
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming = () => { const DiscoverTvUpcoming = () => {
const intl = useIntl(); const intl = useIntl();

View File

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

View File

@@ -780,13 +780,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<div className="media-facts"> <div className="media-facts">
{(!!data.voteCount || {(!!data.voteCount ||
(ratingData?.rt?.criticsRating && (ratingData?.rt?.criticsRating &&
!!ratingData?.rt?.criticsScore) || typeof ratingData?.rt?.criticsScore === 'number') ||
(ratingData?.rt?.audienceRating && (ratingData?.rt?.audienceRating &&
!!ratingData?.rt?.audienceScore) || !!ratingData?.rt?.audienceScore) ||
ratingData?.imdb?.criticsScore) && ( ratingData?.imdb?.criticsScore) && (
<div className="media-ratings"> <div className="media-ratings">
{ratingData?.rt?.criticsRating && {ratingData?.rt?.criticsRating &&
!!ratingData?.rt?.criticsScore && ( typeof ratingData?.rt?.criticsScore === 'number' && (
<Tooltip <Tooltip
content={intl.formatMessage(messages.rtcriticsscore)} content={intl.formatMessage(messages.rtcriticsscore)}
> >

View File

@@ -5,6 +5,7 @@ import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -219,6 +220,7 @@ interface RequestCardProps {
} }
const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const settings = useSettings();
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
@@ -411,7 +413,11 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<span className="mr-2 font-bold "> <span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
seasonCount: seasonCount:
title.seasons.length === request.seasons.length (settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0 ? 0
: request.seasons.length, : request.seasons.length,
})} })}

View File

@@ -5,6 +5,7 @@ import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal'; import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge'; import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks'; import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
@@ -294,6 +295,7 @@ interface RequestItemProps {
} }
const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
@@ -481,7 +483,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
seasonCount: seasonCount:
title.seasons.length === request.seasons.length (settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0 ? 0
: request.seasons.length, : request.seasons.length,
})} })}

View File

@@ -2,13 +2,16 @@ import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header'; import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import RequestItem from '@app/components/RequestList/RequestItem'; import RequestItem from '@app/components/RequestList/RequestItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { import {
BarsArrowDownIcon, ArrowDownIcon,
ArrowUpIcon,
Bars3BottomLeftIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
FunnelIcon, FunnelIcon,
@@ -25,6 +28,7 @@ const messages = defineMessages('components.RequestList', {
showallrequests: 'Show All Requests', showallrequests: 'Show All Requests',
sortAdded: 'Most Recent', sortAdded: 'Most Recent',
sortModified: 'Last Modified', sortModified: 'Last Modified',
sortDirection: 'Toggle Sort Direction',
}); });
enum Filter { enum Filter {
@@ -39,6 +43,8 @@ enum Filter {
type Sort = 'added' | 'modified'; type Sort = 'added' | 'modified';
type SortDirection = 'asc' | 'desc';
const RequestList = () => { const RequestList = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
@@ -48,6 +54,8 @@ const RequestList = () => {
const { user: currentUser } = useUser(); const { user: currentUser } = useUser();
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING); const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added'); const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentSortDirection, setCurrentSortDirection] =
useState<SortDirection>('desc');
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const page = router.query.page ? Number(router.query.page) : 1; const page = router.query.page ? Number(router.query.page) : 1;
@@ -61,7 +69,7 @@ const RequestList = () => {
} = useSWR<RequestResultsResponse>( } = useSWR<RequestResultsResponse>(
`/api/v1/request?take=${currentPageSize}&skip=${ `/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * currentPageSize pageIndex * currentPageSize
}&filter=${currentFilter}&sort=${currentSort}${ }&filter=${currentFilter}&sort=${currentSort}&sortDirection=${currentSortDirection}${
router.pathname.startsWith('/profile') router.pathname.startsWith('/profile')
? `&requestedBy=${currentUser?.id}` ? `&requestedBy=${currentUser?.id}`
: router.query.userId : router.query.userId
@@ -80,6 +88,9 @@ const RequestList = () => {
setCurrentFilter(filterSettings.currentFilter); setCurrentFilter(filterSettings.currentFilter);
setCurrentSort(filterSettings.currentSort); setCurrentSort(filterSettings.currentSort);
setCurrentPageSize(filterSettings.currentPageSize); setCurrentPageSize(filterSettings.currentPageSize);
if (['asc', 'desc'].includes(filterSettings.currentSortDirection)) {
setCurrentSortDirection(filterSettings.currentSortDirection);
}
} }
// If filter value is provided in query, use that instead // If filter value is provided in query, use that instead
@@ -95,10 +106,11 @@ const RequestList = () => {
JSON.stringify({ JSON.stringify({
currentFilter, currentFilter,
currentSort, currentSort,
currentSortDirection,
currentPageSize, currentPageSize,
}) })
); );
}, [currentFilter, currentSort, currentPageSize]); }, [currentFilter, currentSort, currentSortDirection, currentPageSize]);
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -182,7 +194,7 @@ const RequestList = () => {
</div> </div>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0"> <div className="mb-2 flex flex-grow sm:mb-0 lg: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-gray-100 sm:text-sm"> <span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" /> <Bars3BottomLeftIcon className="h-6 w-6" />
</span> </span>
<select <select
id="sort" id="sort"
@@ -197,7 +209,7 @@ const RequestList = () => {
}); });
}} }}
value={currentSort} value={currentSort}
className="rounded-r-only" className="rounded-none border-r-0"
> >
<option value="added"> <option value="added">
{intl.formatMessage(messages.sortAdded)} {intl.formatMessage(messages.sortAdded)}
@@ -206,6 +218,24 @@ const RequestList = () => {
{intl.formatMessage(messages.sortModified)} {intl.formatMessage(messages.sortModified)}
</option> </option>
</select> </select>
<Tooltip content={intl.formatMessage(messages.sortDirection)}>
<Button
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(
currentSortDirection === 'asc' ? 'desc' : 'asc'
)
}
>
{currentSortDirection === 'asc' ? (
<ArrowUpIcon className="h-6 w-6" />
) : (
<ArrowDownIcon className="h-6 w-6" />
)}
</Button>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -253,9 +253,13 @@ const TvRequestModal = ({
}; };
const getAllSeasons = (): number[] => { const getAllSeasons = (): number[] => {
return (data?.seasons ?? []) let allSeasons = (data?.seasons ?? []).filter(
.filter((season) => season.episodeCount !== 0) (season) => season.episodeCount !== 0
.map((season) => season.seasonNumber); );
if (!settings.currentSettings.enableSpecialEpisodes) {
allSeasons = allSeasons.filter((season) => season.seasonNumber > 0);
}
return allSeasons.map((season) => season.seasonNumber);
}; };
const getAllRequestedSeasons = (): number[] => { const getAllRequestedSeasons = (): number[] => {
@@ -577,7 +581,12 @@ const TvRequestModal = ({
</thead> </thead>
<tbody className="divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700">
{data?.seasons {data?.seasons
.filter((season) => season.episodeCount !== 0) .filter(
(season) =>
(!settings.currentSettings.enableSpecialEpisodes
? season.seasonNumber !== 0
: true) && season.episodeCount !== 0
)
.map((season) => { .map((season) => {
const seasonRequest = getSeasonRequest( const seasonRequest = getSeasonRequest(
season.seasonNumber season.seasonNumber

View File

@@ -13,6 +13,7 @@ import type {
TmdbKeywordSearchResponse, TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type { import type {
Keyword, Keyword,
ProductionCompany, ProductionCompany,
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…', searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…', searchGenres: 'Select genres…',
searchStudios: 'Search studios…', searchStudios: 'Search studios…',
searchUsers: 'Select users…',
starttyping: 'Starting typing to search.', starttyping: 'Starting typing to search.',
nooptions: 'No results.', nooptions: 'No results.',
showmore: 'Show More', showmore: 'Show More',
@@ -546,3 +548,77 @@ export const WatchProviderSelector = ({
</> </>
); );
}; };
export const UserSelector = ({
isMulti,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadUsers = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const users = defaultValue.split(',');
const res = await fetch(`/api/v1/user`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: UserResultsResponse = await res.json();
const genreData = users
.filter((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => response.results.find((user) => user.id === Number(u)))
.map((u) => ({
label: u?.displayName ?? '',
value: u?.id ?? 0,
}));
setDefaultDataValue(genreData);
};
loadUsers();
}, [defaultValue]);
const loadUserOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
);
if (!res.ok) throw new Error();
const results: UserResultsResponse = await res.json();
return results.results
.map((result) => ({
label: result.displayName,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
<AsyncSelect
key={`user-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadUserOptions}
placeholder={intl.formatMessage(messages.searchUsers)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};

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