Compare commits

...

31 Commits

Author SHA1 Message Date
fallenbagel
30b441efcb fix: fixes pending media removal after request deletion
This fixes MORE TPYEORM LIFECYCLE HOOK ISSUES for postgresql. Current behavior is after media
request is removed (either by cancellation or deletion) the media remains as `pending` unless a user
with `manage requests` clears the media data, on **POSTGRESQL** database. With this change, the
media status will be changed to `UKNOWN` with the media request removal. TLDR; Though the
@AfterRemove() hook in MediaRequest works fine in SQLite it doesn't reliably update the media status
in PostgreSQL which is likely due to transaction timing or relation loading differences between the
two databases
2025-04-22 13:40:53 +08:00
fallenbagel
355b76de5c fix(imagecache): fix avatar cache folder creation (#1581)
This fixes a regression caused by #1520 which broke the file extension detection thereby breaking
avatars
2025-04-12 02:24:13 +08:00
fallenbagel
b7b05d31a5 Fix episode images url not allowed (#1580)
* fix: fix url parameter not allowed

* fix: use cached image for episode images

* refactor: remove console logs
2025-04-12 01:08:01 +08:00
allcontributors[bot]
f3a895fa7d docs: add benbeauchamp7 as a contributor for code (#1579)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-04-11 22:49:26 +08:00
Ben Beauchamp
4a5ac3cc42 feat(blacklist): Automatically add media with blacklisted tags to the blacklist (#1306)
* feat(blacklist): add blacktag settings to main settings page

* feat(blacklist): create blacktag logic and infrastructure

* feat(blacklist): add scheduling for blacktags job

* feat(blacklist): create blacktag ui badge for blacklist

* docs(blacklist): document blacktags in using-jellyseerr

* fix(blacklist): batch blacklist and media db removes to avoid expression tree too large error

* feat(blacklist): allow easy import and export of blacktag configuration

* fix(settings): don't copy the API key every time you press enter on the main settings

* fix(blacklist): move filter inline with page title to match all the other pages

* feat(blacklist): allow filtering between manually blacklisted and automatically blacklisted entries

* docs(blacklist): reword blacktag documentation a little

* refactor(blacklist): remove blacktag settings from public settings interfaces

There's no reason for it to be there

* refactor(blacklist): remove unused variable from processResults in blacktagsProcessor

* refactor(blacklist): change all instances of blacktag to blacklistedTag and update doc to match

* docs(blacklist): update general documentation for blacklisted tag settings

* fix(blacklist): update setting use of "blacklisted tag" to match between modals

* perf(blacklist): remove media type constraint from existing blacklist entry query

Doesn't make sense to keep it because tmdbid has a unique constraint on it

* fix(blacklist): remove whitespace line causing prettier to fail in CI

* refactor(blacklist): swap out some != and == for !s and _s

* fix(blacklist): merge back CopyButton changes, disable button when there's nothing to copy

* refactor(blacklist): use axios instead of fetch for blacklisted tag queries

* style(blacklist): use templated axios types and remove redundant try-catches
2025-04-11 22:48:44 +08:00
Gauthier
a488f850f3 refactor: switch from Fetch API to Axios (#1520)
* refactor: switch from Fetch API to Axios

* fix: remove unwanted changes

* fix: rewrite error handling for Axios and remove IPv4 first setting

* style: run prettier

* style: run prettier

* fix: add back custom proxy agent

* fix: add back custom proxy agent

* fix: correct rebase issue

* fix: resolve review comments
2025-04-08 13:20:10 +02:00
Nathan Lemmon
21400cecdc feat(gotify): added priority input for gotify (#1410)
* feat(gotify notification): added priority input for gotify

Added priority field for gotify messages on the gotify settings page

issue 562

* feat(gotify notification): added requested changes

fixed json end of file new line, removed unused code, added default priority for previous
configurations

* feat(gotify notifcation): fixed cypress/config/settings.cypress.json

fixed cypress/config/settings.cypress.json

* Update cypress/config/settings.cypress.json

Removed extra line from settings.cypress.json

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

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2025-04-06 16:49:43 +02:00
allcontributors[bot]
5a6ff61f64 docs: add corentinnormand as a contributor for code (#1559)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-04-05 01:42:28 +08:00
Corentin Normand
14ee52e93e feat(discord.ts): adds a link to the pending approval discord notification (#436)
Co-authored-by: Corentin Normand <corentin.normand@swan.io>
2025-04-05 01:41:54 +08:00
allcontributors[bot]
4cf799d6eb docs: add brotaxt as a contributor for code (#1558)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-04-05 01:32:02 +08:00
allcontributors[bot]
bea57c330a docs: add jessielw as a contributor for code (#1556)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-04-05 01:29:00 +08:00
Jessie Wilson
7d36dc182b feat: now uses markdown linebreaks instead of relying purely on newlines (#1514)
refactor: displayUrl now applies an ellipsis if the url is longer than 40 characters

Co-authored-by: jesterr0 <jlw_4049@proton.me>
2025-04-05 01:27:30 +08:00
DominicKo
5865478a3b rebased on latest development branch (#1482)
Co-authored-by: a17f885 <{ID}+{username}@users.noreply.github.com>
2025-04-05 01:26:32 +08:00
Ludovic Ortega
90c58de9b2 chore(helm): bump jellyseerr to 2.5.2 (#1551)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-04-04 06:38:48 +08:00
Gauthier
2f6be955b5 fix(job): rename Plex Sync to Jellyfin Sync (#1549)
Some logs for the Jellyfin scanners were labelled 'Plex Sync' instead of 'Jellyfin Sync', leading to
confusion
2025-04-03 18:15:39 +02:00
Kugelstift
85bbc85714 fix(auth): Bitwarden autofill fix on local/Jellyfin login (2) (#1487)
* Update JellyfinLogin.tsx

* Update LocalLogin.tsx

* Update index.tsx

* Update index.tsx

prettier

* Update JellyfinLogin.tsx

* Update LocalLogin.tsx

* Update index.tsx
2025-04-02 19:34:06 +08:00
Gauthier
8dc1d8196c fix: correct "Remove from *arr" button (#1544)
This PR fixes the "Delete from *arr" button in the request list. It checks from the API whether the
*arr server corresponding to the request still exists before displaying the remove button, and fixes
a cache removal issue that could cause problems when deleting recently added media. This PR also
reverts #1476, which introduced problems during removal.

fix #1494
2025-04-01 02:06:33 +08:00
Gauthier
63dc27d400 fix(job): handle media removal for 4k on the same server (#1543)
This PR fixes a bug where the avaibility sync job was not removing properly 4k items when the same
Radarr server was used for both non-4k and 4k media.
2025-03-31 10:40:30 +02:00
fallenbagel
29034b350d fix(avatar): fix avatar cache busting by using avatarVersion (#1537)
* fix(avatar): fix avatar cache busting by using avatarVersion

Previously, avatar caching did not update the avatar when the remote image changed. This commit adds
logic to check if the avatar was modified remotely by comparing aremote last-modified timestamp with
a locally stored version (avatarVersion). If a change is detected, the cache is cleared, a new image
is fetched, and avatarVersionis updated. Otherwise, the cached image is retained.

* chore(db): add db migrations

* refactor: refactor imagehelpers util to where its used

* refactor: remove remnants from previous cache busting versions
2025-03-28 06:02:34 +08:00
fallenbagel
7438042757 fix(jellyfin): ensure deviceID is never empty (#1538)
If the deviceID becomes an empty string, login fails since jellyfin
requires a non-null deviceID. This commit adds a fallback to guarantee
that deviceID is always set, preventing accidental lockout.
2025-03-28 05:21:47 +08:00
Gauthier
0b0b76e58c fix(migrations): add missing Postgres migration and fix SQLite migration (#1532)
This PR adds the missing migration for PostgreSQL and fix the migration for SQLite.

re #1466
2025-03-28 04:27:07 +08:00
Ludovic Ortega
a5cb505609 chore(docs): use ghcr.io registry instead of dockerhub (#1531)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-03-27 05:26:06 +08:00
fallenbagel
7cb127ec3f chore: fix linting and formatting issues (#1530) 2025-03-27 00:19:05 +08:00
Gauthier
1635932375 chore: merge upstream (#1466)
* 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>

* feat(lang): Translations update from Hosted Weblate (#3835)

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 100.0% (1240 of 1240 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Catalan)

Currently translated at 100.0% (1241 of 1241 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/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ca/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.2% (1231 of 1240 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Polish)

Currently translated at 98.8% (1227 of 1241 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Dutch)

Currently translated at 100.0% (1241 of 1241 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/nl/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (Spanish)

Currently translated at 100.0% (1241 of 1241 strings)

Co-authored-by: Frostar <dasangra@hotmail.com>
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/
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)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (French)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fr/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Swedish)

Currently translated at 100.0% (1241 of 1241 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)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
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.9% (36 of 1240 strings)

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/fi/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Albanian)

Currently translated at 95.8% (1189 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: W L <wl@mailhole.de>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sq/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1241 of 1241 strings)

feat(lang): translated using Weblate (Korean)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ko/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 98.4% (1221 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rafael Souto <git@rafaelsouto.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_PT/
Translation: Overseerr/Overseerr Frontend

* feat(lang): translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.9% (1239 of 1240 strings)

Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Chinese (Traditional Han script))

Currently translated at 98.2% (1219 of 1241 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: dtalens <databio@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Norwegian Bokmål)

Currently translated at 89.9% (1115 of 1240 strings)

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

---------

Co-authored-by: Димитър Мазнеков (Topper) <d.maznekov@gmail.com>
Co-authored-by: Michael Michael <michaelvelosk@gmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: Dargo <fuszi88@gmail.com>
Co-authored-by: senza <senza@users.noreply.hosted.weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Co-authored-by: Frostar <dasangra@hotmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Co-authored-by: Per Erik <urbanlolface@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: W L <wl@mailhole.de>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Co-authored-by: Rafael Souto <git@rafaelsouto.com>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: exentler <gurandsrud@gmail.com>

* feat(ui): prevent password manager interference & improve service links (#3989)

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix(ui): update Plex Logo (#3955)

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat: requests/issues menu count (#3470)

* feat: request and issue count added to sidebar/mobile menu

* fix: added permission check for count visibility

* refactor: modified badge design for count

* fix: properly update issue and request counts in certain scenarios (#4051)

* fix: center count badge on sidebar and mobile menu (#4052)

* fix: request english trailers as a fallback when using other languages (#4009)

Co-authored-by: Stancu Florin <florin@stancu.me>

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* feat: added the PWA badge indicator for requests pending (#3411)

refactor: removed unnecessary code when sending web push notification

fix: moved all notify user logic into webpush

refactor: n

refactor: remove all unnecessary prettier changes

fix: n

fix: n

fix: n

fix: n

fix: increment sw version

fix: n

* fix: improve count badge styling (#4056)

* fix: improved web push management (#3421)

refactor: organized placement of new button + added comments

fix: added api routes for push registration

fix: modified get request to confirm key identity

fix: added back notification types to always show

feat: added a manageable device list

refactor: modified device list to make it mobile friendly

fix: correct typo for enabling notifications

* Revert "fix: improved web push management (#3421)" (#4058)

* fix: manage webpush notifications (#4059)

* feat(lang): Translations update from Hosted Weblate (#4025)

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/pt_BR/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (German)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Rico <rico.jambor@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

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

Currently translated at 40.8% (507 of 1240 strings)

Co-authored-by: George L <lazugeorgem@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ro/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Ukrainian)

Currently translated at 100.0% (1240 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Yaroslav Buzko <yaroslav@buzko.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/uk/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.9% (1239 of 1240 strings)

feat(lang): translated using Weblate (Hungarian)

Currently translated at 99.7% (1237 of 1240 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/hu/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

feat(lang): translated using Weblate (Italian)

Currently translated at 95.3% (1182 of 1240 strings)

feat(lang): translated using Weblate (Italian)

Currently translated at 95.3% (1182 of 1240 strings)

Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/
Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/it/
Translation: Overseerr/Overseerr Frontend

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* feat(lang): added translation using Weblate (Slovenian)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: sct <sctsnipe@gmail.com>

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

---------

Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Rico <rico.jambor@gmail.com>
Co-authored-by: George L <lazugeorgem@gmail.com>
Co-authored-by: Yaroslav Buzko <yaroslav@buzko.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: sct <sctsnipe@gmail.com>

* fix: change localhost to process.env.HOST for client requests (#3839)

* Change localhost to process.env.HOST for client requests

* refactor: reformat

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

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix: set the correct TTL for the cookie store (#3946)

The time-to-live (TTL) of cookies stored in the database was incorrect because the connect-typeorm
library takes a TTL in seconds and not milliseconds, making cookies valid for ~82 years instead of
30 days.

Co-authored-by: Ryan Cohen <ryan@sct.dev>

* docs: add gauthier-th as a contributor for code (#4064) [skip ci]

* docs: update README.md

* docs: update .all-contributorsrc

---------

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

* fix: update migration script (#4065)

* fix: update migration script

fix: remove insert for new entities

* fix: correct migration name

* fix: correct name inside migration

* fix(servarr): merge series tags instead of overwriting them (#4019)

* Merge series tags instead of overwriting when adding a series that already exists

Currently, a request coming in for a series that already exists in sonarr nukes the tags in sonarr for the series in favor of the tags coming from overseerr. This change merges the two lists of tags and deduplicates them before sending them to sonarr.

* fix(servarr api): merge request media tags with servarr instead of overwriting

---------

Co-authored-by: Danshil Kokil Mungur <me@danshilm.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>
Co-authored-by: Dargo <fuszi88@gmail.com>
Co-authored-by: senza <senza@users.noreply.hosted.weblate.org>
Co-authored-by: Robin Van de Vyvere <irazoxgames@gmail.com>
Co-authored-by: Frostar <dasangra@hotmail.com>
Co-authored-by: Nackophilz <zrv4flra@anonaddy.me>
Co-authored-by: TayZ3r <artimmo@hotmail.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: W L <wl@mailhole.de>
Co-authored-by: Hyun Lee <hyun@yahoo.com>
Co-authored-by: cutiekeek <cutiekeek@gmail.com>
Co-authored-by: Rafael Souto <git@rafaelsouto.com>
Co-authored-by: Marc Lerno <mlerno1192@student.carlalbert.edu>
Co-authored-by: exentler <gurandsrud@gmail.com>
Co-authored-by: soup <s0up4200@pm.me>
Co-authored-by: JackOXI <53652452+JackW6809@users.noreply.github.com>
Co-authored-by: Stancu Florin <StancuFlorin@users.noreply.github.com>
Co-authored-by: Stancu Florin <florin@stancu.me>
Co-authored-by: Brandon Cohen <cohbrandon@gmail.com>
Co-authored-by: Felipe Garcia <garcia.o.felipe@gmail.com>
Co-authored-by: Rico <rico.jambor@gmail.com>
Co-authored-by: George L <lazugeorgem@gmail.com>
Co-authored-by: Yaroslav Buzko <yaroslav@buzko.com>
Co-authored-by: ugyes <ferenc.bodi@live.com>
Co-authored-by: Alberto Giardino <alberto.giardino@al-ce.it>
Co-authored-by: Lukas Miklosko <44380311+lmiklosko@users.noreply.github.com>
Co-authored-by: Ryan Cohen <ryan@sct.dev>
Co-authored-by: Andrew Kennedy <andrew-kennedy@users.noreply.github.com>
Co-authored-by: Danshil Kokil Mungur <me@danshilm.com>
2025-03-24 23:45:33 +08:00
fallenbagel
c1aeab9538 chore: update nextjs to 14.2.25 (#1521)
This updates nextjs to fix a security vulnerability.

fixes #1516
2025-03-24 23:14:59 +08:00
alorente
70fb1f2b00 ci: Add OCI Meta information to docker image (#1460)
* Add build version and date to docker build args

* Add OCI Meta information to Dockerfile

* Update ci.yml
2025-03-24 23:12:36 +08:00
0xsysr3ll
4cd02babba fix(ui): handle import-from-plex response as array (#1510) 2025-03-24 23:11:35 +08:00
Gauthier
f5b3a526cb fix(ui): resolve discover language dropdown overlap (#1497)
The discover language selection field is always in the foreground and overlaps other open dropdowns.

fix #1475
2025-03-18 20:47:08 +01:00
Nicolaj Vinholt
e5ab847547 fix(helm): apply annotations to pvc (#1489) 2025-03-17 16:32:17 +01:00
Ludovic Ortega
40539cc4b1 chore(helm): bump jellyseerr to 2.5.1 (#1488)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-03-17 14:59:56 +01:00
Ludovic Ortega
0bd6d57834 docs(helm): add contributing guidelines for helm chart (#1486)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-03-17 10:50:08 +01:00
154 changed files with 4385 additions and 3461 deletions

View File

@@ -249,7 +249,8 @@
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
"profile": "http://www.piribisoft.com",
"contributions": [
"doc"
"doc",
"code"
]
},
{
@@ -711,6 +712,141 @@
"contributions": [
"code"
]
},
{
"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": "JackW6809",
"name": "JackOXI",
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
"profile": "https://github.com/JackW6809",
"contributions": [
"code"
]
},
{
"login": "StancuFlorin",
"name": "Stancu Florin",
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
"profile": "http://indicus.ro",
"contributions": [
"code"
]
},
{
"login": "lmiklosko",
"name": "Lukas Miklosko",
"avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4",
"profile": "https://github.com/lmiklosko",
"contributions": [
"code"
]
},
{
"login": "gauthier-th",
"name": "Gauthier",
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code"
]
},
{
"login": "jessielw",
"name": "Jessie Wilson",
"avatar_url": "https://avatars.githubusercontent.com/u/48299282?v=4",
"profile": "http://www.linkedin.com/in/jessielwilson",
"contributions": [
"code"
]
},
{
"login": "brotaxt",
"name": "DominicKo",
"avatar_url": "https://avatars.githubusercontent.com/u/25477935?v=4",
"profile": "https://github.com/brotaxt",
"contributions": [
"code"
]
},
{
"login": "corentinnormand",
"name": "Corentin Normand",
"avatar_url": "https://avatars.githubusercontent.com/u/30508927?v=4",
"profile": "https://doctolib.com",
"contributions": [
"code"
]
},
{
"login": "benbeauchamp7",
"name": "Ben Beauchamp",
"avatar_url": "https://avatars.githubusercontent.com/u/43358492?v=4",
"profile": "https://github.com/benbeauchamp7",
"contributions": [
"code"
]
}
]
}

View File

@@ -98,6 +98,8 @@ jobs:
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
BUILD_VERSION=develop
BUILD_DATE=${{ github.event.repository.updated_at }}
outputs: |
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true

View File

@@ -33,5 +33,7 @@ jobs:
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
BUILD_DATE=${{ github.event.repository.updated_at }}
tags: |
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}

View File

@@ -58,12 +58,27 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
- Should you need to update your fork, you can do so by rebasing from `upstream`:
```bash
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
### Helm Chart
Tools Required:
- [Helm](https://helm.sh/docs/intro/install/)
- [helm-docs](https://github.com/norwoodj/helm-docs)
Steps:
1. Make the necessary changes.
2. Test your changes.
3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/).
4. Run the `helm-docs` command to regenerate the chart's README.
### Contributing Code
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.

View File

@@ -38,8 +38,17 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:22-alpine
# Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
# OCI Meta information
ARG BUILD_DATE
ARG BUILD_VERSION
LABEL \
org.opencontainers.image.authors="Fallenbagel" \
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
org.opencontainers.image.created=${BUILD_DATE} \
org.opencontainers.image.version=${BUILD_VERSION} \
org.opencontainers.image.title="Jellyseerr" \
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
org.opencontainers.image.licenses="MIT"
WORKDIR /app

View File

@@ -11,7 +11,7 @@
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-77-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-92-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
@@ -117,7 +117,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
</tr>
@@ -184,6 +184,27 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
</tr>
<tr>
<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>
<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>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JackW6809" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=lmiklosko" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.linkedin.com/in/jessielwilson"><img src="https://avatars.githubusercontent.com/u/48299282?v=4?s=100" width="100px;" alt="Jessie Wilson"/><br /><sub><b>Jessie Wilson</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jessielw" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brotaxt"><img src="https://avatars.githubusercontent.com/u/25477935?v=4?s=100" width="100px;" alt="DominicKo"/><br /><sub><b>DominicKo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=brotaxt" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://doctolib.com"><img src="https://avatars.githubusercontent.com/u/30508927?v=4?s=100" width="100px;" alt="Corentin Normand"/><br /><sub><b>Corentin Normand</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=corentinnormand" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benbeauchamp7"><img src="https://avatars.githubusercontent.com/u/43358492?v=4?s=100" width="100px;" alt="Ben Beauchamp"/><br /><sub><b>Ben Beauchamp</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benbeauchamp7" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@@ -335,6 +356,8 @@ 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/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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
name: jellyseerr-chart
description: Jellyseerr helm chart for Kubernetes
type: application
version: 2.3.0
appVersion: "2.5.0"
version: 2.3.3
appVersion: "2.5.2"
maintainers:
- name: Jellyseerr
url: https://github.com/Fallenbagel/jellyseerr

View File

@@ -1,6 +1,6 @@
# jellyseerr-chart
![Version: 2.3.0](https://img.shields.io/badge/Version-2.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.0](https://img.shields.io/badge/AppVersion-2.5.0-informational?style=flat-square)
![Version: 2.3.3](https://img.shields.io/badge/Version-2.3.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.5.2](https://img.shields.io/badge/AppVersion-2.5.2-informational?style=flat-square)
Jellyseerr helm chart for Kubernetes

View File

@@ -4,6 +4,10 @@ metadata:
name: {{ include "jellyseerr.configPersistenceName" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
{{- with .Values.config.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.config.persistence.accessModes }}
accessModes:

View File

@@ -19,11 +19,12 @@
"discoverRegion": "",
"streamingRegion": "",
"originalLanguage": "",
"blacklistedTags": "",
"blacklistedTagsLimit": 50,
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"enableSpecialEpisodes": false,
"forceIpv4First": false,
"locale": "en"
},
"plex": {
@@ -138,7 +139,8 @@
"types": 0,
"options": {
"url": "",
"token": ""
"token": "",
"priority": 0
}
}
}

View File

@@ -37,7 +37,7 @@ docker run -d \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
fallenbagel/jellyseerr
ghcr.io/fallenbagel/jellyseerr
```
:::tip
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
@@ -55,7 +55,7 @@ docker stop jellyseerr && docker rm Jellyseerr
```
Pull the latest image:
```bash
docker pull fallenbagel/jellyseerr
docker pull ghcr.io/fallenbagel/jellyseerr
```
Finally, run the container with the same parameters originally used to create the container:
```bash
@@ -78,7 +78,7 @@ Define the `jellyseerr` service in your `compose.yaml` as follows:
---
services:
jellyseerr:
image: fallenbagel/jellyseerr:latest
image: ghcr.io/fallenbagel/jellyseerr:latest
container_name: jellyseerr
environment:
- LOG_LEVEL=debug
@@ -146,7 +146,7 @@ Then, create and start the Jellyseerr container:
<Tabs groupId="docker-methods" queryString>
<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
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped ghcr.io/fallenbagel/jellyseerr:latest
```
#### Updating:

View File

@@ -97,37 +97,7 @@ You can try them all and see which one works for your network.
</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 going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting. You can also add the environment variable, `FORCE_IPV4_FIRST=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
### Option 2: Use Jellyseerr through a proxy
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.

View File

@@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting
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.
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
## Hide Available Media
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.

View File

@@ -191,9 +191,6 @@ components:
csrfProtection:
type: boolean
example: false
forceIpv4First:
type: boolean
example: false
trustProxy:
type: boolean
example: true
@@ -3965,6 +3962,8 @@ paths:
type: string
p256dh:
type: string
userAgent:
type: string
required:
- endpoint
- auth
@@ -3972,6 +3971,88 @@ paths:
responses:
'204':
description: Successfully registered push subscription
/user/{userId}/pushSubscriptions:
get:
summary: Get all web push notification settings for a user
description: |
Returns all web push notification settings for a user in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'200':
description: User web push notification settings in JSON
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
p256dh:
type: string
auth:
type: string
userAgent:
type: string
/user/{userId}/pushSubscription/{key}:
get:
summary: Get web push notification settings for a user
description: |
Returns web push notification settings for a user in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- in: path
name: key
required: true
schema:
type: string
responses:
'200':
description: User web push notification settings in JSON
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
p256dh:
type: string
auth:
type: string
userAgent:
type: string
delete:
summary: Delete user push subscription by key
description: Deletes the user push subscription with the provided key.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- in: path
name: key
required: true
schema:
type: string
responses:
'204':
description: Successfully removed user push subscription
/user/{userId}:
get:
summary: Get user by ID
@@ -4158,6 +4239,12 @@ paths:
type: string
nullable: true
example: dune
- in: query
name: filter
schema:
type: string
enum: [all, manual, blacklistedTags]
default: manual
responses:
'200':
description: Blacklisted items returned

View File

@@ -4,7 +4,6 @@
module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
},
images: {
remotePatterns: [

View File

@@ -44,7 +44,10 @@
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"@types/wink-jaro-distance": "^2.0.2",
"@types/ua-parser-js": "^0.7.36",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
@@ -64,7 +67,7 @@
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.24",
"next": "^14.2.25",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
@@ -99,6 +102,7 @@
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.11",
"undici": "^7.3.0",
"ua-parser-js": "^1.0.35",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.8.2",

163
pnpm-lock.yaml generated
View File

@@ -41,12 +41,21 @@ importers:
'@tanem/react-nprogress':
specifier: 5.0.30
version: 5.0.30(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/ua-parser-js':
specifier: ^0.7.36
version: 0.7.39
'@types/wink-jaro-distance':
specifier: ^2.0.2
version: 2.0.2
ace-builds:
specifier: 1.15.2
version: 1.15.2
axios:
specifier: 1.3.4
version: 1.3.4
axios-rate-limit:
specifier: 1.3.0
version: 1.3.0(axios@1.3.4)
bcrypt:
specifier: 5.1.0
version: 5.1.0(encoding@0.1.13)
@@ -105,8 +114,8 @@ importers:
specifier: '3'
version: 3.0.0
next:
specifier: ^14.2.24
version: 14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^14.2.25
version: 14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
node-cache:
specifier: 5.1.2
version: 5.1.2
@@ -206,6 +215,9 @@ importers:
typeorm:
specifier: 0.3.11
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
ua-parser-js:
specifier: ^1.0.35
version: 1.0.40
undici:
specifier: ^7.3.0
version: 7.3.0
@@ -2133,62 +2145,62 @@ packages:
'@messageformat/runtime@3.0.1':
resolution: {integrity: sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==}
'@next/env@14.2.24':
resolution: {integrity: sha512-LAm0Is2KHTNT6IT16lxT+suD0u+VVfYNQqM+EJTKuFRRuY2z+zj01kueWXPCxbMBDt0B5vONYzabHGUNbZYAhA==}
'@next/env@14.2.25':
resolution: {integrity: sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==}
'@next/eslint-plugin-next@14.2.4':
resolution: {integrity: sha512-svSFxW9f3xDaZA3idQmlFw7SusOuWTpDTAeBlO3AEPDltrraV+lqs7mAc6A27YdnpQVVIA3sODqUAAHdWhVWsA==}
'@next/swc-darwin-arm64@14.2.24':
resolution: {integrity: sha512-7Tdi13aojnAZGpapVU6meVSpNzgrFwZ8joDcNS8cJVNuP3zqqrLqeory9Xec5TJZR/stsGJdfwo8KeyloT3+rQ==}
'@next/swc-darwin-arm64@14.2.25':
resolution: {integrity: sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@14.2.24':
resolution: {integrity: sha512-lXR2WQqUtu69l5JMdTwSvQUkdqAhEWOqJEYUQ21QczQsAlNOW2kWZCucA6b3EXmPbcvmHB1kSZDua/713d52xg==}
'@next/swc-darwin-x64@14.2.25':
resolution: {integrity: sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@14.2.24':
resolution: {integrity: sha512-nxvJgWOpSNmzidYvvGDfXwxkijb6hL9+cjZx1PVG6urr2h2jUqBALkKjT7kpfurRWicK6hFOvarmaWsINT1hnA==}
'@next/swc-linux-arm64-gnu@14.2.25':
resolution: {integrity: sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@14.2.24':
resolution: {integrity: sha512-PaBgOPhqa4Abxa3y/P92F3kklNPsiFjcjldQGT7kFmiY5nuFn8ClBEoX8GIpqU1ODP2y8P6hio6vTomx2Vy0UQ==}
'@next/swc-linux-arm64-musl@14.2.25':
resolution: {integrity: sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@14.2.24':
resolution: {integrity: sha512-vEbyadiRI7GOr94hd2AB15LFVgcJZQWu7Cdi9cWjCMeCiUsHWA0U5BkGPuoYRnTxTn0HacuMb9NeAmStfBCLoQ==}
'@next/swc-linux-x64-gnu@14.2.25':
resolution: {integrity: sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@14.2.24':
resolution: {integrity: sha512-df0FC9ptaYsd8nQCINCzFtDWtko8PNRTAU0/+d7hy47E0oC17tI54U/0NdGk7l/76jz1J377dvRjmt6IUdkpzQ==}
'@next/swc-linux-x64-musl@14.2.25':
resolution: {integrity: sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@14.2.24':
resolution: {integrity: sha512-ZEntbLjeYAJ286eAqbxpZHhDFYpYjArotQ+/TW9j7UROh0DUmX7wYDGtsTPpfCV8V+UoqHBPU7q9D4nDNH014Q==}
'@next/swc-win32-arm64-msvc@14.2.25':
resolution: {integrity: sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-ia32-msvc@14.2.24':
resolution: {integrity: sha512-9KuS+XUXM3T6v7leeWU0erpJ6NsFIwiTFD5nzNg8J5uo/DMIPvCp3L1Ao5HjbHX0gkWPB1VrKoo/Il4F0cGK2Q==}
'@next/swc-win32-ia32-msvc@14.2.25':
resolution: {integrity: sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@next/swc-win32-x64-msvc@14.2.24':
resolution: {integrity: sha512-cXcJ2+x0fXQ2CntaE00d7uUH+u1Bfp/E0HsNQH79YiLaZE5Rbm7dZzyAYccn3uICM7mw+DxoMqEfGXZtF4Fgaw==}
'@next/swc-win32-x64-msvc@14.2.25':
resolution: {integrity: sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3412,6 +3424,9 @@ packages:
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/ua-parser-js@0.7.39':
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
@@ -3864,6 +3879,14 @@ packages:
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
engines: {node: '>=4'}
axios-rate-limit@1.3.0:
resolution: {integrity: sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==}
peerDependencies:
axios: '*'
axios@1.3.4:
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
axobject-query@3.1.1:
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
@@ -5376,6 +5399,15 @@ packages:
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -7017,8 +7049,8 @@ packages:
nerf-dart@1.0.0:
resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==}
next@14.2.24:
resolution: {integrity: sha512-En8VEexSJ0Py2FfVnRRh8gtERwDRaJGNvsvad47ShkC2Yi8AXQPXEA2vKoDJlGFSj5WE5SyF21zNi4M5gyi+SQ==}
next@14.2.25:
resolution: {integrity: sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
@@ -7840,6 +7872,9 @@ packages:
proxy-from-env@1.0.0:
resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
@@ -9223,6 +9258,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ua-parser-js@1.0.40:
resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -11860,37 +11899,37 @@ snapshots:
dependencies:
make-plural: 7.4.0
'@next/env@14.2.24': {}
'@next/env@14.2.25': {}
'@next/eslint-plugin-next@14.2.4':
dependencies:
glob: 10.3.10
'@next/swc-darwin-arm64@14.2.24':
'@next/swc-darwin-arm64@14.2.25':
optional: true
'@next/swc-darwin-x64@14.2.24':
'@next/swc-darwin-x64@14.2.25':
optional: true
'@next/swc-linux-arm64-gnu@14.2.24':
'@next/swc-linux-arm64-gnu@14.2.25':
optional: true
'@next/swc-linux-arm64-musl@14.2.24':
'@next/swc-linux-arm64-musl@14.2.25':
optional: true
'@next/swc-linux-x64-gnu@14.2.24':
'@next/swc-linux-x64-gnu@14.2.25':
optional: true
'@next/swc-linux-x64-musl@14.2.24':
'@next/swc-linux-x64-musl@14.2.25':
optional: true
'@next/swc-win32-arm64-msvc@14.2.24':
'@next/swc-win32-arm64-msvc@14.2.25':
optional: true
'@next/swc-win32-ia32-msvc@14.2.24':
'@next/swc-win32-ia32-msvc@14.2.25':
optional: true
'@next/swc-win32-x64-msvc@14.2.24':
'@next/swc-win32-x64-msvc@14.2.25':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -13506,7 +13545,7 @@ snapshots:
'@swc/helpers@0.5.5':
dependencies:
'@swc/counter': 0.1.3
tslib: 2.6.3
tslib: 2.8.1
'@swc/types@0.1.17':
dependencies:
@@ -13778,6 +13817,8 @@ snapshots:
'@types/triple-beam@1.3.5': {}
'@types/ua-parser-js@0.7.39': {}
'@types/unist@2.0.10': {}
'@types/web-push@3.3.2':
@@ -14270,6 +14311,18 @@ snapshots:
axe-core@4.9.1: {}
axios-rate-limit@1.3.0(axios@1.3.4):
dependencies:
axios: 1.3.4
axios@1.3.4:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@3.1.1:
dependencies:
deep-equal: 2.2.3
@@ -15632,7 +15685,7 @@ snapshots:
es-get-iterator@1.1.3:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
has-symbols: 1.0.3
is-arguments: 1.1.1
is-map: 2.0.3
@@ -15741,7 +15794,7 @@ snapshots:
debug: 4.3.5
enhanced-resolve: 5.17.0
eslint: 8.35.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0)
eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.54.0(eslint@8.35.0)(typescript@4.9.5))(eslint@8.35.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
@@ -15763,7 +15816,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.35.0):
eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.35.0)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.35.0))(eslint@8.35.0):
dependencies:
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
@@ -16324,6 +16377,8 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.9: {}
for-each@0.3.3:
dependencies:
is-callable: 1.2.7
@@ -17098,7 +17153,7 @@ snapshots:
is-weakset@2.0.3:
dependencies:
call-bind: 1.0.7
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
is-windows@1.0.2: {}
@@ -18289,27 +18344,27 @@ snapshots:
nerf-dart@1.0.0: {}
next@14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
next@14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.2.24
'@next/env': 14.2.25
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001636
caniuse-lite: 1.0.30001700
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.3.1)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.24
'@next/swc-darwin-x64': 14.2.24
'@next/swc-linux-arm64-gnu': 14.2.24
'@next/swc-linux-arm64-musl': 14.2.24
'@next/swc-linux-x64-gnu': 14.2.24
'@next/swc-linux-x64-musl': 14.2.24
'@next/swc-win32-arm64-msvc': 14.2.24
'@next/swc-win32-ia32-msvc': 14.2.24
'@next/swc-win32-x64-msvc': 14.2.24
'@next/swc-darwin-arm64': 14.2.25
'@next/swc-darwin-x64': 14.2.25
'@next/swc-linux-arm64-gnu': 14.2.25
'@next/swc-linux-arm64-musl': 14.2.25
'@next/swc-linux-x64-gnu': 14.2.25
'@next/swc-linux-x64-musl': 14.2.25
'@next/swc-win32-arm64-msvc': 14.2.25
'@next/swc-win32-ia32-msvc': 14.2.25
'@next/swc-win32-x64-msvc': 14.2.25
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -19000,6 +19055,8 @@ snapshots:
proxy-from-env@1.0.0: {}
proxy-from-env@1.1.0: {}
psl@1.15.0:
dependencies:
punycode: 2.3.1
@@ -19510,7 +19567,7 @@ snapshots:
define-properties: 1.2.1
es-abstract: 1.23.3
es-errors: 1.3.0
get-intrinsic: 1.2.4
get-intrinsic: 1.3.0
globalthis: 1.0.4
which-builtin-type: 1.1.3
@@ -20672,6 +20729,8 @@ snapshots:
typescript@5.5.2: {}
ua-parser-js@1.0.40: {}
uc.micro@2.1.0:
optional: true

View File

@@ -1,8 +1,7 @@
import logger from '@server/logger';
import fs, { promises as fsp } from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import axios from 'axios';
import fs, { promises as fsp } from 'fs';
import path from 'path';
import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
@@ -162,18 +161,14 @@ class AnimeListMapping {
label: 'Anime-List Sync',
});
try {
const response = await fetch(MAPPING_URL);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
const response = await axios.get(MAPPING_URL, {
responseType: 'stream',
});
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(LOCAL_PATH);
writer.on('finish', resolve);
writer.on('error', reject);
if (!response.body) return reject();
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
writer
);
response.data.pipe(writer);
});
} catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);

View File

@@ -1,7 +1,6 @@
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 { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
@@ -13,109 +12,75 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
}
class ExternalAPI {
protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
protected axios: AxiosInstance;
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, string> = {},
params: Record<string, unknown>,
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
if (options.rateLimit) {
this.fetch = rateLimit(fetch, options.rateLimit);
} else {
this.fetch = fetch;
}
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.axios = rateLimit(this.axios, {
maxRequests: options.rateLimit.maxRequests,
maxRPS: options.rateLimit.maxRPS,
});
}
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
headers,
...config?.params,
headers: config?.headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
const response = await this.axios.get<T>(endpoint, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return data;
return response.data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
headers,
data,
config: config?.params,
...(data ? { data } : {}),
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -123,115 +88,23 @@ class ExternalAPI {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers,
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
const response = await this.axios.post<T>(endpoint, data, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async delete<T>(
endpoint: string,
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
return data;
return response.data;
}
protected async getRolling<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const headers = { ...this.defaultHeaders, ...config?.headers };
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
headers,
...config?.params,
headers: config?.headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -243,82 +116,29 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers,
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
const response = await this.axios.get<T>(endpoint, config);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return data;
return response.data;
}
protected removeCache(endpoint: string, options?: Record<string, string>) {
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...options,
});
this.cache?.del(cacheKey);
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}
private serializeCacheKey(
endpoint: string,
options?: Record<string, unknown>
@@ -329,29 +149,6 @@ class ExternalAPI {
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
}
export default ExternalAPI;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease {
url: string;
@@ -67,6 +67,10 @@ class GithubAPI extends ExternalAPI {
'https://api.github.com',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data,
}
);
@@ -81,7 +85,9 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GitHubRelease[]>(
'/repos/fallenbagel/jellyseerr/releases',
{
per_page: take.toString(),
params: {
per_page: take,
},
}
);
@@ -106,8 +112,10 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GithubCommit[]>(
'/repos/fallenbagel/jellyseerr/commits',
{
per_page: take.toString(),
branch,
params: {
per_page: take,
branch,
},
}
);

View File

@@ -110,11 +110,18 @@ class JellyfinAPI extends ExternalAPI {
deviceId?: string | null
) {
const settings = getSettings();
const safeDeviceId =
deviceId && deviceId.length > 0
? deviceId
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
'base64'
);
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
}
super(
@@ -123,6 +130,8 @@ class JellyfinAPI extends ExternalAPI {
{
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
@@ -136,7 +145,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string
): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => {
const headers: { [key: string]: string } =
const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
@@ -145,8 +154,6 @@ class JellyfinAPI extends ExternalAPI {
Username,
Pw: Password,
},
{},
undefined,
{ headers }
);
};
@@ -156,36 +163,36 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
error: e.response?.statusText,
ip: ClientIP,
});
if (!e.cause.status) {
if (!e.response?.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
}
logger.error(
'Something went wrong while authenticating with the Jellyfin server',
`Something went wrong while authenticating with the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
error: e.response?.status,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
@@ -200,7 +207,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -213,11 +220,11 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
@@ -228,11 +235,11 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -244,11 +251,11 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -268,10 +275,10 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
'Something went wrong while getting libraries from the Jellyfin server',
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
error: e.response?.status,
}
);
@@ -308,26 +315,20 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const libraryItemsResponse = await this.get<any>(`/Items`, {
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
});
const libraryItemsResponse = await this.get<any>(
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
);
return libraryItemsResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e?.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -337,27 +338,22 @@ class JellyfinAPI extends ExternalAPI {
this.mediaServerType === MediaServerType.JELLYFIN
? `/Items/Latest`
: `/Users/${this.userId}/Items/Latest`;
const baseParams = {
Limit: '12',
ParentId: id,
};
const params =
this.mediaServerType === MediaServerType.JELLYFIN
? { ...baseParams, userId: this.userId ?? `Me` }
: baseParams;
const itemResponse = await this.get<any>(endpoint, params);
const itemResponse = await this.get<any>(
`${endpoint}?Limit=12&ParentId=${id}${
this.mediaServerType === MediaServerType.JELLYFIN
? `&userId=${this.userId ?? 'Me'}`
: ''
}`
);
return itemResponse;
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -366,23 +362,25 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItemExtended | undefined> {
try {
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
ids: id,
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
params: {
ids: id,
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
},
});
return itemResponse.Items?.[0];
} catch (e) {
if (availabilitySync.running) {
if (e.cause?.status === 500) {
if (e.response?.status === 500) {
return undefined;
}
}
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -393,11 +391,11 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -407,10 +405,7 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> {
try {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`,
{
seasonId: seasonID,
}
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
);
return episodeResponse.Items.filter(
@@ -418,11 +413,11 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -435,8 +430,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -1,10 +1,10 @@
import ExternalAPI from '@server/api/externalapi';
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';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@@ -143,6 +143,8 @@ class PlexTvAPI extends ExternalAPI {
{
headers: {
'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('plextv').data,
}
@@ -153,11 +155,15 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.get('/api/resources', {
includeHttps: '1',
});
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise(
devicesResp as DeviceResponse
devicesResp.data as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
@@ -205,11 +211,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> {
try {
const account = await this.get<PlexAccountResponse>(
const account = await this.axios.get<PlexAccountResponse>(
'/users/account.json'
);
return account.user;
return account.data.user;
} catch (e) {
logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -249,10 +255,13 @@ class PlexTvAPI extends ExternalAPI {
}
public async getUsers(): Promise<UsersResponse> {
const data = await this.get('/api/users');
const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
data as string
response.data
)) as UsersResponse;
return parsedXml;
}
@@ -272,28 +281,26 @@ class PlexTvAPI extends ExternalAPI {
this.authToken
);
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
{
headers: {
...this.defaultHeaders,
...(cachedWatchlist?.etag
? { 'If-None-Match': cachedWatchlist.etag }
: {}),
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
headers: {
'If-None-Match': cachedWatchlist?.etag,
},
baseURL: 'https://metadata.provider.plex.tv',
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
}
);
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,
etag: response.headers.etag,
response: response.data,
};
watchlistCache.data.set<PlexWatchlistCache>(
@@ -307,10 +314,9 @@ class PlexTvAPI extends ExternalAPI {
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
{
baseURL: 'https://metadata.provider.plex.tv',
}
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
@@ -361,16 +367,11 @@ 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(),
},
}
);
const data: { pong: unknown } = await this.get('/api/v2/ping', {
headers: {
'X-Plex-Client-Identifier': randomUUID(),
},
});
if (!data?.pong) {
throw new Error('No pong response');
}

View File

@@ -1,4 +1,4 @@
import ExternalAPI from '@server/api/externalapi';
import ExternalAPI from './externalapi';
interface PushoverSoundsResponse {
sounds: {
@@ -26,13 +26,24 @@ export const mapSounds = (sounds: {
class PushoverAPI extends ExternalAPI {
constructor() {
super('https://api.pushover.net/1');
super(
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
}
public async getSounds(appToken: string): Promise<PushoverSound[]> {
try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
token: appToken,
params: {
token: appToken,
},
});
return mapSounds(data.sounds);

View File

@@ -155,13 +155,13 @@ export interface IMDBRating {
*/
class IMDBRadarrProxy extends ExternalAPI {
constructor() {
super(
'https://api.radarr.video/v1',
{},
{
nodeCache: cacheManager.getCache('imdb').data,
}
);
super('https://api.radarr.video/v1', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
}
/**

View File

@@ -105,12 +105,15 @@ class RottenTomatoes extends ExternalAPI {
super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,

View File

@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getSystemStatus = async (): Promise<SystemStatus> => {
try {
const data = await this.get<SystemStatus>('/system/status');
const response = await this.axios.get<SystemStatus>('/system/status');
return data;
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
@@ -157,15 +157,16 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const data = await this.get<QueueResponse<QueueItemAppendT>>(
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
includeEpisode: 'true',
},
0
params: {
includeEpisode: true,
},
}
);
return data.records;
return response.data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
@@ -175,9 +176,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getTags = async (): Promise<Tag[]> => {
try {
const data = await this.get<Tag[]>(`/tag`);
const response = await this.axios.get<Tag[]>(`/tag`);
return data;
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
@@ -187,11 +188,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try {
const data = await this.post<Tag>(`/tag`, {
const response = await this.axios.post<Tag>(`/tag`, {
label,
});
return data;
return response.data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
}
@@ -206,15 +207,10 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
options: Record<string, unknown>
): Promise<void> {
try {
await this.post(
`/command`,
{
name: commandName,
...options,
},
{},
0
);
await this.axios.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

@@ -28,6 +28,39 @@ export interface RadarrMovie {
qualityProfileId: number;
added: string;
hasFile: boolean;
tags: number[];
movieFile?: {
id: number;
movieId: number;
relativePath?: string;
path?: string;
size: number;
dateAdded: string;
sceneName?: string;
releaseGroup?: string;
edition?: string;
indexerFlags?: number;
mediaInfo: {
id: number;
audioBitrate: number;
audioChannels: number;
audioCodec?: string;
audioLanguages?: string;
audioStreamCount: number;
videoBitDepth: number;
videoBitrate: number;
videoCodec?: string;
videoFps: number;
videoDynamicRange?: string;
videoDynamicRangeType?: string;
resolution?: string;
runTime?: string;
scanType?: string;
subtitles?: string;
};
originalFilePath?: string;
qualityCutoffNotMet: boolean;
};
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
@@ -37,9 +70,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovies = async (): Promise<RadarrMovie[]> => {
try {
const data = await this.get<RadarrMovie[]>('/movie');
const response = await this.axios.get<RadarrMovie[]>('/movie');
return data;
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
}
@@ -47,9 +80,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
try {
const data = await this.get<RadarrMovie>(`/movie/${id}`);
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
return data;
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
}
@@ -57,15 +90,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try {
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
term: `tmdb:${id}`,
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
});
if (!data[0]) {
if (!response.data[0]) {
throw new Error('Movie not found');
}
return data[0];
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API',
@@ -95,7 +130,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
// movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const data = await this.put<RadarrMovie>(`/movie`, {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
title: options.title,
qualityProfileId: options.qualityProfileId,
@@ -104,7 +139,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
tags: options.tags,
tags: Array.from(new Set([...movie.tags, ...options.tags])),
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
@@ -112,25 +147,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
},
});
if (data.monitored) {
if (response.data.monitored) {
logger.info(
'Found existing title in Radarr and set it to monitored.',
{
label: 'Radarr',
movieId: data.id,
movieTitle: data.title,
movieId: response.data.id,
movieTitle: response.data.title,
}
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: data,
movie: response.data,
});
if (options.searchNow) {
this.searchMovie(data.id);
this.searchMovie(response.data.id);
}
return data;
return response.data;
} else {
logger.error('Failed to update existing movie in Radarr.', {
label: 'Radarr',
@@ -148,7 +183,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return movie;
}
const data = await this.post<RadarrMovie>(`/movie`, {
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
@@ -164,11 +199,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
},
});
if (data.id) {
if (response.data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', {
label: 'Radarr',
movie: data,
movie: response.data,
});
} else {
logger.error('Failed to add movie to Radarr', {
@@ -177,22 +212,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
});
throw new Error('Failed to add movie to Radarr');
}
return data;
return response.data;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
{
label: 'Radarr',
errorMessage: e.message,
options,
response: errorData,
response: e?.response?.data,
}
);
throw new Error('Failed to add movie to Radarr');
@@ -221,9 +249,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public removeMovie = async (movieId: number): Promise<void> => {
try {
const { id, title } = await this.getMovieByTmdbId(movieId);
await this.delete(`/movie/${id}`, {
deleteFiles: 'true',
addImportExclusion: 'false',
await this.axios.delete(`/movie/${id}`, {
params: {
deleteFiles: true,
addImportExclusion: false,
},
});
logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) {

View File

@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeries(): Promise<SonarrSeries[]> {
try {
const data = await this.get<SonarrSeries[]>('/series');
const response = await this.axios.get<SonarrSeries[]>('/series');
return data;
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
}
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const data = await this.get<SonarrSeries>(`/series/${id}`);
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return data;
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
@@ -137,15 +137,17 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const data = await this.get<SonarrSeries[]>('/series/lookup', {
term: title,
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
});
if (!data[0]) {
if (!response.data[0]) {
throw new Error('No series found');
}
return data;
return response.data;
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
@@ -158,15 +160,17 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try {
const data = await this.get<SonarrSeries[]>('/series/lookup', {
term: `tvdb:${id}`,
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: `tvdb:${id}`,
},
});
if (!data[0]) {
if (!response.data[0]) {
throw new Error('Series not found');
}
return data[0];
return response.data[0];
} catch (e) {
logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API',
@@ -184,30 +188,32 @@ class SonarrAPI extends ServarrBase<{
// If the series already exists, we will simply just update it
if (series.id) {
series.monitored = options.monitored ?? series.monitored;
series.tags = options.tags ?? series.tags;
series.tags = options.tags
? Array.from(new Set([...series.tags, ...options.tags]))
: series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesData = await this.put<SonarrSeries>(
const newSeriesResponse = await this.axios.put<SonarrSeries>(
'/series',
series as any
series
);
if (newSeriesData.id) {
if (newSeriesResponse.data.id) {
logger.info('Updated existing series in Sonarr.', {
label: 'Sonarr',
seriesId: newSeriesData.id,
seriesTitle: newSeriesData.title,
seriesId: newSeriesResponse.data.id,
seriesTitle: newSeriesResponse.data.title,
});
logger.debug('Sonarr update details', {
label: 'Sonarr',
movie: newSeriesData,
series: newSeriesResponse.data,
});
if (options.searchNow) {
this.searchSeries(newSeriesData.id);
this.searchSeries(newSeriesResponse.data.id);
}
return newSeriesData;
return newSeriesResponse.data;
} else {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
@@ -217,35 +223,38 @@ class SonarrAPI extends ServarrBase<{
}
}
const createdSeriesData = await this.post<SonarrSeries>('/series', {
tvdbId: options.tvdbid,
title: options.title,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
// We force all seasons to false if its the first request
monitored: false,
}))
),
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
},
} as Partial<SonarrSeries>);
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
'/series',
{
tvdbId: options.tvdbid,
title: options.title,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
// We force all seasons to false if its the first request
monitored: false,
}))
),
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
},
} as Partial<SonarrSeries>
);
if (createdSeriesData.id) {
if (createdSeriesResponse.data.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: createdSeriesData,
series: createdSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
@@ -255,20 +264,13 @@ class SonarrAPI extends ServarrBase<{
throw new Error('Failed to add series to Sonarr');
}
return createdSeriesData;
return createdSeriesResponse.data;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Something went wrong while adding a series to Sonarr.', {
label: 'Sonarr API',
errorMessage: e.message,
options,
response: errorData,
response: e?.response?.data,
});
throw new Error('Failed to add series');
}
@@ -340,13 +342,14 @@ class SonarrAPI extends ServarrBase<{
return newSeasons;
}
public removeSerie = async (serieId: number): Promise<void> => {
try {
const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.delete(`/series/${id}`, {
deleteFiles: 'true',
addImportExclusion: 'false',
await this.axios.delete(`/series/${id}`, {
params: {
deleteFiles: true,
addImportExclusion: false,
},
});
logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) {

View File

@@ -1,7 +1,8 @@
import ExternalAPI from '@server/api/externalapi';
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
export interface TautulliHistoryRecord {
@@ -112,25 +113,25 @@ interface TautulliInfoResponse {
};
}
class TautulliAPI extends ExternalAPI {
class TautulliAPI {
private axios: AxiosInstance;
constructor(settings: TautulliSettings) {
super(
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
this.axios = axios.create({
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.urlBase ?? ''}`,
{
apikey: settings.apiKey || '',
}
);
params: { apikey: settings.apiKey },
});
}
public async getInfo(): Promise<TautulliInfo> {
try {
return (
await this.get<TautulliInfoResponse>('/api/v2', {
cmd: 'get_tautulli_info',
await this.axios.get<TautulliInfoResponse>('/api/v2', {
params: { cmd: 'get_tautulli_info' },
})
).response.data;
).data.response.data;
} catch (e) {
logger.error('Something went wrong fetching Tautulli server info', {
label: 'Tautulli API',
@@ -147,12 +148,14 @@ class TautulliAPI extends ExternalAPI {
): Promise<TautulliWatchStats[]> {
try {
return (
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: '1',
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: 1,
},
})
).response.data;
).data.response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch stats from Tautulli',
@@ -173,12 +176,14 @@ class TautulliAPI extends ExternalAPI {
): Promise<TautulliWatchUser[]> {
try {
return (
await this.get<TautulliWatchUsersResponse>('/api/v2', {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: '1',
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
params: {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: 1,
},
})
).response.data;
).data.response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch users from Tautulli',
@@ -201,13 +206,15 @@ class TautulliAPI extends ExternalAPI {
}
return (
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId.toString(),
query_days: '0',
grouping: '1',
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId,
query_days: 0,
grouping: 1,
},
})
).response.data[0];
).data.response.data[0];
} catch (e) {
logger.error(
'Something went wrong fetching user watch stats from Tautulli',
@@ -238,17 +245,19 @@ class TautulliAPI extends ExternalAPI {
while (results.length < 20) {
const tautulliData = (
await this.get<TautulliHistoryResponse>('/api/v2', {
cmd: 'get_history',
grouping: '1',
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId.toString(),
media_type: 'movie,episode',
length: take.toString(),
start: start.toString(),
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
params: {
cmd: 'get_history',
grouping: 1,
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId,
media_type: 'movie,episode',
length: take,
start,
},
})
).response.data.data;
).data.response.data.data;
if (!tautulliData.length) {
return results;

View File

@@ -1,5 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import { sortBy } from 'lodash';
import type {
TmdbCollection,
@@ -37,23 +38,26 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
export const SortOptionsIterable = [
'popularity.desc',
'popularity.asc',
'release_date.desc',
'release_date.asc',
'revenue.desc',
'revenue.asc',
'primary_release_date.desc',
'primary_release_date.asc',
'original_title.asc',
'original_title.desc',
'vote_average.desc',
'vote_average.asc',
'vote_count.desc',
'vote_count.asc',
'first_air_date.desc',
'first_air_date.asc',
] as const;
export type SortOptions = (typeof SortOptionsIterable)[number];
interface DiscoverMovieOptions {
page?: number;
@@ -99,6 +103,7 @@ interface DiscoverTvOptions {
}
class TheMovieDb extends ExternalAPI {
private locale: string;
private discoverRegion?: string;
private originalLanguage?: string;
constructor({
@@ -113,11 +118,12 @@ class TheMovieDb extends ExternalAPI {
{
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 50,
id: 'tmdb',
},
}
);
this.locale = getSettings().main?.locale || 'en';
this.discoverRegion = discoverRegion;
this.originalLanguage = originalLanguage;
}
@@ -126,14 +132,11 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = 'en',
language = this.locale,
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
params: { query, page, include_adult: includeAdult, language },
});
return data;
@@ -151,16 +154,18 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = 'en',
language = this.locale,
year,
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
primary_release_year: year?.toString() || '',
params: {
query,
page,
include_adult: includeAdult,
language,
primary_release_year: year,
},
});
return data;
@@ -178,16 +183,18 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = 'en',
language = this.locale,
year,
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
first_air_date_year: year?.toString() || '',
params: {
query,
page,
include_adult: includeAdult,
language,
first_air_date_year: year,
},
});
return data;
@@ -203,14 +210,14 @@ class TheMovieDb extends ExternalAPI {
public getPerson = async ({
personId,
language = 'en',
language = this.locale,
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetails> => {
try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
language,
params: { language },
});
return data;
@@ -221,7 +228,7 @@ class TheMovieDb extends ExternalAPI {
public getPersonCombinedCredits = async ({
personId,
language = 'en',
language = this.locale,
}: {
personId: number;
language?: string;
@@ -230,7 +237,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
language,
params: { language },
}
);
@@ -244,7 +251,7 @@ class TheMovieDb extends ExternalAPI {
public getMovie = async ({
movieId,
language = 'en',
language = this.locale,
}: {
movieId: number;
language?: string;
@@ -253,10 +260,12 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
include_video_language: language + ', en',
params: {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
include_video_language: language + ', en',
},
},
43200
);
@@ -269,7 +278,7 @@ class TheMovieDb extends ExternalAPI {
public getTvShow = async ({
tvId,
language = 'en',
language = this.locale,
}: {
tvId: number;
language?: string;
@@ -278,10 +287,12 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
include_video_language: language + ', en',
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
include_video_language: language + ', en',
},
},
43200
);
@@ -305,8 +316,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
language: language || '',
append_to_response: 'external_ids',
params: {
language,
append_to_response: 'external_ids',
},
}
);
@@ -319,7 +332,7 @@ class TheMovieDb extends ExternalAPI {
public async getMovieRecommendations({
movieId,
page = 1,
language = 'en',
language = this.locale,
}: {
movieId: number;
page?: number;
@@ -329,8 +342,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
page: page.toString(),
language,
params: {
page,
language,
},
}
);
@@ -343,7 +358,7 @@ class TheMovieDb extends ExternalAPI {
public async getMovieSimilar({
movieId,
page = 1,
language = 'en',
language = this.locale,
}: {
movieId: number;
page?: number;
@@ -353,8 +368,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
page: page.toString(),
language,
params: {
page,
language,
},
}
);
@@ -367,7 +384,7 @@ class TheMovieDb extends ExternalAPI {
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en',
language = this.locale,
}: {
keywordId: number;
page?: number;
@@ -377,8 +394,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
page: page.toString(),
language,
params: {
page,
language,
},
}
);
@@ -391,7 +410,7 @@ class TheMovieDb extends ExternalAPI {
public async getTvRecommendations({
tvId,
page = 1,
language = 'en',
language = this.locale,
}: {
tvId: number;
page?: number;
@@ -401,8 +420,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
page: page.toString(),
language,
params: {
page,
language,
},
}
);
@@ -417,7 +438,7 @@ class TheMovieDb extends ExternalAPI {
public async getTvSimilar({
tvId,
page = 1,
language = 'en',
language = this.locale,
}: {
tvId: number;
page?: number;
@@ -425,8 +446,10 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
page: page.toString(),
language,
params: {
page,
language,
},
});
return data;
@@ -439,7 +462,7 @@ class TheMovieDb extends ExternalAPI {
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = 'en',
language = this.locale,
primaryReleaseDateGte,
primaryReleaseDateLte,
originalLanguage,
@@ -467,38 +490,40 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
sort_by: sortBy,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
region: this.discoverRegion || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
// 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!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte || '',
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte || '',
with_genres: genre || '',
with_companies: studio || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
watch_region: watchRegion || '',
with_watch_providers: watchProviders || '',
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
region: this.discoverRegion || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
// 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!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte,
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
});
return data;
@@ -510,7 +535,7 @@ class TheMovieDb extends ExternalAPI {
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = 'en',
language = this.locale,
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
@@ -540,41 +565,41 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
sort_by: sortBy,
page: page.toString(),
language,
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':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte || '',
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
include_null_first_air_dates: includeEmptyReleaseDate
? 'true'
: 'false',
with_genres: genre || '',
with_networks: network?.toString() || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
params: {
sort_by: sortBy,
page,
language,
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':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte,
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
with_status: withStatus,
},
});
return data;
@@ -585,7 +610,7 @@ class TheMovieDb extends ExternalAPI {
public getUpcomingMovies = async ({
page = 1,
language = 'en',
language = this.locale,
}: {
page: number;
language: string;
@@ -594,10 +619,12 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
page: page.toString(),
language,
region: this.discoverRegion || '',
originalLanguage: this.originalLanguage || '',
params: {
page,
language,
region: this.discoverRegion,
originalLanguage: this.originalLanguage,
},
}
);
@@ -610,7 +637,7 @@ class TheMovieDb extends ExternalAPI {
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = 'en',
language = this.locale,
}: {
page?: number;
timeWindow?: 'day' | 'week';
@@ -620,9 +647,11 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
page: page.toString(),
language,
region: this.discoverRegion || '',
params: {
page,
language,
region: this.discoverRegion,
},
}
);
@@ -643,7 +672,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
page: page.toString(),
params: {
page,
},
}
);
@@ -664,7 +695,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
page: page.toString(),
params: {
page,
},
}
);
@@ -677,7 +710,7 @@ class TheMovieDb extends ExternalAPI {
public async getByExternalId({
externalId,
type,
language = 'en',
language = this.locale,
}:
| {
externalId: string;
@@ -693,8 +726,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
}
);
@@ -706,7 +741,7 @@ class TheMovieDb extends ExternalAPI {
public async getMediaByImdbId({
imdbId,
language = 'en',
language = this.locale,
}: {
imdbId: string;
language?: string;
@@ -745,7 +780,7 @@ class TheMovieDb extends ExternalAPI {
public async getShowByTvdbId({
tvdbId,
language = 'en',
language = this.locale,
}: {
tvdbId: number;
language?: string;
@@ -775,7 +810,7 @@ class TheMovieDb extends ExternalAPI {
public async getCollection({
collectionId,
language = 'en',
language = this.locale,
}: {
collectionId: number;
language?: string;
@@ -784,7 +819,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
language,
params: {
language,
},
}
);
@@ -849,7 +886,7 @@ class TheMovieDb extends ExternalAPI {
}
public async getMovieGenres({
language = 'en',
language = this.locale,
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
@@ -857,7 +894,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
language,
params: {
language,
},
},
86400 // 24 hours
);
@@ -869,7 +908,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
language: 'en',
params: {
language: 'en',
},
},
86400 // 24 hours
);
@@ -896,7 +937,7 @@ class TheMovieDb extends ExternalAPI {
}
public async getTvGenres({
language = 'en',
language = this.locale,
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
@@ -904,7 +945,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
language,
params: {
language,
},
},
86400 // 24 hours
);
@@ -916,7 +959,9 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
language: 'en',
params: {
language: 'en',
},
},
86400 // 24 hours
);
@@ -971,8 +1016,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
query,
page: page.toString(),
params: {
query,
page,
},
},
86400 // 24 hours
);
@@ -994,8 +1041,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
query,
page: page.toString(),
params: {
query,
page,
},
},
86400 // 24 hours
);
@@ -1015,7 +1064,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
language: language ? this.originalLanguage || '' : '',
params: {
language: language ?? this.originalLanguage,
},
},
86400 // 24 hours
);
@@ -1039,8 +1090,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
@@ -1064,8 +1117,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);

View File

@@ -1,8 +1,9 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import dataSource from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { EntityManager } from 'typeorm';
import {
Column,
CreateDateColumn,
@@ -35,7 +36,7 @@ export class Blacklist implements BlacklistItem {
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user: User;
user?: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
@@ -43,6 +44,9 @@ export class Blacklist implements BlacklistItem {
@JoinColumn()
public media: Media;
@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;
@CreateDateColumn()
public createdAt: Date;
@@ -50,27 +54,32 @@ export class Blacklist implements BlacklistItem {
Object.assign(this, init);
}
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
public static async addToBlacklist(
{
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
blacklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = getRepository(Media);
const mediaRepository = em.getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = getRepository(this);
const blacklistRepository = em.getRepository(this);
await blacklistRepository.save(blacklist);

View File

@@ -98,6 +98,12 @@ export class User {
@Column()
public avatar: string;
@Column({ type: 'varchar', nullable: true })
public avatarETag?: string | null;
@Column({ type: 'varchar', nullable: true })
public avatarVersion?: string | null;
@RelationCount((user: User) => user.requests)
public requestCount: number;

View File

@@ -1,4 +1,10 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './User';
@Entity()
@@ -18,9 +24,15 @@ export class UserPushSubscription {
@Column()
public p256dh: string;
@Column({ unique: true })
@Column()
public auth: string;
@Column({ nullable: true })
public userAgent: string;
@CreateDateColumn({ nullable: true })
public createdAt: Date;
constructor(init?: Partial<UserPushSubscription>) {
Object.assign(this, init);
}

View File

@@ -35,8 +35,6 @@ import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
@@ -74,15 +72,6 @@ app
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
// Check if we force IPv4 first
if (
process.env.forceIpv4First === 'true' ||
settings.network.forceIpv4First
) {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);

View File

@@ -6,7 +6,8 @@ export interface BlacklistItem {
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user: User;
user?: User;
blacklistedTags?: string;
}
export interface BlacklistResultsResponse extends PaginatedResponse {

View File

@@ -3,7 +3,10 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties, PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse {
results: NonFunctionProperties<MediaRequest>[];
results: (NonFunctionProperties<MediaRequest> & {
profileName?: string;
canRemove?: boolean;
})[];
}
export type MediaRequestBody = {

View File

@@ -0,0 +1,184 @@
import type { SortOptions } from '@server/api/themoviedb';
import { SortOptionsIterable } from '@server/api/themoviedb';
import type {
TmdbSearchMovieResponse,
TmdbSearchTvResponse,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;
public async run() {
this.running = true;
try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
label: 'Jobs',
});
} else {
throw err;
}
} finally {
this.reset();
}
}
public status(): StatusBase {
return {
running: this.running,
progress: this.progress,
total: this.total,
};
}
public cancel() {
this.running = false;
this.progress = 0;
this.total = 0;
}
private reset() {
this.cancel();
}
private async createBlacklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
if (blacklistedTags.length === 0) {
return;
}
// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
for (const type of [MediaType.MOVIE, MediaType.TV]) {
const getDiscover =
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
for (let query = 0; query < queryMax; query++) {
const page: number = fixedSortMode
? query + 1
: (query % pageLimit) + 1;
const sortBy: SortOptions | undefined = fixedSortMode
? undefined
: SortOptionsIterable[query % SortOptionsIterable.length];
if (!this.running) {
throw new AbortTransaction();
}
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
}
}
}
}
private async processResults(
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
keywordId: string,
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);
for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
where: { tmdbId: entry.id },
});
if (blacklistEntry) {
// Don't mark manual blacklists with tags
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
{
blacklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
},
},
em
);
}
}
}
private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blacklistedTags IS NOT NULL`)
.getMany();
// Batch removes so the query doesn't get too large
for (let i = 0; i < mediaToRemove.length; i += 500) {
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
}
}
}
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
export default blacklistedTagsProcessor;

View File

@@ -1,4 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
@@ -21,7 +22,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -237,5 +238,21 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
type: 'process',
interval: 'days',
cronSchedule: jobs['process-blacklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blacklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -747,7 +747,11 @@ class AvailabilitySync {
}
if (radarr && radarr.hasFile) {
existsInRadarr = true;
const resolution =
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
}
} catch (ex) {
if (!ex.message.includes('404')) {

View File

@@ -1,6 +1,6 @@
import logger from '@server/logger';
import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import mime from 'mime/lite';
@@ -131,33 +131,29 @@ class ImageProxy {
return 0;
}
private fetch: typeof fetch;
private axios;
private cacheVersion;
private key;
private baseUrl;
private headers: HeadersInit | null = null;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: RateLimitOptions;
headers?: HeadersInit;
rateLimitOptions?: rateLimitOptions;
headers?: Record<string, string>;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.baseUrl = baseUrl;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
headers: options.headers,
});
if (options.rateLimitOptions) {
this.fetch = rateLimit(fetch, {
...options.rateLimitOptions,
});
} else {
this.fetch = fetch;
this.axios = rateLimit(this.axios, options.rateLimitOptions);
}
this.headers = options.headers || null;
}
public async getImage(
@@ -193,14 +189,34 @@ class ImageProxy {
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
const directory = join(this.getCacheDirectory(), cacheKey);
try {
await promises.access(directory);
} catch (e) {
if (e.code === 'ENOENT') {
logger.debug(
`Cache directory '${cacheKey}' does not exist; nothing to clear.`,
{
label: 'Image Cache',
}
);
return;
} else {
logger.error('Error checking cache directory existence', {
label: 'Image Cache',
message: e.message,
});
return;
}
}
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
logger.debug(`Cleared ${files[0]} from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
@@ -249,34 +265,22 @@ class ImageProxy {
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href, {
headers: this.headers || undefined,
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
});
if (!response.ok) {
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension(
response.headers.get('content-type') ?? ''
);
const buffer = Buffer.from(response.data, 'binary');
const contentType = response.headers['content-type'] || '';
const extension = mime.getExtension(contentType) || '';
let maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
(response.headers['cache-control'] ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir(
directory,

View File

@@ -4,6 +4,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
hasNotificationType,
Notification,
@@ -110,6 +111,8 @@ class DiscordAgent
): DiscordRichEmbed {
const { applicationUrl } = getSettings().main;
const appUrl =
applicationUrl || `http://localhost:${process.env.port || 5055}`;
let color = EmbedColors.DARK_PURPLE;
const fields: Field[] = [];
@@ -124,7 +127,7 @@ class DiscordAgent
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = 'Pending Approval';
status = `[Pending Approval](${appUrl}/requests)`;
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
@@ -295,39 +298,23 @@ class DiscordAgent
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content: userMentions.join(' '),
} as DiscordWebhookPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content: userMentions.join(' '),
} as DiscordWebhookPayload);
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Discord notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -30,7 +31,12 @@ class GotifyAgent
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.url && settings.options.token) {
if (
settings.enabled &&
settings.options.url &&
settings.options.token &&
settings.options.priority
) {
return true;
}
@@ -42,15 +48,17 @@ class GotifyAgent
payload: NotificationPayload
): GotifyPayload {
const { applicationUrl, applicationTitle } = getSettings().main;
let priority = 0;
const settings = this.getSettings();
const priority = settings.options.priority ?? 1;
const title = payload.event
? `${payload.event} - ${payload.subject}`
: payload.subject;
let message = payload.message ?? '';
let message = payload.message ? `${payload.message} \n\n` : '';
if (payload.request) {
message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
message += `\n**Requested By:** ${payload.request.requestedBy.displayName} `;
let status = '';
switch (type) {
@@ -73,29 +81,29 @@ class GotifyAgent
}
if (status) {
message += `\nRequest Status: ${status}`;
message += `\n**Request Status:** ${status} `;
}
} else if (payload.comment) {
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message} `;
} else if (payload.issue) {
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
message += `\nIssue Status: ${
message += `\n\n**Reported By:** ${payload.issue.createdBy.displayName} `;
message += `\n**Issue Type:** ${
IssueTypeName[payload.issue.issueType]
} `;
message += `\n**Issue Status:** ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
if (type == Notification.ISSUE_CREATED) {
priority = 1;
}
} `;
}
for (const extra of payload.extra ?? []) {
message += `\n\n**${extra.name}**\n${extra.value}`;
message += `\n\n**${extra.name}**\n${extra.value} `;
}
if (applicationUrl && payload.media) {
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\nOpen in ${applicationTitle}(${actionUrl})`;
const displayUrl =
actionUrl.length > 40 ? `${actionUrl.slice(0, 41)}...` : actionUrl;
message += `\n\n**Open in ${applicationTitle}:** [${displayUrl}](${actionUrl}) `;
}
return {
@@ -132,32 +140,16 @@ class GotifyAgent
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
const notificationPayload = this.getNotificationPayload(type, payload);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, notificationPayload);
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Gotify notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentLunaSea } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -100,39 +101,28 @@ class LunaSeaAgent
});
try {
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: settings.options.profileName
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
? {
'Content-Type': 'application/json',
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
}
: {
'Content-Type': 'application/json',
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
: undefined
);
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
hasNotificationType,
Notification,
@@ -122,34 +123,22 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Token': settings.options.accessToken,
},
body: JSON.stringify({
...notificationPayload,
channel_tag: settings.options.channelTag,
}),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(
endpoint,
{ ...notificationPayload, channel_tag: settings.options.channelTag },
{
headers: {
'Access-Token': settings.options.accessToken,
},
}
);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -174,32 +163,19 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
await axios.post(endpoint, notificationPayload, {
headers: {
'Content-Type': 'application/json',
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -235,32 +211,19 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
await axios.post(endpoint, notificationPayload, {
headers: {
'Content-Type': 'application/json',
'Access-Token': user.settings.pushbulletAccessToken,
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
hasNotificationType,
Notification,
@@ -51,15 +52,12 @@ class PushoverAgent
imageUrl: string
): Promise<Partial<PushoverImagePayload>> {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
});
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const contentType = (
response.headers.get('Content-Type') ||
response.headers.get('content-type')
response.headers['Content-Type'] || response.headers['content-type']
)?.toString();
return {
@@ -67,17 +65,10 @@ class PushoverAgent
attachment_type: contentType,
};
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error getting image payload', {
label: 'Notifications',
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return {};
}
@@ -210,35 +201,19 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
token: settings.options.accessToken,
user: settings.options.userToken,
sound: settings.options.sound,
} as PushoverPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -266,36 +241,20 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
token: payload.notifyUser.settings.pushoverApplicationToken,
user: payload.notifyUser.settings.pushoverUserKey,
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -332,35 +291,19 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...notificationPayload,
token: user.settings.pushoverApplicationToken,
user: user.settings.pushoverUserKey,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
token: user.settings.pushoverApplicationToken,
user: user.settings.pushoverUserKey,
} as PushoverPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -237,32 +238,19 @@ class SlackAgent
subject: payload.subject,
});
try {
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.buildEmbed(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(
settings.options.webhookUrl,
this.buildEmbed(type, payload)
);
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
hasNotificationType,
Notification,
@@ -176,35 +177,19 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...notificationPayload,
chat_id: settings.options.chatId,
message_thread_id: settings.options.messageThreadId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
chat_id: settings.options.chatId,
message_thread_id: settings.options.messageThreadId,
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;
@@ -228,38 +213,22 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
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),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
chat_id: payload.notifyUser.settings.telegramChatId,
message_thread_id:
payload.notifyUser.settings.telegramMessageThreadId,
disable_notification:
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;
@@ -293,36 +262,20 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...notificationPayload,
chat_id: user.settings.telegramChatId,
message_thread_id: user.settings.telegramMessageThreadId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(endpoint, {
...notificationPayload,
chat_id: user.settings.telegramChatId,
message_thread_id: user.settings.telegramMessageThreadId,
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload);
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
import type { NotificationAgentWebhook } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -177,35 +178,26 @@ class WebhookAgent
});
try {
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(settings.options.authHeader
? { Authorization: settings.options.authHeader }
: {}),
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
);
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e?.response?.data,
});
return false;

View File

@@ -65,8 +65,8 @@ class JellyfinScanner {
if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: jellyfinitem.Id,
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id,
});
return;
}
@@ -204,8 +204,8 @@ class JellyfinScanner {
if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: jellyfinitem.Id,
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id,
});
return;
}

View File

@@ -128,6 +128,8 @@ export interface MainSettings {
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blacklistedTags: string;
blacklistedTagsLimit: number;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
@@ -136,7 +138,6 @@ export interface MainSettings {
export interface NetworkSettings {
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
proxy: ProxySettings;
}
@@ -254,6 +255,7 @@ export interface NotificationAgentGotify extends NotificationAgentConfig {
options: {
url: string;
token: string;
priority: number;
};
}
@@ -302,7 +304,8 @@ export type JobId =
| 'jellyfin-recently-added-scan'
| 'jellyfin-full-scan'
| 'image-cache-cleanup'
| 'availability-sync';
| 'availability-sync'
| 'process-blacklisted-tags';
export interface AllSettings {
clientId: string;
@@ -349,6 +352,8 @@ class Settings {
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blacklistedTags: '',
blacklistedTagsLimit: 50,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
@@ -463,6 +468,7 @@ class Settings {
options: {
url: '',
token: '',
priority: 0,
},
},
},
@@ -504,11 +510,13 @@ class Settings {
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
'process-blacklisted-tags': {
schedule: '0 30 1 */7 * *',
},
},
network: {
csrfProtection: false,
trustProxy: false,
forceIpv4First: false,
proxy: {
enabled: false,
hostname: '',

View File

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

View File

@@ -0,0 +1,29 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateWebPush1743023615532 implements MigrationInterface {
name = 'UpdateWebPush1743023615532';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD "userAgent" character varying`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"`
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"`
);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
name = 'AddBlacklistTagsColumn1737320080282';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
);
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))`
);
await queryRunner.query(
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
}
}

View File

@@ -0,0 +1,203 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateWebPush1743023610704 implements MigrationInterface {
name = 'UpdateWebPush1743023610704';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
);
await queryRunner.query(`DROP TABLE "watchlist"`);
await queryRunner.query(
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
);
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
);
await queryRunner.query(
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
);
await queryRunner.query(
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
);
await queryRunner.query(
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"`
);
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
await queryRunner.query(
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
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(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
);
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
);
await queryRunner.query(
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
await queryRunner.query(
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
);
await queryRunner.query(
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
);
await queryRunner.query(
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
);
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
}
}

View File

@@ -0,0 +1,69 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAvatarCacheFields1743107645301
implements MigrationInterface
{
name = 'AddUserAvatarCacheFields1743107645301';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
await queryRunner.query(
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
);
await queryRunner.query(
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
);
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
}
}

View File

@@ -10,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { checkAvatarChanged } from '@server/routes/avatarproxy';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator';
@@ -216,6 +217,10 @@ authRoutes.post('/plex', async (req, res, next) => {
}
});
function getUserAvatarUrl(user: User): string {
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
}
authRoutes.post('/jellyfin', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
@@ -343,12 +348,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
} else {
@@ -375,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.avatar = getUserAvatarUrl(user);
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
@@ -422,7 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUsername: account.User.Name,
}
);
user.avatar = `/avatarproxy/${account.User.Id}`;
user.avatar = getUserAvatarUrl(user);
user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) {
@@ -460,12 +465,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0;
@@ -475,6 +480,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
await userRepository.save(user);
}
if (user && user.jellyfinUserId) {
try {
const { changed } = await checkAvatarChanged(user);
if (changed) {
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
logger.debug('Avatar updated during login', {
userId: user.id,
jellyfinUserId: user.jellyfinUserId,
});
}
} catch (error) {
logger.error('Error handling avatar during login', {
label: 'Auth',
errorMessage: error.message,
});
}
}
// Set logged in session
if (req.session) {
req.session.userId = user?.id;

View File

@@ -6,12 +6,15 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import axios from 'axios';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { createHash } from 'node:crypto';
const router = Router();
let _avatarImageProxy: ImageProxy | null = null;
async function initAvatarImageProxy() {
if (!_avatarImageProxy) {
const userRepository = getRepository(User);
@@ -31,6 +34,83 @@ async function initAvatarImageProxy() {
return _avatarImageProxy;
}
function getJellyfinAvatarUrl(userId: string) {
const settings = getSettings();
return settings.main.mediaServerType === MediaServerType.JELLYFIN
? `${getHostname()}/UserImage?UserId=${userId}`
: `${getHostname()}/Users/${userId}/Images/Primary?quality=90`;
}
function computeImageHash(buffer: Buffer): string {
return createHash('sha256').update(buffer).digest('hex');
}
export async function checkAvatarChanged(
user: User
): Promise<{ changed: boolean; etag?: string }> {
try {
if (!user || !user.jellyfinUserId) {
return { changed: false };
}
const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId);
let headResponse;
try {
headResponse = await axios.head(jellyfinAvatarUrl);
if (headResponse.status !== 200) {
return { changed: false };
}
} catch (error) {
return { changed: false };
}
const settings = getSettings();
let remoteVersion: string;
if (settings.main.mediaServerType === MediaServerType.JELLYFIN) {
const remoteLastModifiedStr = headResponse.headers['last-modified'] || '';
remoteVersion = (
Date.parse(remoteLastModifiedStr) || Date.now()
).toString();
} else if (settings.main.mediaServerType === MediaServerType.EMBY) {
remoteVersion =
headResponse.headers['etag']?.replace(/"/g, '') ||
Date.now().toString();
} else {
remoteVersion = Date.now().toString();
}
if (user.avatarVersion && user.avatarVersion === remoteVersion) {
return { changed: false, etag: user.avatarETag ?? undefined };
}
const avatarImageCache = await initAvatarImageProxy();
await avatarImageCache.clearCachedImage(jellyfinAvatarUrl);
const imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
gravatarUrl(user.email || 'none', { default: 'mm', size: 200 })
);
const newHash = computeImageHash(imageData.imageBuffer);
const hasChanged = user.avatarETag !== newHash;
user.avatarVersion = remoteVersion;
if (hasChanged) {
user.avatarETag = newHash;
}
await getRepository(User).save(user);
return { changed: hasChanged, etag: newHash };
} catch (error) {
logger.error('Error checking avatar changes', {
errorMessage: error.message,
});
return { changed: false };
}
}
router.get('/:jellyfinUserId', async (req, res) => {
try {
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
@@ -46,6 +126,10 @@ router.get('/:jellyfinUserId', async (req, res) => {
const avatarImageCache = await initAvatarImageProxy();
const userEtag = req.headers['if-none-match'];
const versionParam = req.query.v;
const user = await getRepository(User).findOne({
where: { jellyfinUserId: req.params.jellyfinUserId },
});
@@ -55,13 +139,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
size: 200,
});
const setttings = getSettings();
const jellyfinAvatarUrl =
setttings.main.mediaServerType === MediaServerType.JELLYFIN
? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}`
: `${getHostname()}/Users/${
req.params.jellyfinUserId
}/Images/Primary?quality=90`;
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
let imageData = await avatarImageCache.getImage(
jellyfinAvatarUrl,
@@ -73,10 +151,15 @@ router.get('/:jellyfinUserId', async (req, res) => {
imageData = await avatarImageCache.getImage(fallbackUrl);
}
if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) {
return res.status(304).end();
}
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
ETag: `"${imageData.meta.etag}"`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});

View File

@@ -19,39 +19,54 @@ export const blacklistAdd = z.object({
user: z.coerce.number(),
});
const blacklistGet = z.object({
take: z.coerce.number().int().positive().default(25),
skip: z.coerce.number().int().nonnegative().default(0),
search: z.string().optional(),
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
});
blacklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
const { take, skip, search, filter } = blacklistGet.parse(req.query);
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user');
.leftJoinAndSelect('blacklist.user', 'user')
.where('1 = 1'); // Allow use of andWhere later
if (search.length > 0) {
query = query.where('blacklist.title like :title', {
switch (filter) {
case 'manual':
query = query.andWhere('blacklist.blacklistedTags IS NULL');
break;
case 'blacklistedTags':
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
break;
}
if (search) {
query = query.andWhere('blacklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
.take(pageSize)
.take(take)
.skip(skip)
.getManyAndCount();
return res.status(200).json({
pageInfo: {
pages: Math.ceil(itemsCount / pageSize),
pageSize,
pages: Math.ceil(itemsCount / take),
pageSize: take,
results: itemsCount,
page: Math.ceil(skip / pageSize) + 1,
page: Math.ceil(skip / take) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);

View File

@@ -5,6 +5,7 @@ import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});

View File

@@ -237,19 +237,6 @@ mediaRoutes.delete(
}
if (isMovie) {
// check if the movie exists
try {
await (service as RadarrAPI).getMovie({
id: parseInt(
is4k
? (media.externalServiceSlug4k as string)
: (media.externalServiceSlug as string)
),
});
} catch {
return res.status(204).send();
}
// remove the movie
await (service as RadarrAPI).removeMovie(
parseInt(
is4k
@@ -264,13 +251,6 @@ mediaRoutes.delete(
if (!tvdbId) {
throw new Error('TVDB ID not found');
}
// check if the series exists
try {
await (service as SonarrAPI).getSeriesByTvdbId(tvdbId);
} catch {
return res.status(204).send();
}
// remove the series
await (service as SonarrAPI).removeSerie(tvdbId);
}

View File

@@ -189,7 +189,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
);
// add profile names to the media requests, with undefined if not found
const requestsWithProfileNames = requests.map((r) => {
let mappedRequests = requests.map((r) => {
switch (r.type) {
case MediaType.MOVIE: {
const profileName = radarrServers
@@ -212,6 +212,36 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
}
});
// add canRemove prop if user has permission
if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) {
mappedRequests = mappedRequests.map((r) => {
switch (r.type) {
case MediaType.MOVIE: {
return {
...r,
// check if the radarr server for this request is configured
canRemove: radarrServers.some(
(server) =>
server.id ===
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
),
};
}
case MediaType.TV: {
return {
...r,
// check if the sonarr server for this request is configured
canRemove: sonarrServers.some(
(server) =>
server.id ===
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
),
};
}
}
});
}
return res.status(200).json({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
@@ -219,7 +249,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requestsWithProfileNames,
results: mappedRequests,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -539,6 +569,31 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
await requestRepository.remove(request);
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: request.media.id },
relations: { requests: true },
});
if (media) {
if (
!media.requests.some((r) => !r.is4k) &&
media.status !== MediaStatus.AVAILABLE
) {
media.status = MediaStatus.UNKNOWN;
}
if (
!media.requests.some((r) => r.is4k) &&
media.status4k !== MediaStatus.AVAILABLE
) {
media.status4k = MediaStatus.UNKNOWN;
}
await mediaRepository.save(media);
}
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting a request.', {

View File

@@ -184,13 +184,15 @@ router.post<
endpoint: string;
p256dh: string;
auth: string;
userAgent: string;
}
>('/registerPushSubscription', async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({
where: { auth: req.body.auth },
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
if (existingSubs.length > 0) {
@@ -205,6 +207,7 @@ router.post<
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
@@ -219,6 +222,79 @@ router.post<
}
});
router.get<{ userId: number }>(
'/:userId/pushSubscriptions',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSubs = await userPushSubRepository.find({
relations: { user: true },
where: { user: { id: req.params.userId } },
});
return res.status(200).json(userPushSubs);
} catch (e) {
next({ status: 404, message: 'User subscriptions not found.' });
}
}
);
router.get<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSub = await userPushSubRepository.findOneOrFail({
relations: {
user: true,
},
where: {
user: { id: req.params.userId },
p256dh: req.params.key,
},
});
return res.status(200).json(userPushSub);
} catch (e) {
next({ status: 404, message: 'User subscription not found.' });
}
}
);
router.delete<{ userId: number; key: string }>(
'/:userId/pushSubscription/:key',
async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
const userPushSub = await userPushSubRepository.findOneOrFail({
relations: {
user: true,
},
where: {
user: { id: req.params.userId },
p256dh: req.params.key,
},
});
await userPushSubRepository.remove(userPushSub);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting the user push subcription', {
label: 'API',
key: req.params.key,
errorMessage: e.message,
});
return next({
status: 500,
message: 'User push subcription not found',
});
}
}
);
router.get<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);

View File

@@ -1,5 +1,6 @@
import type { ProxySettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import type { Dispatcher } from 'undici';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
@@ -73,15 +74,8 @@ export default async function createCustomProxyAgent(
}
try {
const res = await fetch('https://www.google.com', { method: 'HEAD' });
if (res.ok) {
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
} else {
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
label: 'Proxy',
});
setGlobalDispatcher(defaultAgent);
}
await axios.head('https://www.google.com');
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
} catch (e) {
logger.error(
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,

View File

@@ -1,68 +0,0 @@
export type RateLimitOptions = {
maxRPS: number;
id?: string;
};
type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
queue: {
args: Parameters<T>;
resolve: (value: U) => void;
reject: (reason?: unknown) => void;
}[];
lastTimestamps: number[];
timeout: ReturnType<typeof setTimeout>;
};
const rateLimitById: Record<string, unknown> = {};
/**
* Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed.
* @param fn The function to rate limit
* @param options.maxRPS Maximum number of Requests Per Second
* @param options.id An ID to share between rate limits, so it uses the same request queue.
* @returns The function with a rate limit
*/
export default function rateLimit<
T extends (...args: Parameters<T>) => Promise<U>,
U
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
const state: RateLimiteState<T, U> = (rateLimitById[
options.id || ''
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
if (options.id) {
rateLimitById[options.id] = state;
}
const processQueue = () => {
// remove old timestamps
state.lastTimestamps = state.lastTimestamps.filter(
(timestamp) => Date.now() - timestamp < 1000
);
if (state.lastTimestamps.length < options.maxRPS) {
// process requests if RPS not exceeded
const item = state.queue.shift();
if (!item) return;
state.lastTimestamps.push(Date.now());
const { args, resolve, reject } = item;
fn(...args)
.then(resolve)
.catch(reject);
processQueue();
} else {
// rerun once the oldest item in queue is older than 1s
if (state.timeout) clearTimeout(state.timeout);
state.timeout = setTimeout(
processQueue,
1000 - (Date.now() - state.lastTimestamps[0])
);
}
};
return (...args: Parameters<T>): Promise<U> => {
return new Promise<U>((resolve, reject) => {
state.queue.push({ args, resolve, reject });
processQueue();
});
};
}

View File

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

View File

@@ -1,3 +1,4 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
FunnelIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
@@ -23,6 +25,7 @@ import type {
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react';
@@ -41,8 +44,17 @@ const messages = defineMessages('components.Blacklist', {
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
filterManual: 'Manual',
filterBlacklistedTags: 'Blacklisted Tags',
showAllBlacklisted: 'Show All Blacklisted Media',
});
enum Filter {
ALL = 'all',
MANUAL = 'manual',
BLACKLISTEDTAGS = 'blacklistedTags',
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
@@ -51,6 +63,7 @@ const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
const router = useRouter();
const intl = useIntl();
@@ -63,9 +76,11 @@ const Blacklist = () => {
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}
&skip=${pageIndex * currentPageSize}
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&filter=${currentFilter}${
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
@@ -93,19 +108,52 @@ const Blacklist = () => {
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentFilter}
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(globalMessages.all)}
</option>
<option value="manual">
{intl.formatMessage(messages.filterManual)}
</option>
<option value="blacklistedTags">
{intl.formatMessage(messages.filterBlacklistedTags)}
</option>
</select>
</div>
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
</div>
@@ -116,6 +164,16 @@ const Blacklist = () => {
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
{currentFilter !== Filter.ALL && (
<div className="mt-4">
<Button
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
{intl.formatMessage(messages.showAllBlacklisted)}
</Button>
</div>
)}
</div>
) : (
data.results.map((item: BlacklistItem) => {
@@ -238,11 +296,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
try {
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -252,7 +308,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
} catch {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
@@ -353,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
numeric="auto"
/>
),
user: (
user: item.user ? (
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
@@ -370,6 +426,14 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>
</span>
</Link>
) : item.blacklistedTags ? (
<span className="ml-1">
<BlacklistedTagsBadge data={item} />
</span>
) : (
<span className="ml-1 truncate text-sm font-semibold">
???
</span>
),
})}
</span>

View File

@@ -1,3 +1,4 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -7,6 +8,7 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -38,11 +40,9 @@ const BlacklistBlock = ({
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
try {
await axios.delete('/api/v1/blacklist/' + tmdbId);
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
@@ -52,7 +52,7 @@ const BlacklistBlock = ({
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
} catch {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
@@ -78,22 +78,33 @@ const BlacklistBlock = ({
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
{data.user ? (
<>
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
</span>
</Link>
</span>
</Link>
</span>
</>
) : data.blacklistedTags ? (
<>
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(messages.blacklistedby)}:&nbsp;
</span>
<BlacklistedTagsBadge data={data} />
</>
) : null}
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">

View File

@@ -4,6 +4,7 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -44,12 +45,8 @@ const BlacklistModal = ({
if (!show) return;
try {
setError(null);
const response = await fetch(`/api/v1/${type}/${tmdbId}`);
if (!response.ok) {
throw new Error();
}
const result = await response.json();
setData(result);
const response = await axios.get(`/api/v1/${type}/${tmdbId}`);
setData(response.data);
} catch (err) {
setError(err);
}

View File

@@ -0,0 +1,62 @@
import Badge from '@app/components/Common/Badge';
import Tooltip from '@app/components/Common/Tooltip';
import defineMessages from '@app/utils/defineMessages';
import { TagIcon } from '@heroicons/react/20/solid';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.Settings', {
blacklistedTagsText: 'Blacklisted Tags',
});
interface BlacklistedTagsBadgeProps {
data: BlacklistItem;
}
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
useState<string>('Loading...');
const intl = useIntl();
useEffect(() => {
if (!data.blacklistedTags) {
return;
}
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
Promise.all(
keywordIds.map(async (keywordId) => {
try {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data.name;
} catch (err) {
return '';
}
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));
});
}, [data.blacklistedTags]);
return (
<Tooltip
content={tagNamesBlacklistedFor}
tooltipConfig={{ followCursor: false }}
>
<Badge
badgeType="dark"
className="items-center border border-red-500 !text-red-400"
>
<TagIcon className="mr-1 h-4" />
{intl.formatMessage(messages.blacklistedTagsText)}
</Badge>
</Tooltip>
);
};
export default BlacklistedTagsBadge;

View File

@@ -0,0 +1,402 @@
import Modal from '@app/components/Common/Modal';
import Tooltip from '@app/components/Common/Tooltip';
import CopyButton from '@app/components/Settings/CopyButton';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { ArrowDownIcon } from '@heroicons/react/24/solid';
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
import type { Keyword } from '@server/models/common';
import axios from 'axios';
import { useFormikContext } from 'formik';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { useIntl } from 'react-intl';
import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select';
import { components } from 'react-select';
import AsyncSelect from 'react-select/async';
const messages = defineMessages('components.Settings', {
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
copyBlacklistedTagsEmpty: 'Nothing to copy',
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
clearBlacklistedTagsConfirm:
'Are you sure you want to clear the blacklisted tags?',
yes: 'Yes',
no: 'No',
searchKeywords: 'Search keywords…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
valueRequired: 'You must provide a value.',
noSpecialCharacters:
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
invalidKeyword: '{keywordId} is not a TMDB keyword.',
});
type SingleVal = {
label: string;
value: number;
};
type BlacklistedTagsSelectorProps = {
defaultValue?: string;
};
const BlacklistedTagsSelector = ({
defaultValue,
}: BlacklistedTagsSelectorProps) => {
const { setFieldValue } = useFormikContext();
const [value, setValue] = useState<string | undefined>(defaultValue);
const intl = useIntl();
const [selectorValue, setSelectorValue] =
useState<MultiValue<SingleVal> | null>(null);
const update = useCallback(
(value: MultiValue<SingleVal> | null) => {
const strVal = value?.map((v) => v.value).join(',');
setSelectorValue(value);
setValue(strVal);
setFieldValue('blacklistedTags', strVal);
},
[setSelectorValue, setValue, setFieldValue]
);
const copyDisabled = value === null || value?.length === 0;
return (
<>
<ControlledKeywordSelector
value={selectorValue}
onChange={update}
defaultValue={defaultValue}
components={{
DropdownIndicator: undefined,
IndicatorSeparator: undefined,
ClearIndicator: VerifyClearIndicator,
}}
/>
<CopyButton
textToCopy={value ?? ''}
disabled={copyDisabled}
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
tooltipContent={intl.formatMessage(
copyDisabled
? messages.copyBlacklistedTagsEmpty
: messages.copyBlacklistedTagsTip
)}
tooltipConfig={{ followCursor: false }}
/>
<BlacklistedTagsImportButton setSelector={update} />
</>
);
};
type BaseSelectorMultiProps = {
defaultValue?: string;
value: MultiValue<SingleVal> | null;
onChange: (value: MultiValue<SingleVal> | null) => void;
components?: Partial<typeof components>;
};
const ControlledKeywordSelector = ({
defaultValue,
onChange,
components,
value,
}: BaseSelectorMultiProps) => {
const intl = useIntl();
useEffect(() => {
const loadDefaultKeywords = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data;
})
);
onChange(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
loadDefaultKeywords();
}, [defaultValue, onChange]);
const loadKeywordOptions = async (inputValue: string) => {
const { data } = await axios.get<TmdbKeywordSearchResponse>(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
);
return data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`keyword-select-blacklistedTags`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
value={value}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={onChange}
components={components}
/>
);
};
type BlacklistedTagsImportButtonProps = {
setSelector: (value: MultiValue<SingleVal>) => void;
};
const BlacklistedTagsImportButton = ({
setSelector,
}: BlacklistedTagsImportButtonProps) => {
const [show, setShow] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const intl = useIntl();
const onConfirm = useCallback(async () => {
if (formRef.current) {
if (await formRef.current.submitForm()) {
setShow(false);
}
}
}, []);
const onClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setShow(true);
}, []);
return (
<>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
okText="Confirm"
onOk={onConfirm}
onCancel={() => setShow(false)}
>
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
</Modal>
</Transition>
<Tooltip
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
tooltipConfig={{ followCursor: false }}
>
<button className="input-action" onClick={onClick} type="button">
<ArrowDownIcon />
</button>
</Tooltip>
</>
);
};
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
const BlacklistedTagImportForm = forwardRef<
Partial<HTMLFormElement>,
BlacklistedTagImportFormProps
>((props, ref) => {
const { setSelector } = props;
const intl = useIntl();
const [formValue, setFormValue] = useState('');
const [errors, setErrors] = useState<string[]>([]);
useImperativeHandle(ref, () => ({
submitForm: handleSubmit,
formValue,
}));
const validate = async () => {
if (formValue.length === 0) {
setErrors([intl.formatMessage(messages.valueRequired)]);
return false;
}
if (!/^(?:\d+,)*\d+$/.test(formValue)) {
setErrors([intl.formatMessage(messages.noSpecialCharacters)]);
return false;
}
const keywords = await Promise.allSettled(
formValue.split(',').map(async (keywordId) => {
try {
const { data } = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return {
label: data.name,
value: data.id,
};
} catch (err) {
throw intl.formatMessage(messages.invalidKeyword, { keywordId });
}
})
);
const failures = keywords.filter(
(res) => res.status === 'rejected'
) as PromiseRejectedResult[];
if (failures.length > 0) {
setErrors(failures.map((failure) => `${failure.reason}`));
return false;
}
setSelector(
(keywords as PromiseFulfilledResult<SingleVal>[]).map(
(keyword) => keyword.value
)
);
setErrors([]);
return true;
};
const handleSubmit = validate;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="value">
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
</label>
<textarea
id="value"
value={formValue}
onChange={(e) => setFormValue(e.target.value)}
className="h-20"
/>
{errors.length > 0 && (
<div className="error">
{errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
)}
</div>
</form>
);
});
const VerifyClearIndicator = <
Option,
IsMuti extends boolean,
Group extends GroupBase<Option>
>(
props: ClearIndicatorProps<Option, IsMuti, Group>
) => {
const { clearValue } = props;
const [show, setShow] = useState(false);
const intl = useIntl();
const openForm = useCallback(() => {
setShow(true);
}, [setShow]);
const openFormKey = useCallback(
(event: React.KeyboardEvent) => {
if (show) return;
if (event.key === 'Enter' || event.key === 'Space') {
setShow(true);
}
},
[setShow, show]
);
const acceptForm = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.stopPropagation();
event.preventDefault();
clearValue();
}
},
[clearValue]
);
useEffect(() => {
if (show) {
window.addEventListener('keydown', acceptForm);
}
return () => window.removeEventListener('keydown', acceptForm);
}, [show, acceptForm]);
return (
<>
<button
type="button"
onClick={openForm}
onKeyDown={openFormKey}
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
>
<components.CrossIcon />
</button>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
okText={intl.formatMessage(messages.yes)}
cancelText={intl.formatMessage(messages.no)}
onOk={clearValue}
onCancel={() => setShow(false)}
>
<form />{' '}
{/* Form prevents accidentally saving settings when pressing enter */}
</Modal>
</Transition>
</>
);
};
export default BlacklistedTagsSelector;

View File

@@ -29,7 +29,6 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
data-form-type="other"
data-1pignore="true"
data-lpignore="true"
data-bwignore="true"
{...componentProps}
className={`rounded-l-only ${componentProps.className ?? ''}`}
type={

View File

@@ -14,6 +14,7 @@ import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -76,9 +77,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
const keyword: Keyword = await res.json();
return keyword;
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
@@ -95,13 +98,15 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return;
}
const res = await fetch(
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genres: TmdbGenre[] = await res.json();
const genre = genres.find((genre) => genre.id === Number(slider.data));
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
@@ -116,8 +121,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return;
}
const res = await fetch(`/api/v1/studio/${slider.data}`);
const studio: ProductionCompany = await res.json();
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
@@ -160,17 +168,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
);
const loadKeywordOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`,
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
headers: {
'Content-Type': 'application/json',
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
const results: TmdbKeywordSearchResponse = await res.json();
return results.results.map((result) => ({
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -181,37 +188,38 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
return [];
}
const res = await fetch(
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`,
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
headers: {
'Content-Type': 'application/json',
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
const results: TmdbCompanySearchResponse = await res.json();
return results.results.map((result) => ({
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadMovieGenreOptions = async () => {
const res = await fetch('/api/v1/discover/genreslider/movie');
const results: GenreSliderItem[] = await res.json();
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.map((result) => ({
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const res = await fetch('/api/v1/discover/genreslider/tv');
const results: GenreSliderItem[] = await res.json();
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.map((result) => ({
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -306,31 +314,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
onSubmit={async (values, { resetForm }) => {
try {
if (slider) {
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
if (!res.ok) throw new Error();
} else {
const res = await fetch('/api/v1/settings/discover/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: Number(values.sliderType),
title: values.title,
data: values.data,
}),
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
if (!res.ok) throw new Error();
}
addToast(

View File

@@ -20,6 +20,7 @@ import {
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { useIntl } from 'react-intl';
@@ -77,10 +78,7 @@ const DiscoverSliderEdit = ({
const deleteSlider = async () => {
try {
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,

View File

@@ -28,6 +28,7 @@ import {
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -75,14 +76,7 @@ const Discover = () => {
const updateSliders = async () => {
try {
const res = await fetch('/api/v1/settings/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sliders),
});
if (!res.ok) throw new Error();
await axios.post('/api/v1/settings/discover', sliders);
addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success',
@@ -100,10 +94,7 @@ const Discover = () => {
const resetSliders = async () => {
try {
const res = await fetch('/api/v1/settings/discover/reset', {
method: 'GET',
});
if (!res.ok) throw new Error();
await axios.get('/api/v1/settings/discover/reset');
addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success',

View File

@@ -6,6 +6,7 @@ import defineMessages from '@app/utils/defineMessages';
import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { Fragment, useState } from 'react';
@@ -48,10 +49,7 @@ const IssueComment = ({
const deleteComment = async () => {
try {
const res = await fetch(`/api/v1/issueComment/${comment.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/issueComment/${comment.id}`);
} catch (e) {
// something went wrong deleting the comment
} finally {
@@ -178,17 +176,9 @@ const IssueComment = ({
<Formik
initialValues={{ newMessage: comment.message }}
onSubmit={async (values) => {
const res = await fetch(
`/api/v1/issueComment/${comment.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }),
}
);
if (!res.ok) throw new Error();
await axios.put(`/api/v1/issueComment/${comment.id}`, {
message: values.newMessage,
});
if (onUpdate) {
onUpdate();

View File

@@ -27,6 +27,7 @@ import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -121,14 +122,9 @@ const IssueDetails = () => {
const editFirstComment = async (newMessage: string) => {
try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
message: newMessage,
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success',
@@ -145,10 +141,7 @@ const IssueDetails = () => {
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try {
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success',
@@ -166,10 +159,7 @@ const IssueDetails = () => {
const deleteIssue = async () => {
try {
const res = await fetch(`/api/v1/issue/${issueData.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/issue/${issueData.id}`);
mutate('/api/v1/issue/count');
addToast(intl.formatMessage(messages.toastissuedeleted), {
@@ -504,17 +494,9 @@ const IssueDetails = () => {
}}
validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => {
const res = await fetch(
`/api/v1/issue/${issueData?.id}/comment`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);
if (!res.ok) throw new Error();
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
message: values.message,
});
revalidateIssue();
resetForm();
}}

View File

@@ -11,6 +11,7 @@ import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Formik } from 'formik';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -100,22 +101,14 @@ const CreateIssueModal = ({
validationSchema={CreateIssueModalSchema}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
issueType: values.selectedIssue.issueType,
message: values.message,
mediaId: data?.mediaInfo?.id,
problemSeason: values.problemSeason,
problemEpisode:
values.problemSeason > 0 ? values.problemEpisode : 0,
}),
const newIssue = await axios.post<Issue>('/api/v1/issue', {
issueType: values.selectedIssue.issueType,
message: values.message,
mediaId: data?.mediaInfo?.id,
problemSeason: values.problemSeason,
problemEpisode:
values.problemSeason > 0 ? values.problemEpisode : 0,
});
if (!res.ok) throw new Error();
const newIssue: Issue = await res.json();
if (data) {
addToast(
@@ -126,7 +119,7 @@ const CreateIssueModal = ({
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</div>
<Link href={`/issues/${newIssue.id}`} legacyBehavior>
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
<Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowRightCircleIcon />

View File

@@ -255,7 +255,9 @@ const MobileMenu = ({
router.pathname.match(link.activeRegExp)
? 'border-indigo-600 from-indigo-700 to-purple-700'
: 'border-indigo-500 from-indigo-600 to-purple-600'
} flex h-4 w-4 items-center justify-center !px-[9px] !py-[9px] text-[9px]`}
} flex ${
pendingRequestsCount > 99 ? 'w-6' : 'w-4'
} h-4 items-center justify-center !px-[5px] !py-[7px] text-[8px]`}
>
{pendingRequestsCount > 99
? '99+'

View File

@@ -8,6 +8,7 @@ import {
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { forwardRef, Fragment } from 'react';
@@ -38,13 +39,9 @@ const UserDropdown = () => {
const { user, revalidate } = useUser();
const logout = async () => {
const res = await fetch('/api/v1/auth/logout', {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
const response = await axios.post('/api/v1/auth/logout');
if (data?.status === 'ok') {
if (response.data?.status === 'ok') {
revalidate();
}
};

View File

@@ -2,6 +2,7 @@ import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useIntl } from 'react-intl';
import * as Yup from 'yup';
@@ -57,18 +58,11 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
validationSchema={EmailSettingsSchema}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,
email: values.email,
}),
await axios.post('/api/v1/auth/jellyfin', {
username: username,
password: password,
email: values.email,
});
if (!res.ok) throw new Error();
onSave();
} catch (e) {

View File

@@ -5,6 +5,7 @@ import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -71,28 +72,14 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
validateOnBlur={false}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
password: values.password,
email: values.username,
}),
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
email: values.username,
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null;
switch (errorData?.message) {
switch (e?.response?.data?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;

View File

@@ -3,6 +3,7 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useState } from 'react';
@@ -54,17 +55,10 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
validateOnBlur={false}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/auth/local', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: values.email,
password: values.password,
}),
await axios.post('/api/v1/auth/local', {
email: values.email,
password: values.password,
});
if (!res.ok) throw new Error();
} catch (e) {
setLoginError(intl.formatMessage(messages.loginerror));
} finally {

View File

@@ -14,6 +14,7 @@ import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
@@ -50,28 +51,13 @@ const Login = () => {
const login = async () => {
setProcessing(true);
try {
const res = await fetch('/api/v1/auth/plex', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authToken }),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
const data = await res.json();
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (data?.id) {
if (response.data?.id) {
revalidate();
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
setError(errorData?.message);
setError(e.response?.data?.message);
setAuthToken(undefined);
setProcessing(false);
}

View File

@@ -28,6 +28,7 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -111,10 +112,7 @@ const ManageSlideOver = ({
const deleteMedia = async () => {
if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate();
onClose();
}
@@ -122,14 +120,8 @@ const ManageSlideOver = ({
const deleteMediaFile = async () => {
if (data.mediaInfo) {
// we don't check if the response is ok here because there may be no file to delete
await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
method: 'DELETE',
});
await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
method: 'DELETE',
});
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate();
onClose();
}
@@ -158,16 +150,9 @@ const ManageSlideOver = ({
const markAvailable = async (is4k = false) => {
if (data.mediaInfo) {
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is4k,
}),
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
is4k,
});
if (!res.ok) throw new Error();
revalidate();
}
};

View File

@@ -52,6 +52,7 @@ import { IssueStatus } from '@server/constants/issue';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import axios from 'axios';
import { countries } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash';
@@ -319,41 +320,29 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
const res = await fetch('/api/v1/watchlist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
try {
const response = await axios.post('/api/v1/watchlist', {
tmdbId: movie?.id,
mediaType: MediaType.MOVIE,
title: movie?.title,
}),
});
});
if (!res.ok) {
if (response.data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch (e) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
autoDismiss: true,
});
setIsUpdating(false);
return;
}
const data = await res.json();
if (data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
setIsUpdating(false);
@@ -363,22 +352,17 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/watchlist/${movie?.id}`);
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
}
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} catch (e) {
addToast(intl.formatMessage(messages.watchlistError), {
appearance: 'error',
@@ -393,21 +377,14 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const onClickHideItemBtn = async (): Promise<void> => {
setIsBlacklistUpdating(true);
const res = await fetch('/api/v1/blacklist', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
try {
await axios.post('/api/v1/blacklist', {
tmdbId: movie?.id,
mediaType: 'movie',
title: movie?.title,
user: user?.id,
}),
});
});
if (res.status === 201) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistSuccess, {
@@ -419,21 +396,23 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
);
revalidate();
} else if (res.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
} catch (e) {
if (e?.response?.status === 412) {
addToast(
<span>
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
title: movie?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'info', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
}
setIsBlacklistUpdating(false);

View File

@@ -17,6 +17,7 @@ import {
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -53,10 +54,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
setIsUpdating(true);
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
await axios.post(`/api/v1/request/${request.id}/${type}`);
if (onUpdate) {
onUpdate();
@@ -67,10 +65,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const deleteRequest = async () => {
setIsUpdating(true);
const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/request/${request.id}`);
if (onUpdate) {
onUpdate();

View File

@@ -13,6 +13,7 @@ import {
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import { useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { mutate } from 'swr';
@@ -94,13 +95,9 @@ const RequestButton = ({
request: MediaRequest,
type: 'approve' | 'decline'
) => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
if (data) {
if (response) {
onUpdate();
mutate('/api/v1/request/count');
}
@@ -116,11 +113,7 @@ const RequestButton = ({
await Promise.all(
requests.map(async (request) => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
return res.json();
return axios.post(`/api/v1/request/${request.id}/${type}`);
})
);

View File

@@ -23,6 +23,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -74,10 +75,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
});
const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
@@ -264,23 +262,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
});
const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
if (data) {
if (response) {
revalidate();
mutate('/api/v1/request/count');
}
};
const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/request/${request.id}`);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
};
@@ -289,13 +280,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
setRetrying(true);
try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
if (data) {
if (response) {
revalidate();
}
} catch (e) {

View File

@@ -17,12 +17,13 @@ import {
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus, MediaType } from '@server/constants/media';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -65,10 +66,7 @@ const RequestItemError = ({
const { hasPermission } = useUser();
const deleteRequest = async () => {
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
revalidateList();
mutate('/api/v1/request/count');
};
@@ -292,18 +290,11 @@ const RequestItemError = ({
};
interface RequestItemProps {
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
request: RequestResultsResponse['results'][number];
revalidateList: () => void;
radarrData?: RadarrSettings[];
sonarrData?: SonarrSettings[];
}
const RequestItem = ({
request,
revalidateList,
radarrData,
sonarrData,
}: RequestItemProps) => {
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
@@ -335,23 +326,16 @@ const RequestItem = ({
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => {
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const data = await res.json();
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
if (data) {
if (response) {
revalidate();
mutate('/api/v1/request/count');
}
};
const deleteRequest = async () => {
const res = await fetch(`/api/v1/request/${request.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/request/${request.id}`);
revalidateList();
mutate('/api/v1/request/count');
@@ -359,13 +343,8 @@ const RequestItem = ({
const deleteMediaFile = async () => {
if (request.media) {
// we don't check if the response is ok here because there may be no file to delete
await fetch(`/api/v1/media/${request.media.id}/file`, {
method: 'DELETE',
});
await fetch(`/api/v1/media/${request.media.id}`, {
method: 'DELETE',
});
await axios.delete(`/api/v1/media/${request.media.id}/file`);
await axios.delete(`/api/v1/media/${request.media.id}`);
revalidateList();
}
};
@@ -374,12 +353,7 @@ const RequestItem = ({
setRetrying(true);
try {
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
method: 'POST',
});
if (!res.ok) throw new Error();
const result = await res.json();
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
revalidate(result.data);
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {
@@ -398,23 +372,6 @@ const RequestItem = ({
iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k,
});
const serviceExists = () => {
if (title?.mediaInfo) {
if (title?.mediaInfo.mediaType === MediaType.MOVIE) {
return (
radarrData?.find((radarr) => radarr.id === request.serverId) !==
undefined
);
} else {
return (
sonarrData?.find((sonarr) => sonarr.id === request.serverId) !==
undefined
);
}
}
return false;
};
if (!title && !error) {
return (
<div
@@ -722,30 +679,30 @@ const RequestItem = ({
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
)}
{hasPermission(Permission.MANAGE_REQUESTS) &&
title?.mediaInfo?.serviceId &&
serviceExists() && (
<ConfirmButton
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>
{intl.formatMessage(messages.removearr, {
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</ConfirmButton>
<>
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
{request.canRemove && (
<ConfirmButton
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>
{intl.formatMessage(messages.removearr, {
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</ConfirmButton>
)}
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -17,8 +17,6 @@ import {
FunnelIcon,
} from '@heroicons/react/24/solid';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -53,7 +51,7 @@ const RequestList = () => {
const { user } = useUser({
id: Number(router.query.userId),
});
const { user: currentUser, hasPermission } = useUser();
const { user: currentUser } = useUser();
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentSortDirection, setCurrentSortDirection] =
@@ -64,13 +62,6 @@ const RequestList = () => {
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const { data: radarrData } = useSWR<RadarrSettings[]>(
hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null
);
const { data: sonarrData } = useSWR<SonarrSettings[]>(
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
);
const {
data,
error,
@@ -254,8 +245,6 @@ const RequestList = () => {
<RequestItem
request={request}
revalidateList={() => revalidate()}
radarrData={radarrData}
sonarrData={sonarrData}
/>
</div>
);

View File

@@ -13,6 +13,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { Collection } from '@server/models/Collection';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -198,19 +199,12 @@ const CollectionRequestModal = ({
(
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
).map(async (part) => {
const res = await fetch('/api/v1/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
await axios.post<MediaRequest>('/api/v1/request', {
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
});
if (!res.ok) throw new Error();
})
);

View File

@@ -12,6 +12,7 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -89,24 +90,16 @@ const MovieRequestModal = ({
tags: requestOverrides.tags,
};
}
const res = await fetch('/api/v1/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaId: data?.id,
mediaType: 'movie',
is4k,
...overrideParams,
}),
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'movie',
is4k,
...overrideParams,
});
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (mediaRequest) {
if (response.data) {
if (onComplete) {
onComplete(
hasPermission(
@@ -154,15 +147,13 @@ const MovieRequestModal = ({
setIsUpdating(true);
try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
const response = await axios.delete<MediaRequest>(
`/api/v1/request/${editRequest?.id}`
);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (res.status === 204) {
if (response.status === 204) {
if (onComplete) {
onComplete(MediaStatus.UNKNOWN);
}
@@ -185,27 +176,17 @@ const MovieRequestModal = ({
setIsUpdating(true);
try {
const res = await fetch(`/api/v1/request/${editRequest?.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaType: 'movie',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
}),
await axios.put(`/api/v1/request/${editRequest?.id}`, {
mediaType: 'movie',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
if (!res.ok) throw new Error();
if (alsoApproveRequest) {
const res = await fetch(`/api/v1/request/${editRequest?.id}/approve`, {
method: 'POST',
});
if (!res.ok) throw new Error();
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');

View File

@@ -17,6 +17,7 @@ import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -111,35 +112,22 @@ const TvRequestModal = ({
try {
if (selectedSeasons.length > 0) {
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaType: 'tv',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
}),
await axios.put(`/api/v1/request/${editRequest.id}`, {
mediaType: 'tv',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
});
if (!res.ok) throw new Error();
if (alsoApproveRequest) {
const res = await fetch(`/api/v1/request/${editRequest.id}/approve`, {
method: 'POST',
});
if (!res.ok) throw new Error();
await axios.post(`/api/v1/request/${editRequest.id}/approve`);
}
} else {
const res = await fetch(`/api/v1/request/${editRequest.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
await axios.delete(`/api/v1/request/${editRequest.id}`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
@@ -206,32 +194,23 @@ const TvRequestModal = ({
tags: requestOverrides.tags,
};
}
const res = await fetch('/api/v1/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
mediaId: data?.id,
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
mediaType: 'tv',
is4k,
seasons: settings.currentSettings.partialRequestsEnabled
? selectedSeasons.sort((a, b) => a - b)
: getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
),
...overrideParams,
}),
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
mediaType: 'tv',
is4k,
seasons: settings.currentSettings.partialRequestsEnabled
? selectedSeasons.sort((a, b) => a - b)
: getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
),
...overrideParams,
});
if (!res.ok) throw new Error();
const mediaRequest: MediaRequest = await res.json();
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (mediaRequest) {
if (response.data) {
if (onComplete) {
onComplete(mediaRequest.media.status);
onComplete(response.data.media.status);
}
addToast(
<span>

View File

@@ -4,6 +4,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
@@ -84,18 +85,14 @@ const ResetPassword = () => {
}}
validationSchema={ResetSchema}
onSubmit={async (values) => {
const res = await fetch(`/api/v1/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
const response = await axios.post(
`/api/v1/auth/reset-password`,
{
email: values.email,
}),
});
if (!res.ok) throw new Error();
}
);
if (res.status === 200) {
if (response.status === 200) {
setSubmitted(true);
}
}}

View File

@@ -5,6 +5,7 @@ import LanguagePicker from '@app/components/Layout/LanguagePicker';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { LifebuoyIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
@@ -99,21 +100,14 @@ const ResetPassword = () => {
}}
validationSchema={ResetSchema}
onSubmit={async (values) => {
const res = await fetch(
const response = await axios.post(
`/api/v1/auth/reset-password/${guid}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password: values.password,
}),
password: values.password,
}
);
if (!res.ok) throw new Error();
if (res.status === 200) {
if (response.status === 200) {
setSubmitted(true);
}
}}

View File

@@ -19,6 +19,7 @@ import type {
ProductionCompany,
WatchProviderDetails,
} from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -80,9 +81,11 @@ export const CompanySelector = ({
return;
}
const res = await fetch(`/api/v1/studio/${defaultValue}`);
if (!res.ok) throw new Error();
const studio: ProductionCompany = await res.json();
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${defaultValue}`
);
const studio = response.data;
setDefaultDataValue([
{
@@ -100,15 +103,16 @@ export const CompanySelector = ({
return [];
}
const res = await fetch(
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbCompanySearchResponse = await res.json();
return results.results.map((result) => ({
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -164,15 +168,11 @@ export const GenreSelector = ({
const genres = defaultValue.split(',');
const res = await fetch(`/api/v1/genres/${type}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: TmdbGenre[] = await res.json();
const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
const genreData = genres
.filter((genre) => response.find((gd) => gd.id === Number(genre)))
.map((g) => response.find((gd) => gd.id === Number(g)))
.filter((genre) => response.data.find((gd) => gd.id === Number(genre)))
.map((g) => response.data.find((gd) => gd.id === Number(g)))
.map((g) => ({
label: g?.name ?? '',
value: g?.id ?? 0,
@@ -185,11 +185,11 @@ export const GenreSelector = ({
}, [defaultValue, type]);
const loadGenreOptions = async (inputValue: string) => {
const res = await fetch(`/api/v1/discover/genreslider/${type}`);
if (!res.ok) throw new Error();
const results: GenreSliderItem[] = await res.json();
const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
return results
return results.data
.map((result) => ({
label: result.name,
value: result.id,
@@ -309,13 +309,11 @@ export const KeywordSelector = ({
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const res = await fetch(`/api/v1/keyword/${keywordId}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const keyword: Keyword = await res.json();
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword;
return keyword.data;
})
);
@@ -331,15 +329,16 @@ export const KeywordSelector = ({
}, [defaultValue]);
const loadKeywordOptions = async (inputValue: string) => {
const res = await fetch(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const results: TmdbKeywordSearchResponse = await res.json();
return results.results.map((result) => ({
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
@@ -578,14 +577,10 @@ export const UserSelector = ({
const users = defaultValue.split(',');
const res = await fetch(
const res = await axios.get(
`/api/v1/user?includeIds=${encodeURIComponent(defaultValue)}`
);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const response: UserResultsResponse = await res.json();
const response: UserResultsResponse = res.data;
const genreData = users
.filter((u) => response.results.find((user) => user.id === Number(u)))
@@ -602,11 +597,10 @@ export const UserSelector = ({
}, [defaultValue]);
const loadUserOptions = async (inputValue: string) => {
const res = await fetch(
const res = await axios.get(
`/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
);
if (!res.ok) throw new Error();
const results: UserResultsResponse = await res.json();
const results: UserResultsResponse = res.data;
return results.results
.map((result) => ({

View File

@@ -1,10 +1,9 @@
/* eslint-disable no-console */
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { useEffect } from 'react';
const ServiceWorkerSetup = () => {
const { currentSettings } = useSettings();
const { user } = useUser();
useEffect(() => {
if ('serviceWorker' in navigator && user?.id) {
@@ -15,40 +14,12 @@ const ServiceWorkerSetup = () => {
'[SW] Registration successful, scope is:',
registration.scope
);
if (currentSettings.enablePushRegistration) {
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: currentSettings.vapidPublic,
});
const parsedSub = JSON.parse(JSON.stringify(sub));
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
const res = await fetch('/api/v1/user/registerPushSubscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
}),
});
if (!res.ok) throw new Error();
}
}
})
.catch(function (error) {
console.log('[SW] Service worker registration failed, error:', error);
});
}
}, [
user,
currentSettings.vapidPublic,
currentSettings.enablePushRegistration,
]);
}, [user]);
return null;
};

View File

@@ -1,40 +1,54 @@
import defineMessages from '@app/utils/defineMessages';
import Tooltip from '@app/components/Common/Tooltip';
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import React, { useEffect } from 'react';
import type { Config } from 'react-popper-tooltip';
import { useToasts } from 'react-toast-notifications';
import useClipboard from 'react-use-clipboard';
const messages = defineMessages('components.Settings', {
copied: 'Copied API key to clipboard.',
});
type CopyButtonProps = {
textToCopy: string;
disabled?: boolean;
toastMessage?: string;
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
const intl = useIntl();
tooltipContent?: React.ReactNode;
tooltipConfig?: Partial<Config>;
};
const CopyButton = ({
textToCopy,
disabled,
toastMessage,
tooltipContent,
tooltipConfig,
}: CopyButtonProps) => {
const [isCopied, setCopied] = useClipboard(textToCopy, {
successDuration: 1000,
});
const { addToast } = useToasts();
useEffect(() => {
if (isCopied) {
addToast(intl.formatMessage(messages.copied), {
if (isCopied && toastMessage) {
addToast(toastMessage, {
appearance: 'info',
autoDismiss: true,
});
}
}, [isCopied, addToast, intl]);
}, [isCopied, addToast, toastMessage]);
return (
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
>
<ClipboardDocumentIcon />
</button>
<Tooltip content={tooltipContent} tooltipConfig={tooltipConfig}>
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
type="button"
disabled={disabled}
>
<ClipboardDocumentIcon />
</button>
</Tooltip>
);
};

View File

@@ -5,6 +5,7 @@ import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
@@ -83,24 +84,17 @@ const NotificationsDiscord = () => {
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/settings/notifications/discord', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
body: JSON.stringify({
enabled: values.enabled,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.discordsettingssaved), {
appearance: 'success',
@@ -139,27 +133,17 @@ const NotificationsDiscord = () => {
toastId = id;
}
);
const res = await fetch(
'/api/v1/settings/notifications/discord/test',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
}
);
if (!res.ok) throw new Error();
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
types: values.types,
options: {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
});
if (toastId) {
removeToast(toastId);

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