Compare commits

..

75 Commits

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

fix #1227
2025-01-11 21:01:59 +08:00
Fallenbagel
425f0c854e docs: remove pgloader volume from db config
this is not needed as we are using the command directly anyways
2025-01-10 19:37:12 +08:00
Fallenbagel
b8dbfaaed0 fix(setup): plex library setting validation (#1233)
* fix(setup): plex library setting validation

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

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

* chore: increase the required node version

* build: update nodejs version to 22

* chore: update nodejs version to 22

* chore: update @types/node to v22

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

re #919

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

---------

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

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

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

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

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

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

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

* fix: use default service instance for override rules

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-12-30 20:14:29 +08:00
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
Gauthier
89831f7090 feat(usersettings): add separate setting for streaming region (#993)
* feat: add separate setting for streaming region

Currently, the "Currently Streaming On" information is based on the Discover Region setting. This PR
adds a new setting to specify which region should be used to display the streaming region.

re #890

* fix: add missing newline

* fix: rename migration function
2024-12-08 17:19:11 +01:00
Gauthier
84fd884052 fix: use tmdb first as metadata provider and fallback to tvdb (#1138)
* fix: use tmdb first as metadata provider and fallback to tvdb

This PR changes the order of the metadata provider to TMDB first and then fallback to TheTVDB if no
TMDB metadata is available. Previously, TheTVDB was used first and there was no fallback if the
latter failed.

fix #1137

* feat: add logs

* fix: add logs

* fix: add show name
2024-12-08 15:54:53 +01:00
Gauthier
57767156f7 fix: use links instead of buttons for external links in movie/tv details page (#923)
Previously, the "Play on Jellyfin" or "Watch Trailers" buttons used the onClick event and
window.open to open links, instead of using 'a' elements with a href.
2024-12-08 13:10:44 +01:00
Gauthier
9fa47cbba2 docs: add a troubleshooting page (#1109)
* docs: add a troubleshooting page

This troubleshooting page explains how to solve dns blockage issues from ISPs and how to fix some
slowdowns / ipv6 errors with the forceIpv4First environment variable.

fix #1098

* fix: specify the difference between DNS blocking and total blocking from the ISP
2024-12-06 06:03:41 +08:00
Gauthier
17418f82af fix(avatarproxy): add support for Emby avatars (#1128)
Refactoring avatarproxy to retrieve avatars from the Jellyfin API instead of the public endpoint
broke Emby avatars that doesn't have this API method.

fix #1101
2024-12-03 10:53:23 +01:00
Chris Bannister
01bbeced65 fix(server/settings): write settings to a temp file then move to avoid corruption (#1067)
When writing the settings.json file ensure that the file is fully written by writing it to temporary
file before renaming it to the final settings path. This should prevent issues where the config gets
lost due to the file being corrupted.
2024-11-27 10:42:30 +01:00
Ludovic Ortega
27e3d465bd feat(helm): add base helm chart (#1116)
* feat(helm): add base helm chart

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* chore(ci): ignore helm charts files in prettier

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* chore(ci): ignore helm charts files in prettier

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* chore(ci): prettier ignore charts folder

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* fix: missing capital J

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

---------

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2024-11-26 16:24:30 +01:00
Gauthier
ef5e954db1 chore: merge upstream (#1112)
* feat(pushover): attach image to pushover notification payload (#3701)

* fix: api language query parameter (#3720)

* docs: add j0srisk as a contributor for code (#3745) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat(tooltip): add tooltip to display exact time on date hover (#3773)

Co-authored-by: Loetwiek <lodommerholtcm@gmail.com>

* docs: add Loetwiek as a contributor for code (#3776) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* fix(ui): ensure title fits into the `view collection` box (#3696)

* fix(docs): correct openapi docs minor issues (#3648)

* docs: add Fuochi as a contributor for doc (#3826)

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat: translations update from Hosted Weblate (#3597)

* feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (1232 of 1234 strings)

Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1236 of 1236 strings)

feat(lang): translated using Weblate (Danish)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kenneth Hansen <erathor@live.dk>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/da/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Greek)

Currently translated at 100.0% (1236 of 1236 strings)

Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/el/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 99.5% (1234 of 1240 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

feat(lang): translated using Weblate (Russian)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Romanian)

Currently translated at 37.1% (461 of 1240 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 37.0% (459 of 1240 strings)

feat(lang): translated using Weblate (Romanian)

Currently translated at 34.8% (432 of 1240 strings)

Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Bulgarian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 57.4% (712 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 13.2% (164 of 1240 strings)

feat(lang): translated using Weblate (Bulgarian)

Currently translated at 4.8% (60 of 1240 strings)

feat(lang): added translation using Weblate (Bulgarian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/bg/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 97.9% (1215 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 82.0% (1017 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 72.9% (905 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 72.9% (905 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 71.3% (885 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 64.9% (805 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 64.4% (799 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 63.8% (792 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 63.7% (791 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 57.5% (714 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 49.9% (619 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 35.9% (446 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 35.9% (446 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 32.1% (399 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 24.6% (306 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 18.9% (235 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 17.5% (217 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 17.3% (215 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 8.0% (100 of 1240 strings)

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 3.3% (41 of 1240 strings)

feat(lang): added translation using Weblate (Ukrainian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Czech)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Czech)

Currently translated at 99.6% (1236 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/cs/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Croatian)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.8% (1238 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.8% (1238 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.6% (1236 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.5% (1235 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.5% (1235 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 99.1% (1230 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 97.5% (1210 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.5% (1185 of 1240 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.6% (1182 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.6% (1182 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.2% (1177 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 95.2% (1177 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 94.3% (1166 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 91.7% (1134 of 1236 strings)

feat(lang): translated using Weblate (Croatian)

Currently translated at 91.7% (1134 of 1236 strings)

Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Stjepan <stjepstjepanovic@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hungarian)

Currently translated at 91.3% (1133 of 1240 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 89.3% (1108 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Hebrew)

Currently translated at 13.9% (172 of 1236 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: osh <osh@osh.cc>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/he/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Polish)

Currently translated at 99.1% (1225 of 1236 strings)

Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pl/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Italian)

Currently translated at 92.8% (1148 of 1236 strings)

Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Arabic)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ar/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1236 of 1236 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/es/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1235 of 1236 strings)

feat(lang): translated using Weblate (French)

Currently translated at 99.9% (1235 of 1236 strings)

Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1236 of 1236 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Finnish)

Currently translated at 2.6% (33 of 1240 strings)

feat(lang): added translation using Weblate (Finnish)

Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Serbian)

Currently translated at 50.8% (630 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Milan Smudja <smudja@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sr/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1240 of 1240 strings)

feat(lang): translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1234 of 1234 strings)

Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/
Translation: Overseerr/Overseerr Frontend

---------

Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Kenneth Hansen <erathor@live.dk>
Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Stjepan <stjepstjepanovic@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: osh <osh@osh.cc>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Milan Smudja <smudja@gmail.com>
Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>

* feat(lang): add lang config for Bulgarian, Finnish, Ukrainian, Indonesian, Slovak, Turkish and Maori (#3834)

* fix: correct deeplinks on iPad (#3883)

* feat(studios): add a24 to studios list (#3902)

* docs: add demrich as a contributor for code (#3906) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat(watchlist): Cache watchlist requests with matching E-Tags (#3901)

* perf(watchlist): add E-Tag caching to Plex watchlist requests

* refactor(watchlist): increase frequency of watchlist requests

* fix: sync watchlist every 3 min instead of 3 sec

* docs: add maxnatamo as a contributor for code (#3907) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* feat(plex): refresh token schedule (#3875)

* feat: refresh token schedule

fix #3861

* fix(i18n): add i18n message

* refactor(plextv): use randomUUID crypto instead custom function

* docs: add DamsDev1 as a contributor for code (#3924) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

* fix: correct icon showing on certain phones when not pulled (#3939)

* feat: add support for requesting "Specials" for TV Shows (#3724)

* feat: add support for requesting "Specials" for TV Shows

This commit is responsible for adding support in Overseerr for requesting "Special" episodes for TV
Shows. This request has become especially pertinent when you consider shows like "Doctor Who". These
shows have Specials that are critical to understanding the plot of a TV show.

fix #779

* chore(yarn.lock): undo inappropriate changes to yarn.lock

I was informed by @sct in a comment on the #3724 PR that it was not appropriate to commit the
changes that ended up being made to the yarn.lock file. This commit is responsible, then, for
undoing the changes to the yarn.lock file that ended up being submitted.

* refactor: change loose equality to strict equality

I received a comment from OwsleyJr pointing out that we are using loose equality when we could
alternatively just be using strict equality to increase the robustness of our code. This commit
does exactly that by squashing out previous usages of loose equality in my commits and replacing
them with strict equality

* refactor: move 'Specials' string to a global message

Owsley pointed out that we are redefining the 'Specials' string multiple times throughout this PR.
Instead, we can just move it as a global message. This commit does exactly that. It squashes out and
previous declarations of the 'Specials' string inside the src files, and moves it directly to the
global messages file.

* docs: add AhmedNSidd as a contributor for code (#3964) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>

---------

Co-authored-by: Isaac M <masesisaac@gmail.com>
Co-authored-by: Joseph Risk <j0srisk@gmail.com>
Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Loetwiek <79059734+Loetwiek@users.noreply.github.com>
Co-authored-by: Loetwiek <lodommerholtcm@gmail.com>
Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
Co-authored-by: Fuochi <ffuochi@hotmail.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Cleiton Carvalho <cleitonsilvacarvalho@gmail.com>
Co-authored-by: Nandor Rusz <nandor.rusz@vodafone.de>
Co-authored-by: Thomas Schöneberg <ta.schoeneberg@gmail.com>
Co-authored-by: Anders Ecklon <aecklon@gmail.com>
Co-authored-by: Kenneth Hansen <erathor@live.dk>
Co-authored-by: BeardedWatermelon <BeardedWatermelon@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <SoundwaveUwU@users.noreply.hosted.weblate.org>
Co-authored-by: SoundwaveUwU <noreply@1000-7.space>
Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Кирилл Тюрин <1337soundwave1337@gmail.com>
Co-authored-by: Don Cezar <goldie.czr@gmail.com>
Co-authored-by: Dragos <themsk@yahoo.com>
Co-authored-by: Eduard Oancea <uberfly@420blaze.it>
Co-authored-by: sct <sctsnipe@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Karel Krýda <karel.kryda@gmail.com>
Co-authored-by: Smexhy <roman.bartik@icloud.com>
Co-authored-by: Bruno Ševčenko <bs3vcenk@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Stjepan <stjepstjepanovic@gmail.com>
Co-authored-by: lpispek <lpispek@gmail.com>
Co-authored-by: Levente Szajkó <leviko112@gmail.com>
Co-authored-by: osh <osh@osh.cc>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Francesco <francy.ammirati@hotmail.com>
Co-authored-by: Fhd-pro <juve.11@msn.com>
Co-authored-by: Kobe <kobaubarr@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Baptiste <baptiste.nee@me.com>
Co-authored-by: Dimitri <dimitridroeck@gmail.com>
Co-authored-by: Maxime Lafarie <maxime.lafarie@gmail.com>
Co-authored-by: Miguel <mig.mllr@gmail.com>
Co-authored-by: asurare <jonathan.biteau16@gmail.com>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Shjosan <shjosan@kakmix.co>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: Eero Konttaniemi <eero.konttaniemi@gmail.com>
Co-authored-by: Milan Smudja <smudja@gmail.com>
Co-authored-by: Developer J <jshsakura@gmail.com>
Co-authored-by: Haohao Zhang <hyacz@foxmail.com>
Co-authored-by: lkw123 <lkw20010211@gmail.com>
Co-authored-by: Jordan Jones <me@jjones.tech>
Co-authored-by: Brandon Cohen <brandon@z3hn.dev>
Co-authored-by: David Emrich <demrich@me.com>
Co-authored-by: Max T. Kristiansen <me@maxtrier.dk>
Co-authored-by: Damien Fajole <60252259+DamsDev1@users.noreply.github.com>
Co-authored-by: Ahmed Siddiqui <36286128+AhmedNSidd@users.noreply.github.com>
2024-11-20 19:33:16 +08:00
Gauthier
39a5ccb7f3 fix(usersettings): allow unset email and add more explicit email error message (#1096) 2024-11-16 16:55:38 +01:00
Gauthier
9b151feb4f fix(ui): allow thetvdb images for unmatched series (#1105)
When a series has no equivalent in TheTVDB used by Sonarr, a popup is displayed to select the series
on TheTVDB. The images in this popup come from artworks.thetvdb.com, which was not an authorized
domain for Next.js images.

fix #1075
2024-11-16 15:33:26 +01:00
Gauthier
fe5d016929 fix(ui): resize streaming service logos (#1106)
The size of streaming service logos has been changed due to the Next.js Image component update.

fix #1103
2024-11-16 15:26:48 +01:00
Gauthier
14f316a9a6 fix: use less strict validation for external URLs (#1104)
* fix: use less strict validation for external URLs

Default url validation from the Yup module doesn't allow URLs like "http://custom-host", while it is
a correct value for an external URL.

fix #1068

* fix: resolve GitHub CodeQL review
2024-11-16 15:26:31 +01:00
Guillaume ARNOUX
5c24e79b1d feat(notifications): improve discord notifications (#1102)
* feat: improve discord notifications

Added a field in the general notification settings to allow a role to be mentioned in the webhook
message via discord notification agent

* feat: add discord role id notification - locales
2024-11-15 18:38:23 +01:00
Joe
ba84212e68 docs: added missing sub_filters in nginx subpath (#1052)
* Update reverse-proxy.mdx

Fix posters not showing up when using sub-folder (at least with NPM)

* Update docs/extending-jellyseerr/reverse-proxy.mdx

Added `avatarproxy` support

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-11-13 10:54:18 +08:00
Gauthier
f25b32aec8 fix: update i18n translations (#1090) 2024-11-12 22:31:15 +01:00
Fallenbagel
5a13226877 refactor(jellyfinapi): improve logging of jellyfin api (#1089)
This commit improves the logging of jellyfin api. This will also fix the wrong logging of throwing
an invalid credentials when jellyfin/emby is unreachable.

re #1053
2024-11-12 22:26:56 +01:00
Gauthier
694913c767 fix(blacklist): request data only when modal is shown, remove useless ratelimit and lazy load blacklist (#1084)
* perf: remove eager load of Blacklist entity from Media entity

Try to resolve some performance issues by removing the eager loading of Blacklist items from the
Media entity

* fix: fix ManageSlideOver for blacklist

* perf(blacklist): request data only when modal is shown

For admin users, the button to blacklist a media (used on every media card) was displaying a Modal,
that was requesting data BEFORE the modal was displayed. This resulted in dozens of additional
requests everytime media cards were displayed.

* perf(blacklist): remove useless ratelimit
2024-11-13 03:01:06 +08:00
Gauthier
a2d2fd3c2a fix(i18n): update extractMessages function for better escaping of characters (#1079)
This PR fix a bug when a translation message has two single quote like "message": "hello 'world'",
the extractMessages function was escaping the message correcly.
2024-11-13 02:58:33 +08:00
Gauthier
cb94ad5a2e docs: fix pnpm install command (#1086) 2024-11-11 23:31:55 +08:00
Gauthier
2829c2548a fix(setup): add leading slash validation for baseUrl (#1083) 2024-11-11 02:51:45 +08:00
Gauthier
64f4610b9f fix: resolve error when setup on second attempt (#1061) 2024-11-06 15:21:19 +08:00
Ludovic Ortega
2d3b777daf docs: migrate to docker compose v2 (#1073)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2024-11-04 22:48:37 +08:00
Fallenbagel
cf59102ef9 fix(externalapi): extract basic auth and pass it through header (#1062)
This commit adds extraction of basic authentication credentials from the URL and then pass the
credentials as the `Authorization` header. And then credentials are removed from the URL before
being passed to fetch. This is done because fetch request cannot be constructed using a URL with
credentials

fix #1027
2024-11-03 14:35:20 +08:00
Gauthier
ca838a00fa feat: add bypass list, bypass local addresses and username/password to proxy setting (#1059)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* feat: add bypass list, bypass local addresses and username/password to proxy setting

This PR adds more options to the proxy setting, like username/password authentication, bypass list
of domains and bypass local addresses. The UX is taken from *arrs.

* fix: add error handling for proxy creating

* fix: remove logs
2024-10-31 16:10:45 +01:00
Gauthier
f2ed101e52 fix: use fs/promises for settings (#1057)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* refactor: add more logs to migration
2024-10-31 15:51:57 +01:00
177 changed files with 6124 additions and 1081 deletions

View File

@@ -448,6 +448,114 @@
"contributions": [
"security"
]
},
{
"login": "j0srisk",
"name": "Joseph Risk",
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
"profile": "http://josephrisk.com",
"contributions": [
"code"
]
},
{
"login": "Loetwiek",
"name": "Loetwiek",
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
"profile": "https://github.com/Loetwiek",
"contributions": [
"code"
]
},
{
"login": "Fuochi",
"name": "Fuochi",
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
"profile": "https://github.com/Fuochi",
"contributions": [
"doc"
]
},
{
"login": "demrich",
"name": "David Emrich",
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
"profile": "https://github.com/demrich",
"contributions": [
"code"
]
},
{
"login": "maxnatamo",
"name": "Max T. Kristiansen",
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
"profile": "https://maxtrier.dk",
"contributions": [
"code"
]
},
{
"login": "DamsDev1",
"name": "Damien Fajole",
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
"profile": "https://damsdev.me",
"contributions": [
"code"
]
},
{
"login": "AhmedNSidd",
"name": "Ahmed Siddiqui",
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
"profile": "https://github.com/AhmedNSidd",
"contributions": [
"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

@@ -18,7 +18,7 @@ config/logs/*
config/*.json
dist
Dockerfile*
docker-compose.yml
compose.yaml
docs
LICENSE
node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore
.editorconfig export-ignore
Dockerfile.local export-ignore
docker-compose.yml export-ignore
compose.yaml export-ignore
stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore

View File

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

View File

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

View File

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

33
.github/workflows/lint-helm-charts.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Lint and Test Charts
on:
pull_request:
branches:
- develop
paths:
- '.github/workflows/lint-helm-charts.yml'
- 'charts/**'
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@v4.2.0
- name: Ensure documentation is updated
uses: docker://jnorwood/helm-docs:v1.14.2
- name: Set up chart-testing
uses: helm/chart-testing-action@v2.6.1
- name: Run chart-testing (list-changed)
id: list-changed
run: |
changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
if [[ -n "$changed" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Run chart-testing
if: steps.list-changed.outputs.changed == 'true'
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --validate-maintainers=false

View File

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

View File

@@ -4,7 +4,7 @@ on:
pull_request:
branches:
- develop
path:
paths:
- 'docs/**'
- 'gen-docs/**'

View File

@@ -9,3 +9,6 @@ pnpm-lock.yaml
src/assets/
public/
docs/
# helm charts
**/charts

View File

@@ -15,5 +15,11 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
{
files: 'charts/**',
options: {
rangeEnd: 0, // default: Infinity
},
},
],
};

View File

@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 20.x)
- [NodeJS](https://nodejs.org/en/download/) (Node 22.x)
- [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads)
@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm
pnpm install
pnpm dev
```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes.
@@ -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>
## 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
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
@@ -36,7 +36,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:20-alpine
FROM node:22-alpine
# Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"

View File

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

View File

@@ -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="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-60-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library.
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring additional support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
## Current Features
@@ -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/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="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>
</tbody>
</table>
@@ -291,6 +307,12 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<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/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
<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/sct/overseerr/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/sct/overseerr/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/sct/overseerr/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/sct/overseerr/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/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
</tr>
<tr>
<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/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@@ -0,0 +1,13 @@
apiVersion: v2
kubeVersion: ">=1.23.0-0"
name: Jellyseerr
description: Jellyseerr helm chart for Kubernetes
type: application
version: 1.1.0
appVersion: "2.1.0"
maintainers:
- name: Jellyseerr
url: https://github.com/Fallenbagel/jellyseerr
sources:
- https://github.com/Fallenbagel/jellyseerr/tree/main/charts/jellyseerr
home: https://github.com/Fallenbagel/jellyseerr

View File

@@ -0,0 +1,69 @@
# Jellyseerr
![Version: 1.1.0](https://img.shields.io/badge/Version-1.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.1.0](https://img.shields.io/badge/AppVersion-2.1.0-informational?style=flat-square)
Jellyseerr helm chart for Kubernetes
**Homepage:** <https://github.com/Fallenbagel/jellyseerr>
## Maintainers
| Name | Email | Url |
| ---- | ------ | --- |
| Jellyseerr | | <https://github.com/Fallenbagel/jellyseerr> |
## Source Code
* <https://github.com/Fallenbagel/jellyseerr/tree/main/charts/jellyseerr>
## Requirements
Kubernetes: `>=1.23.0-0`
## Values
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | |
| autoscaling.enabled | bool | `false` | |
| autoscaling.maxReplicas | int | `100` | |
| autoscaling.minReplicas | int | `1` | |
| autoscaling.targetCPUUtilizationPercentage | int | `80` | |
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
| config.persistence.name | string | `""` | Config name |
| config.persistence.size | string | `"5Gi"` | Size of persistent disk |
| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. |
| extraEnv | list | `[]` | Environment variables to add to the jellyseerr pods |
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods |
| fullnameOverride | string | `""` | |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.registry | string | `"docker.io"` | |
| image.repository | string | `"fallenbagel/jellyseerr"` | |
| image.sha | string | `""` | |
| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. |
| imagePullSecrets | list | `[]` | |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"chart-example.local"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | |
| ingress.ingressClassName | string | `""` | |
| ingress.tls | list | `[]` | |
| nameOverride | string | `""` | |
| nodeSelector | object | `{}` | |
| podAnnotations | object | `{}` | |
| podLabels | object | `{}` | |
| podSecurityContext | object | `{}` | |
| replicaCount | int | `1` | |
| resources | object | `{}` | |
| securityContext | object | `{}` | |
| service.port | int | `80` | |
| service.type | string | `"ClusterIP"` | |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
| serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? |
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
| tolerations | list | `[]` | |

View File

@@ -0,0 +1,17 @@
{{ template "chart.header" . }}
{{ template "chart.deprecationWarning" . }}
{{ template "chart.badgesSection" . }}
{{ template "chart.description" . }}
{{ template "chart.homepageLine" . }}
{{ template "chart.maintainersSection" . }}
{{ template "chart.sourcesSection" . }}
{{ template "chart.requirementsSection" . }}
{{ template "chart.valuesSection" . }}

View File

@@ -0,0 +1,5 @@
***********************************************************************
Welcome to {{ .Chart.Name }}
Chart version: {{ .Chart.Version }}
App version: {{ .Chart.AppVersion }}
***********************************************************************

View File

@@ -0,0 +1,70 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "jellyseerr.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "jellyseerr.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "jellyseerr.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "jellyseerr.labels" -}}
helm.sh/chart: {{ include "jellyseerr.chart" . }}
{{ include "jellyseerr.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/part-of: {{ .Chart.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "jellyseerr.selectorLabels" -}}
app.kubernetes.io/name: {{ include "jellyseerr.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "jellyseerr.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "jellyseerr.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Create the name of the pvc config to use
*/}}
{{- define "jellyseerr.configPersistenceName" -}}
{{- default (printf "%s-config" (include "jellyseerr.fullname" .)) .Values.config.persistence.name }}
{{- end }}

View File

@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyseerr.fullname" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
strategy:
type: {{ .Values.strategy.type }}
selector:
matchLabels:
{{- include "jellyseerr.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "jellyseerr.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "jellyseerr.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
{{- if .Values.image.sha }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}@sha256:{{ .Values.image.sha }}"
{{- else }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- end }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 5055
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.extraEnv }}
env:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.extraEnvFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: config
mountPath: /app/config
volumes:
- name: config
persistentVolumeClaim:
claimName: {{ include "jellyseerr.configPersistenceName" . }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,32 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "jellyseerr.fullname" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "jellyseerr.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "jellyseerr.fullname" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.ingressClassName }}
ingressClassName: {{ .Values.ingress.ingressClassName }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "jellyseerr.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyseerr.configPersistenceName" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
spec:
{{- with .Values.config.persistence.accessModes }}
accessModes:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.config.persistence.volumeName }}
volumeName: {{ .Values.config.persistence.volumeName }}
{{- end }}
{{- with .Values.config.persistence.storageClass }}
storageClassName: {{ if (eq "-" .) }}""{{ else }}{{ . }}{{ end }}
{{- end }}
resources:
requests:
storage: "{{ .Values.config.persistence.size }}"

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyseerr.fullname" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "jellyseerr.selectorLabels" . | nindent 4 }}
ipFamilyPolicy: PreferDualStack

View File

@@ -0,0 +1,13 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "jellyseerr.serviceAccountName" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "jellyseerr.fullname" . }}-test-connection"
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "jellyseerr.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,108 @@
replicaCount: 1
image:
registry: docker.io
repository: fallenbagel/jellyseerr
pullPolicy: IfNotPresent
# -- Overrides the image tag whose default is the chart appVersion.
tag: ""
sha: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# -- Deployment strategy
strategy:
type: Recreate
# -- Environment variables to add to the jellyseerr pods
extraEnv: []
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
extraEnvFrom: []
serviceAccount:
# -- Specifies whether a service account should be created
create: true
# -- Automatically mount a ServiceAccount's API credentials?
automount: true
# -- Annotations to add to the service account
annotations: {}
# -- The name of the service account to use.
# -- If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
# -- Creating PVC to store configuration
config:
persistence:
# -- Size of persistent disk
size: 5Gi
# -- Annotations for PVCs
annotations: {}
# -- Access modes of persistent disk
accessModes:
- ReadWriteOnce
# -- Config name
name: ""
# -- Name of the permanent volume to reference in the claim.
# Can be used to bind to existing volumes.
volumeName: ""
ingress:
enabled: false
ingressClassName: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -1,4 +1,3 @@
version: '3'
services:
jellyseerr:
build:

View File

@@ -16,11 +16,13 @@
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"discoverRegion": "",
"streamingRegion": "",
"originalLanguage": "",
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"enableSpecialEpisodes": false,
"locale": "en"
},
"plex": {
@@ -75,6 +77,7 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},
@@ -98,6 +101,7 @@
"options": {
"botAPI": "",
"chatId": "",
"messageThreadId": "",
"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.
- Granular permission system.
- Localization into other languages.
- Support for PostgreSQL and SQLite databases.
- More features to come!
## 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 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

@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
@@ -190,7 +192,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2)
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
Add the following labels to the Jellyseerr service in your `compose.yaml` file:
```yaml
labels:

View File

@@ -12,7 +12,7 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
### Prerequisites
- [Node.js 20.x](https://nodejs.org/en/download/)
- [Node.js 22.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)
@@ -26,7 +26,7 @@ sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
```bash
git clone https://github.com/Fallenbagel/jellyseerr.git
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:
```bash
@@ -58,9 +58,6 @@ PORT=5055
## specify on which interface to listen, by default jellyseerr listens on all interfaces
#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)
# FORCE_IPV4_FIRST=true
```
@@ -203,7 +200,7 @@ cd C:\jellyseerr
2. Clone the Jellyseerr repository and checkout the develop branch:
```powershell
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:
```powershell

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation:
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
Define the `jellyseerr` service in your `compose.yaml` as follows:
```yaml
---
services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file:
```bash
docker-compose up -d
docker compose up -d
```
#### Updating:
Pull the latest image:
```bash
docker-compose pull jellyseerr
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker-compose up -d
docker compose up -d
```
:::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
@@ -145,6 +145,16 @@ Then, create and start the Jellyseerr container:
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
@@ -167,6 +177,16 @@ services:
volumes:
jellyseerr-data:
external: true
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
</Tabs>
@@ -185,3 +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.)
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.
:::

158
docs/troubleshooting.mdx Normal file
View File

@@ -0,0 +1,158 @@
---
title: Troubleshooting
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
## [TMDB] failed to retrieve/fetch XXX
### Option 1: Change your DNS servers
This error often comes from your Internet Service Provider (ISP) blocking TMDB API. The ISP may block the DNS resolution to the TMDB API hostname.
To fix this, you can change your DNS servers to a public DNS service like Google's DNS or Cloudflare's DNS:
<Tabs groupId="methods" queryString>
<TabItem value="docker-cli" label="Docker CLI">
Add the following to your `docker run` command to use Google's DNS:
```bash
--dns=8.8.8.8
```
or for Cloudflare's DNS:
```bash
--dns=1.1.1.1
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
Add the following to your `compose.yaml` to use Google's DNS:
```yaml
---
services:
jellyseerr:
dns:
- 8.8.8.8
```
or for Cloudflare's DNS:
```yaml
---
services:
jellyseerr:
dns:
- 1.1.1.1
```
</TabItem>
<TabItem value="windows" label="Windows">
1. Open the Control Panel.
2. Click on Network and Internet.
3. Click on Network and Sharing Center.
4. Click on Change adapter settings.
5. Right-click the network interface connected to the internet and select Properties.
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
</TabItem>
<TabItem value="linux" label="Linux">
1. Open a terminal.
2. Edit the `/etc/resolv.conf` file with your favorite text editor.
3. Add the following line to use Google's DNS:
```bash
nameserver 8.8.8.8
```
or for Cloudflare's DNS:
```bash
nameserver 1.1.1.1
```
</TabItem>
</Tabs>
### Option 2: Force IPV4 resolution first
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`:
<Tabs groupId="methods" queryString>
<TabItem value="docker-cli" label="Docker CLI">
Add the following to your `docker run` command:
```bash
-e "FORCE_IPV4_FIRST=true"
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
Add the following to your `compose.yaml`:
```yaml
---
services:
jellyseerr:
environment:
- FORCE_IPV4_FIRST=true
```
</TabItem>
</Tabs>
### Option 3: Use Jellyseerr through a proxy
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API.
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
### Option 4: Check that your server can reach TMDB API
Make sure that your server can reach the TMDB API by running the following command:
<Tabs groupId="methods" queryString>
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker exec -it jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org"
```
</TabItem>
<TabItem value="docker-compose" label="Docker Compose">
```bash
docker compose exec jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org"
```
</TabItem>
<TabItem value="linux" label="Linux">
In a terminal:
```bash
curl -L https://api.themoviedb.org
```
</TabItem>
<TabItem value="windows" label="Windows">
In a PowerShell window:
```powershell
(Invoke-WebRequest -Uri "https://api.themoviedb.org" -Method Get).Content
```
</TabItem>
</Tabs>
If you can't get a response, then your server can't reach the TMDB API.
This is usually due to a network configuration issue or a firewall blocking the connection.

View File

@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Notification Role ID (optional)
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -58,9 +58,9 @@ You should enable this if you are having issues with loading images directly fro
Set the default display language for Jellyseerr. Users can override this setting in their user settings.
## Discover Region & Discover Language
## Discover Region, Discover Language & Streaming Region
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
## Hide Available Media

View File

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

View File

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

View File

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

View File

@@ -143,10 +143,12 @@ components:
properties:
locale:
type: string
region:
discoverRegion:
type: string
originalLanguage:
type: string
streamingRegion:
type: string
MainSettings:
type: object
properties:
@@ -186,6 +188,9 @@ components:
defaultPermissions:
type: number
example: 32
enableSpecialEpisodes:
type: boolean
example: false
PlexLibrary:
type: object
properties:
@@ -1273,6 +1278,8 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -1334,6 +1341,8 @@ components:
type: string
chatId:
type: string
messageThreadId:
type: string
sendSilently:
type: boolean
PushbulletSettings:
@@ -1817,6 +1826,9 @@ components:
telegramChatId:
type: string
nullable: true
telegramMessageThreadId:
type: string
nullable: true
telegramSendSilently:
type: boolean
nullable: true
@@ -1930,6 +1942,11 @@ components:
type: string
native_name:
type: string
OverrideRule:
type: object
properties:
id:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -3753,6 +3770,11 @@ paths:
type: string
enum: [created, updated, requests, displayname]
default: created
- in: query
name: q
required: false
schema:
type: string
responses:
'200':
description: A JSON array of all users
@@ -3869,7 +3891,7 @@ paths:
schema:
type: object
properties:
jellyfinIds:
jellyfinUserIds:
type: array
items:
type: string
@@ -4142,6 +4164,21 @@ paths:
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete:
summary: Remove media from blacklist
tags:
@@ -5419,6 +5456,13 @@ paths:
type: string
enum: [added, modified]
default: added
- in: query
name: sortDirection
schema:
type: string
enum: [asc, desc]
nullable: true
default: desc
- in: query
name: requestedBy
schema:
@@ -5469,7 +5513,7 @@ paths:
- type: array
items:
type: number
minimum: 1
minimum: 0
- type: string
enum: [all]
is4k:
@@ -5575,7 +5619,7 @@ paths:
type: array
items:
type: number
minimum: 1
minimum: 0
is4k:
type: boolean
example: false
@@ -6939,6 +6983,68 @@ paths:
type: array
items:
$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:
- cookieAuth: []
- apiKey: []

View File

@@ -69,6 +69,7 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"pg": "8.11.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"react": "^18.3.1",
@@ -122,7 +123,7 @@
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node": "22.10.5",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
@@ -168,7 +169,7 @@
"typescript": "4.9.5"
},
"engines": {
"node": "^20.0.0",
"node": "^22.0.0",
"pnpm": "^9.0.0"
},
"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 rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache';
@@ -14,6 +16,10 @@ interface ExternalAPIOptions {
rateLimit?: RateLimitOptions;
}
interface CustomRequestConfig extends RequestInit {
params?: Record<string, unknown>;
}
class ExternalAPI {
protected fetch: typeof fetch;
protected params: Record<string, string>;
@@ -32,13 +38,32 @@ class ExternalAPI {
this.fetch = fetch;
}
this.baseUrl = baseUrl;
this.params = params;
const url = new URL(baseUrl);
const settings = getSettings();
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...((url.username || url.password) && {
Authorization: `Basic ${Buffer.from(
`${url.username}:${url.password}`
).toString('base64')}`,
}),
...(settings.main.mediaServerType === MediaServerType.EMBY && {
'Accept-Encoding': 'gzip',
}),
...options.headers,
};
if (url.username || url.password) {
url.username = '';
url.password = '';
baseUrl = url.toString();
}
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache;
}
@@ -46,12 +71,11 @@ class ExternalAPI {
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
config?: CustomRequestConfig
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, config?.params, headers);
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
@@ -272,6 +296,14 @@ class ExternalAPI {
return data;
}
protected removeCache(endpoint: string, params?: Record<string, string>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
this.cache?.del(cacheKey);
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
@@ -296,13 +328,15 @@ class ExternalAPI {
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
params?: Record<string, unknown>,
headers?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
const key = `${this.baseUrl}${endpoint}`;
if (!params && !headers) {
return key;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
return `${key}${JSON.stringify({ params, headers })}`;
}
private async getDataFromResponse(response: Response) {

View File

@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
});
if (!e.cause.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
const status = e.cause?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
logger.error(
'Something went wrong while authenticating with the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
}
}
@@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting libraries from the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
);
return [];
@@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
@@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -3,6 +3,7 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
interface PlexAccountResponse {
@@ -127,6 +128,11 @@ export interface PlexWatchlistItem {
title: string;
}
export interface PlexWatchlistCache {
etag: string;
response: WatchlistResponse;
}
class PlexTvAPI extends ExternalAPI {
private authToken: string;
@@ -261,6 +267,11 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[];
}> {
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
this.authToken
);
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
@@ -268,42 +279,62 @@ class PlexTvAPI extends ExternalAPI {
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{
headers: this.defaultHeaders,
headers: {
...this.defaultHeaders,
...(cachedWatchlist?.etag
? { 'If-None-Match': cachedWatchlist.etag }
: {}),
},
}
);
const data = (await response.json()) as WatchlistResponse;
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
if (response.status >= 200 && response.status <= 299) {
cachedWatchlist = {
etag: response.headers.get('etag') ?? '',
response: data,
};
watchlistCache.data.set<PlexWatchlistCache>(
this.authToken,
cachedWatchlist
);
}
const watchlistDetails = await Promise.all(
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
})
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -311,7 +342,7 @@ class PlexTvAPI extends ExternalAPI {
return {
offset,
size,
totalSize: data.MediaContainer.totalSize,
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
items: filteredList,
};
} catch (e) {
@@ -327,6 +358,29 @@ class PlexTvAPI extends ExternalAPI {
};
}
}
public async pingToken() {
try {
const data: { pong: unknown } = await this.get(
'/api/v2/ping',
{},
undefined,
{
headers: {
'X-Plex-Client-Identifier': randomUUID(),
},
}
);
if (!data?.pong) {
throw new Error('No pong response');
}
} catch (e) {
logger.error('Failed to ping token', {
label: 'Plex Refresh Token',
errorMessage: e.message,
});
}
}
}
export default PlexTvAPI;

View File

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

View File

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

View File

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

View File

@@ -99,12 +99,12 @@ interface DiscoverTvOptions {
}
class TheMovieDb extends ExternalAPI {
private region?: string;
private discoverRegion?: string;
private originalLanguage?: string;
constructor({
region,
discoverRegion,
originalLanguage,
}: { region?: string; originalLanguage?: string } = {}) {
}: { discoverRegion?: string; originalLanguage?: string } = {}) {
super(
'https://api.themoviedb.org/3',
{
@@ -118,7 +118,7 @@ class TheMovieDb extends ExternalAPI {
},
}
);
this.region = region;
this.discoverRegion = discoverRegion;
this.originalLanguage = originalLanguage;
}
@@ -469,7 +469,7 @@ class TheMovieDb extends ExternalAPI {
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
region: this.region || '',
region: this.discoverRegion || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
@@ -541,7 +541,7 @@ class TheMovieDb extends ExternalAPI {
sort_by: sortBy,
page: page.toString(),
language,
region: this.region || '',
region: this.discoverRegion || '',
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
@@ -594,7 +594,7 @@ class TheMovieDb extends ExternalAPI {
{
page: page.toString(),
language,
region: this.region || '',
region: this.discoverRegion || '',
originalLanguage: this.originalLanguage || '',
}
);
@@ -620,7 +620,7 @@ class TheMovieDb extends ExternalAPI {
{
page: page.toString(),
language,
region: this.region || '',
region: this.discoverRegion || '',
}
);

View File

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

View File

@@ -1,7 +1,43 @@
import 'reflect-metadata';
import fs from 'fs';
import type { TlsOptions } from 'tls';
import type { DataSourceOptions, EntityTarget, Repository } 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 = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
@@ -9,10 +45,10 @@ const devConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
migrations: ['server/migration/sqlite/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
@@ -23,16 +59,56 @@ const prodConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: false,
migrationsRun: false,
logging: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrations: ['dist/migration/sqlite/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
const postgresDevConfig: 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: 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>(
target: EntityTarget<Entity>

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
blacklist: Promise.resolve(blacklist),
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -10,6 +10,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
@@ -42,6 +43,10 @@ class Media {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
return [];
}
const media = await mediaRepository
.createQueryBuilder('media')
.leftJoinAndSelect(
@@ -118,10 +123,8 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@CreateDateColumn()
public createdAt: Date;
@@ -129,10 +132,23 @@ class Media {
@UpdateDateColumn()
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;
@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;
@Column({ nullable: true, type: 'int' })

View File

@@ -7,12 +7,14 @@ import type {
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
@@ -57,6 +59,7 @@ export class MediaRequest {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
const settings = getSettings();
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) {
await mediaRepository.save(media);
@@ -243,9 +374,9 @@ export class MediaRequest {
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
profileId: profileId,
rootFolder: rootFolder,
tags: tags,
isAutoRequest: options.isAutoRequest ?? false,
});
@@ -255,12 +386,14 @@ export class MediaRequest {
const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
let requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
? tmdbMediaShow.seasons.map((season) => season.season_number)
: (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
@@ -346,10 +479,10 @@ export class MediaRequest {
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
profileId: profileId,
rootFolder: rootFolder,
languageProfileId: requestBody.languageProfileId,
tags: requestBody.tags,
tags: tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
@@ -586,10 +719,15 @@ export class MediaRequest {
// Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE
MediaStatus.PARTIALLY_AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
mediaRepository.save(media);
const statusField = this.is4k ? 'status4k' : 'status';
await mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.PROCESSING }
);
}
if (
@@ -859,7 +997,7 @@ export class MediaRequest {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
await requestRepository.save(this);
logger.warn(
'Something went wrong sending movie request to Radarr, marking status as FAILED',
@@ -872,6 +1010,14 @@ export class MediaRequest {
);
this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
radarr.clearCache({
tmdbId: movie.id,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
});
});
logger.info('Sent request to Radarr', {
label: 'Media Request',
@@ -1129,18 +1275,23 @@ export class MediaRequest {
throw new Error('Media data not found');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
await mediaRepository.save(media);
const updateFields = {
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
sonarrSeries.id,
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
sonarrSeries.titleSlug,
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
};
await mediaRepository.update({ id: this.media.id }, updateFields);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
await requestRepository.update(
{ id: this.id },
{ status: MediaRequestStatus.FAILED }
);
logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -1153,6 +1304,15 @@ export class MediaRequest {
);
this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
sonarr.clearCache({
tvdbId,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
title: series.name,
});
});
logger.info('Sent request to Sonarr', {
label: 'Media Request',

View File

@@ -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 })
public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
@ManyToOne(() => Media, (media) => media.seasons, {
onDelete: 'CASCADE',
})
public media: Promise<Media>;
@CreateDateColumn()

View File

@@ -31,7 +31,10 @@ export class UserSettings {
public locale?: string;
@Column({ nullable: true })
public region?: string;
public discoverRegion?: string;
@Column({ nullable: true })
public streamingRegion?: string;
@Column({ nullable: true })
public originalLanguage?: string;
@@ -57,6 +60,9 @@ export class UserSettings {
@Column({ nullable: true })
public telegramChatId?: string;
@Column({ nullable: true })
public telegramMessageThreadId?: string;
@Column({ nullable: true })
public telegramSendSilently?: boolean;

View File

@@ -1,5 +1,5 @@
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 { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
@@ -66,9 +66,13 @@ app
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
if (isPgsql) {
await dbConnection.runMigrations();
} else {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
}
// Load Settings

View File

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

View File

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

View File

@@ -32,10 +32,12 @@ export interface PublicSettingsResponse {
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;

View File

@@ -5,7 +5,8 @@ export interface UserSettingsGeneralResponse {
email?: string;
discordId?: string;
locale?: string;
region?: string;
discoverRegion?: string;
streamingRegion?: string;
originalLanguage?: string;
movieQuotaLimit?: number;
movieQuotaDays?: number;
@@ -33,6 +34,7 @@ export interface UserSettingsNotificationsResponse {
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: string;
telegramMessageThreadId?: string;
telegramSendSilently?: boolean;
webPushEnabled?: boolean;
notificationTypes: Partial<NotificationAgentTypes>;

View File

@@ -2,6 +2,7 @@ import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import refreshToken from '@server/lib/refreshToken';
import {
jellyfinFullScanner,
jellyfinRecentScanner,
@@ -13,7 +14,6 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -113,30 +113,20 @@ export const startJobs = (): void => {
}
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
@@ -233,5 +223,19 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'plex-refresh-token',
name: 'Plex Refresh Token',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-refresh-token'].schedule,
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
logger.info('Starting scheduled job: Plex Refresh Token', {
label: 'Jobs',
});
refreshToken.run();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

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

View File

@@ -291,6 +291,10 @@ class DiscordAgent
}
}
if (settings.options.webhookRoleId) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {

View File

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

View File

@@ -0,0 +1,37 @@
import PlexTvAPI from '@server/api/plextv';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import logger from '@server/logger';
class RefreshToken {
public async run() {
const userRepository = getRepository(User);
const users = await userRepository
.createQueryBuilder('user')
.addSelect('user.plexToken')
.where("user.plexToken != ''")
.getMany();
for (const user of users) {
await this.refreshUserToken(user);
}
}
private async refreshUserToken(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user refresh token for user without plex token', {
label: 'Plex Refresh Token',
user: user.displayName,
});
return;
}
const plexTvApi = new PlexTvAPI(user.plexToken);
plexTvApi.pingToken();
}
}
const refreshToken = new RefreshToken();
export default refreshToken;

View File

@@ -210,14 +210,27 @@ class JellyfinScanner {
return;
}
if (metadata.ProviderIds.Tvdb) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb),
});
} else if (metadata.ProviderIds.Tmdb) {
tvShow = await this.tmdb.getTvShow({
tvId: Number(metadata.ProviderIds.Tmdb),
});
if (metadata.ProviderIds.Tmdb) {
try {
tvShow = await this.tmdb.getTvShow({
tvId: Number(metadata.ProviderIds.Tmdb),
});
} catch {
this.log('Unable to find TMDb ID for this title.', 'debug', {
jellyfinitem,
});
}
}
if (!tvShow && metadata.ProviderIds.Tvdb) {
try {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb),
});
} catch {
this.log('Unable to find TVDb ID for this title.', 'debug', {
jellyfinitem,
});
}
}
if (tvShow) {
@@ -491,7 +504,13 @@ class JellyfinScanner {
}
});
} else {
this.log(`failed show: ${metadata.Name}`);
this.log(
`No information found for the show: ${metadata.Name}`,
'debug',
{
jellyfinitem,
}
);
}
} catch (e) {
this.log(

View File

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

View File

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

View File

@@ -76,6 +76,7 @@ export interface DVRSettings {
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
overrideRule: number[];
}
export interface RadarrSettings extends DVRSettings {
@@ -124,11 +125,13 @@ export interface MainSettings {
hideAvailable: boolean;
localLogin: boolean;
newPlexLogin: boolean;
region: string;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
trustProxy: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
locale: string;
proxy: ProxySettings;
}
@@ -144,13 +147,15 @@ interface FullPublicSettings extends PublicSettings {
localLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
mediaServerType: number;
jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
@@ -170,6 +175,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
};
}
@@ -210,6 +216,7 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig {
botUsername?: string;
botAPI: string;
chatId: string;
messageThreadId: string;
sendSilently: boolean;
};
}
@@ -281,6 +288,7 @@ export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'plex-refresh-token'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@@ -331,11 +339,13 @@ class Settings {
hideAvailable: false,
localLogin: true,
newPlexLogin: true,
region: '',
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
locale: 'en',
proxy: {
enabled: false,
@@ -394,6 +404,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},
@@ -417,6 +428,7 @@ class Settings {
options: {
botAPI: '',
chatId: '',
messageThreadId: '',
sendSilently: false,
},
},
@@ -467,7 +479,10 @@ class Settings {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
schedule: '0 */3 * * * *',
},
'plex-refresh-token': {
schedule: '0 0 5 * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',
@@ -570,10 +585,12 @@ class Settings {
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
region: this.data.main.region,
discoverRegion: this.data.main.discoverRegion,
streamingRegion: this.data.main.streamingRegion,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
enableSpecialEpisodes: this.data.main.enableSpecialEpisodes,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,
enablePushRegistration: this.data.notifications.agents.webpush.enabled,
@@ -682,10 +699,9 @@ class Settings {
}
public async save(): Promise<void> {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
const tmp = SETTINGS_PATH + '.tmp';
await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' '));
await fs.rename(tmp, SETTINGS_PATH);
}
}

View File

@@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
try {
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
settings.jellyfin.apiKey = apiKey;
} catch {
throw new Error(
"Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue."
);
}
}
return settings;
};

View File

@@ -0,0 +1,17 @@
import type { AllSettings } from '@server/lib/settings';
const migrateRegionSetting = (settings: any): AllSettings => {
const oldRegion = settings.main.region;
if (oldRegion) {
settings.main.discoverRegion = oldRegion;
settings.main.streamingRegion = oldRegion;
} else {
settings.main.discoverRegion = '';
settings.main.streamingRegion = 'US';
}
delete settings.main.region;
return settings;
};
export default migrateRegionSetting;

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger';
import fs from 'fs/promises';
@@ -45,10 +44,20 @@ export const runMigrations = async (
}
migrated = newSettings;
} catch (e) {
logger.error(`Error while running migration '${migration}'`, {
label: 'Settings Migrator',
});
throw e;
// we stop jellyseerr if the migration failed
logger.error(
`Error while running migration '${migration}': ${e.message}`,
{
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
{
label: 'Settings Migrator',
}
);
process.exit();
}
}
@@ -72,22 +81,18 @@ export const runMigrations = async (
await fs.writeFile(BACKUP_PATH, oldBackup.toString());
}
} catch (e) {
// we stop jellyseerr if the migration failed
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
{
label: 'Settings Migrator',
}
);
// we stop jellyseerr if the migration failed
console.log(
'===================================================================='
);
console.log(
' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS '
);
console.log(
' Please check that your configuration folder is properly set up '
);
console.log(
'===================================================================='
logger.error(
'A common cause for this issue is a permission error of your configuration folder.',
{
label: 'Settings Migrator',
}
);
process.exit();
}

View File

@@ -62,7 +62,7 @@ class WatchlistSync {
const plexTvApi = new PlexTvAPI(user.plexToken);
const response = await plexTvApi.getWatchlist({ size: 200 });
const response = await plexTvApi.getWatchlist({ size: 20 });
const mediaItems = await Media.getRelatedMedia(
user,

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

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