Compare commits

...

80 Commits

Author SHA1 Message Date
gauthier-th
7ce5925426 feat: add custom DNS servers 2024-06-18 19:29:56 +02:00
Fallenbagel
38ad875dd7 refactor(jellyfin): abstract jellyfin hostname, updated ui to reflect it, better validation (#773)
* refactor(jellyfinsettings): abstract jellyfin hostname, updated ui to reflect it, better validation

This PR refactors and abstracts jellyfin hostname into, jellyfin ip, jellyfin port, jellyfin useSsl,
and jellyfin urlBase. This makes it more consistent with how plex settings are stored as well. In
addition, this improves validation as validation can be applied seperately to them instead of as one
whole regex doing the work to validate the url.
UI was updated to reflect this.

BREAKING CHANGE: Jellyfin settings now does not include a hostname. Instead it abstracted it to ip,
port, useSsl, and urlBase. However, migration of old settings to new settings should work
automatically.

* refactor: remove console logs and use getHostname and ApiErrorCodes

* fix: store req.body jellyfin settings temporarily and store only if valid

This should fix the issue where settings are saved even if the url
was invalid. Now the settings will only be saved if the url is
valid. Sort of like a test connection.

* refactor: clean up commented out code

* refactor(i18n): extract translation keys

* fix(auth): auth failing with jellyfin login is disabled

* fix(settings): jellyfin migrations replacing the rest of the settings

* fix(settings): jellyfin hostname should be carried out if hostname exists

* fix(settings): merging the wrong settings source

* refactor(settings): use migrator for dynamic settings migrations

* refactor(settingsmigrator): settings migration handler and the migrations

* test(cypress): fix cypress tests failing

cypress settings were lacking some of the jobs so when the startJobs() is called when the app
starts, it was failing to schedule the jobs where their cron timings were not specified in the
cypress settings. Therefore, this commit adds those jobs back. In addition, other setting options
were added to keep cypress settings consistent with a normal user.

* chore(prettierignore): ignore cypress/config/settings.cypress.json as it does not need prettier

* chore(prettier): ran formatter on cypress config to fix format check error

format check locally passes on this file. However, it fails during the github actions format check.
Therefore, json language features formatter was run instead of prettier to see if that fixes the
issue.

* test(cypress): add only missing jobs to the cypress settings

* ci: attempt at trying to get formatter to pass on cypress config json file

* refactor: revert the changes brought to try and fix formatter

added back the rest of the cypress settings and removed cypress settings from .prettierignore

* refactor(settings): better erorr logging when jellyfin connection test fails in settings page
2024-06-13 19:06:33 +02:00
Fallenbagel
a9741fa36d fix(auth): improve login resilience with headerless fallback authentication (#814)
adds fallback to authenticate without headers to ensure and improve resilience across different
browsers and client configurations.
2024-06-13 11:16:07 +02:00
Fallenbagel
b5a069901a fix: bypass cache-able lookups when resolving localhost (#813)
* fix: bypass cache-able lookups when resolving localhost

* fix: bypass cacheable-lookup when resolving localhost

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
2024-06-13 04:53:12 +05:00
Fallenbagel
9aeb3604e6 fix(auth): validation of ipv6/ipv4 (#812)
validation for ipv6 was sort of broken where for example `::1` was being sent as `1`, therefore,
logins were broken. This PR fixes it by using nodejs `net.isIPv4()` & `net.isIPv6` for ipv4 and ipv6
validation.

possibly related to and fixes #795
2024-06-12 18:50:00 +05:00
Fallenbagel
6eb88f8674 ci: temporarily disable snap release builds (#811) 2024-06-12 10:49:15 +05:00
Gauthier
46ee8a4ca1 fix(api): add DNS caching (#810)
fix #387 #657 #728
2024-06-12 02:56:10 +05:00
Gauthier
f52939e4cd fix: remove the settings button of media when useless (#809)
After the Media Availability Sync job rund on deleted media, the setting button is still visible
even if neither the media file nor the media request no longer exists. This PR hides this button
when it's no longer the case
2024-06-11 19:47:02 +05:00
Gauthier
d31a2c37e6 fix(jellyfinscanner): assign only 4k available badge for a 4k request instead of both badges (#805)
When you have a 4k server setup, and request a 4k item, when it becomes available it also sets the
normal item as available thus not allowing the user to request for the normal item
2024-06-11 17:58:48 +05:00
Gauthier
20863d4a8d fix: empty email in user settings (#807)
Email is mandatory for every user and required during the setup of Jellyseerr, but it is possible to
set it empty afterwards in the user settings. When the email is empty, users are not able to connect
to Jellyseer. This PR makes the email field mandatory in the user settings.

fix #803
2024-06-11 16:23:35 +05:00
Fallenbagel
4757f1c3e5 Revert "ci: update format check command to ignore .prettierignore files (#787)" (#788)
This reverts commit 1f1ad72e9e.
2024-06-01 06:10:07 +05:00
Fallenbagel
1f1ad72e9e ci: update format check command to ignore .prettierignore files (#787)
This is to try and fix formatting issues on #773 on a file
that should be ignored.
2024-06-01 05:52:14 +05:00
allcontributors[bot]
c3ddc860b6 docs: add ThowZzy as a contributor for code (#779)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-29 00:32:43 +05:00
ThowZzy
2bd125d9a5 fix(auth): case-sensitive logins not updating authtokens (#778) 2024-05-28 23:42:26 +05:00
Fallenbagel
7a5e8d69bf feat(settings): stores jellyfin/emby server name in the settings (#763)
Stores jellyfin/emby(?) server name in the settings file. This might come in handy in the future
once simultaneous multi-server sync is implemented.
2024-05-26 18:21:14 +05:00
Fallenbagel
650c339d74 fix(jellyfinapi): use external api class for jellyfin api requests (#762)
* refactor(jellyfinapi): use the external api class for jellyfin api requests

refactors jellyfin api requests to be handled by the external api
to be consistent with how other external api requests are made

related #728, related #387

* style: prettier formatted

* refactor(jellyfinapi): rename device in auth header as jellyseerr

* refactor(error): rename api error code generic to unknown

* refactor(errorcodes): consistent casing of error code enums
2024-05-25 15:44:36 +05:00
Fallenbagel
4ef5a3c7c5 style: ran prettier on snap yaml file (#774) 2024-05-25 06:10:19 +05:00
allcontributors[bot]
a791b53953 docs: add Bretterteig as a contributor for translation (#772)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:19:22 +05:00
allcontributors[bot]
68467ced9d docs: add JoaquinOlivero as a contributor for code (#771)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:18:18 +05:00
allcontributors[bot]
296aee6338 docs: add Kara-Zor-El as a contributor for infra (#770)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:17:05 +05:00
allcontributors[bot]
0a4b38e50d docs: add gauthier-th as a contributor for code (#766)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-05-24 23:13:16 +05:00
Fallenbagel
bcc84d8551 ci: turn off edge snap builds temporarily (#765)
turns off edge snap builds temporarily and makes it manual
2024-05-24 21:15:13 +05:00
Joaquin Olivero
783fda9621 feat: add Latin American Spanish translation (#725)
#677

Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
2024-05-24 21:07:53 +05:00
THOMAS B
d765055da8 feat(auth): send real information on login (#470)
* feat(auth): send real ip on login

* feat(auth): send application name on login
2024-05-24 18:05:05 +02:00
Fallenbagel
fed66f0702 chore: replace github sponsor with buymeacoffee (#764) 2024-05-24 18:47:52 +05:00
Julian Behr
461202da75 refactor: updated german translations (#732)
* Updated german translations

* Consistant sort titles

---------

Co-authored-by: Julian <git@muellerjulian.email>
2024-05-24 15:40:34 +02:00
Gauthier
0bbcfdc4f9 fix(api): save user email on the first try (#760)
* fix(api): save user email on the first try

fix #227

* fix(api): remove todo

* fix(logging): handle media server connection refused error/toast (#748)

* fix(logging): handle media server connection refused error/toast

Properly log as connection refused if the jellyfin/emby server is unreachable. Previously it used to
throw a credentials error which lead to a lot of confusion

* refactor(i8n): extract translation keys

* refactor(auth): error message for a more consistent format

* refactor(auth/errors): use custom error types and error codes instead of abusing error messages

* refactor(i8n): replace connection refused translation key with invalidurl

* fix(error): combine auth and api error class into a single one called network error

* fix(error): use the new network error and network error codes in auth/api

* refactor(error): rename NetworkError to ApiError

---------

Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-05-23 23:56:11 +05:00
Fallenbagel
f486fb5e75 fix(logging): handle media server connection refused error/toast (#748)
* fix(logging): handle media server connection refused error/toast

Properly log as connection refused if the jellyfin/emby server is unreachable. Previously it used to
throw a credentials error which lead to a lot of confusion

* refactor(i8n): extract translation keys

* refactor(auth): error message for a more consistent format

* refactor(auth/errors): use custom error types and error codes instead of abusing error messages

* refactor(i8n): replace connection refused translation key with invalidurl

* fix(error): combine auth and api error class into a single one called network error

* fix(error): use the new network error and network error codes in auth/api

* refactor(error): rename NetworkError to ApiError
2024-05-23 19:34:31 +05:00
allcontributors[bot]
10082292e8 docs: add joshuaboniface as a contributor for code (#734)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-04-29 12:21:18 +05:00
Joshua M. Boniface
c0a0b9c8a8 fix: use UTF8 encoding for webhook JSON (#714) 2024-04-29 12:19:01 +05:00
Gauvino
d9d07c705a feat: add merge conflict labeler workflow (#719)
remove dash on label
2024-04-20 02:08:33 +05:00
Kara
0eea1090df fix(api): small errors on overseerr-api.yaml (#721) 2024-04-20 00:21:12 +05:00
Fallenbagel
cd0fa3e223 Revert "fix: disable seasonfolder option in sonarr for jellyfin/Emby users" (#718)
This reverts commit 8ec8f2ac57.
Disabling seasonfolder is no longer needed as we now allow
virtualFolders from jellyfin api.
2024-04-17 16:07:02 +05:00
Gauvino
9c68616343 Update action (#717)
- Update action version to latest (remove all warnings)
 - Update nodejs version on action
 - Update ubuntu to latest on support action
2024-04-16 02:41:08 +05:00
Fallenbagel
0c2713213c feat: jellyseerr makeover (#715) 2024-04-16 00:22:12 +05:00
Fallenbagel
3856061fe1 fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections (#700)
* fix(jellyfinapi): refactors jellyfin library sync to support automatic grouping and collections

Previously, #450 added support for automatic library grouping. However, some users reported that
they were getting a 401 when using custom authentication such as LDAP. Therefore, that PR was
reverted (#524). This PR adds back the support for automatic library grouping for jellyfin
authentication users using the endpoint `/Library/MediaFolders` and fallsback to User views endpoint
if they're unable to sync the libraries (some, not all LDAP users had issues. Some reported that it
worked despite having custom authentication). Once it falls back to user views endpoint for syncing,
now it will detect if automatic grouping is enabled giving a warning that its not supported when
using some custom authentication methods. This PR also fixed collection syncing by expanding the
boxsets when syncing.

fix #256, fix #489, re #450, #524, fix #515, fix #474, fix #473

* refactor(i18n): adds the suffix "jellyfin" to jellyfin library sync message keys

* refactor(i18n): extract translation keys

* refactor: remove console logs

* refactor: remove more console logs

* refactor: apply review suggestions

* chore: fix prettier failing on .github file
2024-04-16 00:04:11 +05:00
Fallenbagel
0900a95532 fix: nullable type for jellyfinMediaId(4k) (#702)
The jellyfinMediaId(4k) properties were inferred as string | undefined, causing them to be set to
undefined when assigning null. This prevented the media from being saved correctly to the SQLite
database, as it doesn't accept undefined values. This resolves the availabilitySync job issue where
the "play on" button wasn't being removed for all media server types.

fix #668
2024-03-31 16:26:09 +05:00
Fallenbagel
0c86684bc2 refactor(i18n): change the user-facing identity of the application in i18n (#703) 2024-03-31 16:25:45 +05:00
Danish Humair
010df62776 feat: check if first jellyfin user is admin (#635)
* feat: merge check if first jellyfin user is admin

re #610

* refactor(i18n): extract admin error message into en locale

---------

Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-03-30 05:53:14 +05:00
Fallenbagel
530be4272c fix(jellyfinscanner): conditionally assign the jellyfinMediaId and jellyfinMediaId4k (#686)
Previously `jellyfinMediaId4k` was being assigned even if 4k server was not setup or even if 4k
content were not present. This fixes it by conditionally assigning the jellyfinMediaId and
JellyfinMediaId4k

fix #681
2024-03-14 03:11:53 +05:00
Fallenbagel
c2e87714b4 fix(embyauth): remove the accidentally added mediaServerType change code from another PR (#684)
Accidentally added the mediaServerType change code from another feature branch/PR during the auth
logic refactor that broke emby logins.
2024-03-14 01:08:09 +05:00
Gauvino
eee9a025d2 fix: typos on readme (#655)
* Fix typo

* Apply suggestions

* Apply suggestions

---------

Co-authored-by: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
2024-02-23 12:57:57 +05:00
allcontributors[bot]
aed011a557 docs: add trackmastersteve as a contributor for doc (#665)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-02-23 09:39:53 +05:00
Stephen Harris
ea47dd3571 Fixed a typo (#654)
Just a simple typo fix.
2024-02-23 09:38:45 +05:00
Fallenbagel
4c9013729e refactor: jellyfin authentication and add gravatar for missing avatars of jellyfin users (#664)
* refactor: jellyfin authentication

This refactor standardizes the authentication approach in Jellyfin to mirror the method employed in
Plex authentication for consistency

* feat: use gravatar for jellyfin users' with missing jellyfin avatars
2024-02-23 09:38:18 +05:00
Fallenbagel
3eb1bb3d8f feat(job): media availability support for jellyfin/emby (#522)
* feat(job): media availability support for jellyfin/emby

This refactors the media availability job to support jellyfin/emby for media removal automatically.
Needs further testing on 4k items (as I have not yet tested with 4k), however, non-4k items work as
intended.

fix #406, fix #193, fix #516, fix #362, fix #84

* fix(availabilitysync): use the correct 4k jellyfinMediaId

* fix: season mapping for plex

Fixes a bug introduced with this PR where media availability sync job removed the seasons from all
series even when those seasons existed
2024-02-23 07:42:59 +05:00
InvalidArgumentException
db84f6529a fix(jellyfin.ts): process virtual seasons if they have non virtual episodes (#639)
All seasons are processed now, but those without any episodes are filtered out again as unavailable.
This fixes in issue where jellyfin reports all seasons as virtual
2024-02-01 16:10:06 +05:00
Fallenbagel
4f81788386 Merge pull request #640 from Fallenbagel/all-contributors/add-Danish-H
docs: add Danish-H as a contributor for code
2024-01-29 00:54:02 +05:00
allcontributors[bot]
72d3f9b908 docs: update .all-contributorsrc [skip ci] 2024-01-28 19:53:51 +00:00
allcontributors[bot]
333ffed7f0 docs: update README.md [skip ci] 2024-01-28 19:53:50 +00:00
Fallenbagel
8641a26771 Merge pull request #636 from Danish-H/feature-letterboxd-links
feat: added Letterboxd links for the external link blocks for movies
2024-01-29 00:53:29 +05:00
Fallenbagel
7329524868 Merge pull request #638 from Fallenbagel/all-contributors/add-aleksasiriski
docs: add aleksasiriski as a contributor for infra
2024-01-28 01:10:56 +05:00
allcontributors[bot]
908dcb487a docs: update .all-contributorsrc [skip ci] 2024-01-27 20:10:44 +00:00
allcontributors[bot]
d486d58d3d docs: update README.md [skip ci] 2024-01-27 20:10:43 +00:00
Fallenbagel
d8b08f4c6b Merge pull request #637 from aleksasiriski/patch-1
ci(preview): added arm support for preview tags
2024-01-28 00:50:23 +05:00
Aleksa Siriški
a48a337e0f ci(preview): added arm support for preview tags 2024-01-27 16:58:35 +01:00
Danish Humair
981f5e679c feat: added Letterboxd links for the external link blocks for movies 2024-01-27 03:25:03 +05:00
Fallenbagel
7af193b8f6 docs: fix weblate link 2024-01-13 22:05:03 +05:00
Fallenbagel
6040e16645 update discord badge 2024-01-04 02:02:16 +05:00
Fallenbagel
3877301fc8 add translation percentage badge 2024-01-04 02:00:49 +05:00
Fallenbagel
092a1458a4 move weblate details to contributing.md 2024-01-03 14:25:23 +05:00
Fallenbagel
1c68111b12 update weblate link 2024-01-03 14:24:45 +05:00
Fallenbagel
0e777ddb1e Merge pull request #612 from Fallenbagel/feat-readme-weblate
Add more badges and weblate status
2024-01-03 14:20:29 +05:00
Fallenbagel
52c689b080 Merge pull request #613 from Fallenbagel/all-contributors/add-xeruf
docs: add xeruf as a contributor for doc
2024-01-03 14:12:32 +05:00
allcontributors[bot]
1a11f085ba docs: update .all-contributorsrc [skip ci] 2024-01-03 09:12:19 +00:00
allcontributors[bot]
c0234582a6 docs: update README.md [skip ci] 2024-01-03 09:12:18 +00:00
Fallenbagel
fd958d6347 Merge pull request #611 from xeruf/patch-1
Link related projects in README.md
2024-01-03 14:10:17 +05:00
Fallenbagel
6586db52dc Add more badges and weblate status 2024-01-03 14:04:17 +05:00
Janek
a41cb8b004 Link related projects in README.md 2024-01-03 07:39:48 +01:00
Fallenbagel
de66222e7a Merge pull request #590 from Fallenbagel/all-contributors/add-mdll23
docs: add mdll23 as a contributor for translation
2023-12-03 21:04:28 +05:00
allcontributors[bot]
eb790cb466 docs: update .all-contributorsrc [skip ci] 2023-12-03 16:03:26 +00:00
allcontributors[bot]
0680931332 docs: update README.md [skip ci] 2023-12-03 16:03:26 +00:00
Fallenbagel
ff2821471e Merge pull request #589 from mdll23/develop
fix: translation de.json
2023-12-03 21:02:59 +05:00
mdll23
e032c02f5f fix: fix german translation for "components.Discover.FilterSlideover.tmdbuservotecount" 2023-12-03 15:13:19 +01:00
Fallenbagel
f8c4def229 Merge pull request #565 from notquitenothing/custom-jellyfin-password-reset
feat: Custom jellyfin password reset setting
2023-11-30 14:08:20 +05:00
fallenbagel
a0415e7b6b Merge branch 'develop' into custom-jellyfin-password-reset 2023-11-30 09:26:14 +05:00
fallenbagel
b5f672785a docs: reverted two unrelated files to its develop branch state 2023-11-30 09:25:34 +05:00
Derek Paschal
0dfe050ba1 Fixing code formatting, prettier 2023-11-15 06:59:02 -06:00
Derek Paschal
13dd3cad54 Making the new setting optional 2023-11-14 08:51:29 -06:00
Derek Paschal
ce9802d5d4 Adding Jellyfin Setting for Custom "Forgot Password" URL
Adding Jellyfin Setting for Custom "Forgot Password" URL.  Useful in cases where you are using a custom authentication provider such as the LDAP plugin, Authelia, lldap, or any other external auth scheme with its own password reset page.
2023-11-14 08:20:28 -06:00
109 changed files with 4143 additions and 1205 deletions

View File

@@ -277,6 +277,105 @@
"contributions": [ "contributions": [
"doc" "doc"
] ]
},
{
"login": "mdll23",
"name": "Michael Dallinger",
"avatar_url": "https://avatars.githubusercontent.com/u/142844478?v=4",
"profile": "https://github.com/mdll23",
"contributions": [
"translation"
]
},
{
"login": "xeruf",
"name": "Janek",
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
"profile": "https://github.com/xeruf",
"contributions": [
"doc"
]
},
{
"login": "aleksasiriski",
"name": "Aleksa Siriški",
"avatar_url": "https://avatars.githubusercontent.com/u/31509435?v=4",
"profile": "https://aleksasiriski.dev",
"contributions": [
"infra"
]
},
{
"login": "Danish-H",
"name": "Danish Humair",
"avatar_url": "https://avatars.githubusercontent.com/u/121830048?v=4",
"profile": "http://danishhumair.com",
"contributions": [
"code"
]
},
{
"login": "trackmastersteve",
"name": "Stephen Harris",
"avatar_url": "https://avatars.githubusercontent.com/u/16858514?v=4",
"profile": "https://arm0.red",
"contributions": [
"doc"
]
},
{
"login": "joshuaboniface",
"name": "Joshua M. Boniface",
"avatar_url": "https://avatars.githubusercontent.com/u/4031396?v=4",
"profile": "https://www.boniface.me",
"contributions": [
"code"
]
},
{
"login": "gauthier-th",
"name": "Gauthier",
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code"
]
},
{
"login": "Kara-Zor-El",
"name": "Kara",
"avatar_url": "https://avatars.githubusercontent.com/u/69772087?v=4",
"profile": "https://github.com/Kara-Zor-El",
"contributions": [
"infra"
]
},
{
"login": "JoaquinOlivero",
"name": "Joaquin Olivero",
"avatar_url": "https://avatars.githubusercontent.com/u/66050823?v=4",
"profile": "https://joaquinolivero.com",
"contributions": [
"code"
]
},
{
"login": "Bretterteig",
"name": "Julian Behr",
"avatar_url": "https://avatars.githubusercontent.com/u/47298401?v=4",
"profile": "https://github.com/Bretterteig",
"contributions": [
"translation"
]
},
{
"login": "ThowZzy",
"name": "ThowZzy",
"avatar_url": "https://avatars.githubusercontent.com/u/61882536?v=4",
"profile": "https://github.com/ThowZzy",
"contributions": [
"code"
]
} }
] ]
} }

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [Fallenbagel] buy_me_a_coffee: fallen.bagel

View File

@@ -16,7 +16,7 @@ jobs:
container: node:18.18-alpine container: node:18.18-alpine
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
env: env:
HUSKY: 0 HUSKY: 0
@@ -34,18 +34,18 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -56,7 +56,7 @@ jobs:
env: env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2

26
.github/workflows/conflict_labeler.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Merge Conflict Labeler
on:
push:
branches:
- develop
pull_request_target:
branches:
- develop
types: [synchronize]
jobs:
label:
name: Labeling
runs-on: ubuntu-latest
if: ${{ github.repository == 'Fallenbagel/jellyseerr' }}
permissions:
contents: read
pull-requests: write
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@v3
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: '${{ secrets.GITHUB_TOKEN }}'

View File

@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v4 uses: cypress-io/github-action@v6
with: with:
build: yarn cypress:build build: yarn cypress:build
start: yarn start start: yarn start

View File

@@ -11,25 +11,25 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Get the version - name: Get the version
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
build-args: | build-args: |
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}

View File

@@ -10,19 +10,19 @@ jobs:
HUSKY: 0 HUSKY: 0
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 16 node-version: 18
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
@@ -35,60 +35,60 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release run: npx semantic-release
build-snap: # build-snap:
name: Build Snap Package (${{ matrix.architecture }}) # name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release # needs: semantic-release
runs-on: ubuntu-22.04 # runs-on: ubuntu-22.04
strategy: # strategy:
fail-fast: false # fail-fast: false
matrix: # matrix:
architecture: # architecture:
- amd64 # - amd64
- arm64 # - arm64
- armhf # - armhf
steps: # steps:
- name: Checkout Code # - name: Checkout Code
uses: actions/checkout@v3 # uses: actions/checkout@v4
with: # with:
fetch-depth: 0 # fetch-depth: 0
- name: Switch to main branch # - name: Switch to main branch
run: git checkout main # run: git checkout main
- name: Pull latest changes # - name: Pull latest changes
run: git pull # run: git pull
- name: Prepare # - name: Prepare
id: prepare # id: prepare
run: | # run: |
git fetch --prune --tags # git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then # if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo "RELEASE=stable" >> $GITHUB_OUTPUT # echo "RELEASE=stable" >> $GITHUB_OUTPUT
else # else
echo "RELEASE=edge" >> $GITHUB_OUTPUT # echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi # fi
- name: Set Up QEMU # - name: Set Up QEMU
uses: docker/setup-qemu-action@v1 # uses: docker/setup-qemu-action@v3
with: # with:
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde # image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
- name: Build Snap Package # - name: Build Snap Package
uses: diddlesnaps/snapcraft-multiarch-action@v1 # uses: diddlesnaps/snapcraft-multiarch-action@v1
id: build # id: build
with: # with:
architecture: ${{ matrix.architecture }} # architecture: ${{ matrix.architecture }}
- name: Upload Snap Package # - name: Upload Snap Package
uses: actions/upload-artifact@v2 # uses: actions/upload-artifact@v4
with: # with:
name: jellyseerr-snap-package-${{ matrix.architecture }} # name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }} # path: ${{ steps.build.outputs.snap }}
- name: Review Snap Package # - name: Review Snap Package
uses: diddlesnaps/snapcraft-review-tools-action@v1 # uses: diddlesnaps/snapcraft-review-tools-action@v1
with: # with:
snap: ${{ steps.build.outputs.snap }} # snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package # - name: Publish Snap Package
uses: snapcore/action-publish@v1 # uses: snapcore/action-publish@v1
env: # env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }} # SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with: # with:
snap: ${{ steps.build.outputs.snap }} # snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }} # release: ${{ steps.prepare.outputs.RELEASE }}
discord: discord:
name: Send Discord Notification name: Send Discord Notification

View File

@@ -1,9 +1,13 @@
name: Publish Snap name: Publish Snap
on: # turn off edge snap builds temporarily and make it manual
push:
branches: # on:
- develop # push:
# branches:
# - develop
on: workflow_dispatch
jobs: jobs:
jobs: jobs:
@@ -12,7 +16,7 @@ jobs:
if: "!contains(github.event.head_commit.message, '[skip ci]')" if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps: steps:
- name: Cancel Previous Runs - name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.10.0 uses: styfle/cancel-workflow-action@0.12.1
with: with:
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
@@ -29,7 +33,7 @@ jobs:
- armhf - armhf
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Prepare - name: Prepare
id: prepare id: prepare
run: | run: |
@@ -40,7 +44,7 @@ jobs:
echo "RELEASE=edge" >> $GITHUB_OUTPUT echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi fi
- name: Set Up QEMU - name: Set Up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Configure Git - name: Configure Git
run: git config --add safe.directory /data/parts/jellyseerr/src run: git config --add safe.directory /data/parts/jellyseerr/src
- name: Build Snap Package - name: Build Snap Package
@@ -49,7 +53,7 @@ jobs:
with: with:
architecture: ${{ matrix.architecture }} architecture: ${{ matrix.architecture }}
- name: Upload Snap Package - name: Upload Snap Package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: jellyseerr-snap-package-${{ matrix.architecture }} name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }} path: ${{ steps.build.outputs.snap }}

View File

@@ -6,9 +6,9 @@ on:
jobs: jobs:
support: support:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps: steps:
- uses: dessant/support-requests@v2 - uses: dessant/support-requests@v4
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
support-label: 'support' support-label: 'support'

View File

@@ -1,4 +1,4 @@
# Contributing to Overseerr # Contributing to Jellyseerr
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started... All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
@@ -17,7 +17,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device: 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/overseerr.git git clone https://github.com/YOUR_USERNAME/jellyseerr.git
cd overseerr/ cd overseerr/
``` ```
@@ -97,9 +97,9 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation ## Translation
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose). We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a> <a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
## Attribution ## Attribution

View File

@@ -2,23 +2,28 @@
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;"> <img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
</p> </p>
<p align="center"> <p align="center">
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a> <img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/release.yml/badge.svg" alt="Jellyseerr Release" />
<img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/ci.yml/badge.svg" alt="Jellyseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-29-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers! **Jellyseerr** is a free and open source software application for managing requests for your media library.
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
_The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_
## Current Features ## Current Features
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex! - Full Jellyfin/Emby/Plex integration including authentication with user import & management
- Supports Movies, Shows, Mixed Libraries! - Supports Movies, Shows and Mixed Libraries
- Ability to change email addresses for smtp purposes - Ability to change email addresses for smtp purposes
- Ability to import all jellyfin/emby users
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come! - Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available. - Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface. - Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
@@ -35,11 +40,11 @@ With more features on the way! Check out our [issue tracker](https://github.com/
#### Pre-requisite (Important) #### Pre-requisite (Important)
_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_ _*On Jellyfin/Emby, ensure the `Settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
### Launching Jellyseerr using Docker (Recommended) ### Launching Jellyseerr using Docker (Recommended)
Check out our dockerhub for instructions on how to install and run Jellyseerr: Check out our docker hub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr https://hub.docker.com/r/fallenbagel/jellyseerr
### Building from source (ADVANCED): ### Building from source (ADVANCED):
@@ -49,7 +54,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
Pre-requisites: Pre-requisites:
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2) - Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
- Download/git clone the source code from the github (Either develop branch or main for stable) - Download/git clone the source code from the github (Either develop branch or main for stable)
```cmd ```cmd
@@ -59,16 +64,17 @@ yarn install --frozen-lockfile --network-timeout 1000000
yarn run build yarn run build
yarn start yarn start
``` ```
(you can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background)
_to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_ (You can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background)
_To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
#### Linux #### Linux
**Pre-requisites:** **Pre-requisites:**
- Nodejs [v18](https://nodejs.org/en/download/package-manager) - Nodejs [v18](https://nodejs.org/en/download/package-manager)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
- Git - Git
**Steps:** **Steps:**
@@ -79,7 +85,7 @@ _to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i
cd /opt cd /opt
``` ```
2. Then clone the follow commands to clone and checkout to the stable version 2. Then execute the following commands to clone and checkout to the stable version
```bash ```bash
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
@@ -98,9 +104,9 @@ yarn run build
5. If you want to run jellyseerr as a _Systemd-service:_ 5. If you want to run jellyseerr as a _Systemd-service:_
- assuming jellyseerr was cloned to `/opt/` - assuming jellyseerr was cloned to `/opt/`
- first create the environmentfile at `/etc/jellyseerr/jellyseerr.conf` - first create the environment file at `/etc/jellyseerr/jellyseerr.conf`
Environmentfile: Environment file:
``` ```
# Jellyseerr's default port is 5055, if you want to use both, change this. # Jellyseerr's default port is 5055, if you want to use both, change this.
@@ -136,6 +142,7 @@ ExecStart=/usr/bin/node dist/index.js
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
### Packages: ### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr) Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
@@ -217,6 +224,19 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</tr> </tr>
<tr> <tr>
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td> <td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
</tr>
<tr>
<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="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -19,6 +19,7 @@
"region": "", "region": "",
"originalLanguage": "", "originalLanguage": "",
"trustProxy": false, "trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true, "partialRequestsEnabled": true,
"locale": "en" "locale": "en"
}, },
@@ -37,6 +38,17 @@
], ],
"machineId": "test" "machineId": "test"
}, },
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {}, "tautulli": {},
"radarr": [], "radarr": [],
"sonarr": [], "sonarr": [],
@@ -139,11 +151,26 @@
"sonarr-scan": { "sonarr-scan": {
"schedule": "0 30 4 * * *" "schedule": "0 30 4 * * *"
}, },
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": { "download-sync": {
"schedule": "0 * * * * *" "schedule": "0 * * * * *"
}, },
"download-sync-reset": { "download-sync-reset": {
"schedule": "0 0 1 * * *" "schedule": "0 0 1 * * *"
},
"jellyfin-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"jellyfin-full-scan": {
"schedule": "0 0 3 * * *"
},
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
} }
} }
} }

View File

@@ -368,6 +368,9 @@ components:
externalHostname: externalHostname:
type: string type: string
example: 'http://my.jellyfin.host' example: 'http://my.jellyfin.host'
jellyfinForgotPasswordUrl:
type: string
example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html'
adminUser: adminUser:
type: string type: string
example: 'admin' example: 'admin'
@@ -2089,6 +2092,13 @@ paths:
application/json: application/json:
schema: schema:
type: array type: array
items:
type: object
properties:
username:
type: string
userId:
type: integer
/settings/jellyfin/sync: /settings/jellyfin/sync:
get: get:
summary: Get status of full Jellyfin library sync summary: Get status of full Jellyfin library sync
@@ -3392,6 +3402,12 @@ paths:
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags: tags:
- settings - settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
requestBody: requestBody:
required: true required: true
content: content:
@@ -3721,7 +3737,7 @@ paths:
results: results:
type: array type: array
items: items:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
post: post:
summary: Create new user summary: Create new user
description: | description: |

View File

@@ -44,6 +44,7 @@
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"cacheable-lookup": "^7.0.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -1,13 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import availabilitySync from '@server/lib/availabilitySync';
import logger from '@server/logger'; import logger from '@server/logger';
import type { AxiosInstance } from 'axios'; import { ApiError } from '@server/types/error';
import axios from 'axios'; import { getAppVersion } from '@server/utils/appVersion';
export interface JellyfinUserResponse { export interface JellyfinUserResponse {
Name: string; Name: string;
ServerId: string; ServerId: string;
ServerName: string; ServerName: string;
Id: string; Id: string;
Configuration: {
GroupedFolders: string[];
};
Policy: {
IsAdministrator: boolean;
};
PrimaryImageTag?: string; PrimaryImageTag?: string;
} }
@@ -20,6 +29,13 @@ export interface JellyfinUserListResponse {
users: JellyfinUserResponse[]; users: JellyfinUserResponse[];
} }
interface JellyfinMediaFolder {
Name: string;
Id: string;
Type: string;
CollectionType: string;
}
export interface JellyfinLibrary { export interface JellyfinLibrary {
type: 'show' | 'movie'; type: 'show' | 'movie';
key: string; key: string;
@@ -76,48 +92,90 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string; DateCreated?: string;
} }
class JellyfinAPI { class JellyfinAPI extends ExternalAPI {
private authToken?: string; private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string; private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = jellyfinHost; let authHeaderVal: string;
this.authToken = authToken; if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
let authHeaderVal = '';
if (this.authToken) {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
} else { } else {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`; authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
} }
this.axios = axios.create({ super(
baseURL: this.jellyfinHost, jellyfinHost,
headers: { {},
'X-Emby-Authorization': authHeaderVal, {
'Content-Type': 'application/json', headers: {
Accept: 'application/json', 'X-Emby-Authorization': authHeaderVal,
}, 'Content-Type': 'application/json',
}); Accept: 'application/json',
},
}
);
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
} }
public async login( public async login(
Username?: string, Username?: string,
Password?: string Password?: string,
ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
try { const authenticate = async (useHeaders: boolean) => {
const account = await this.axios.post<JellyfinLoginResponse>( const headers =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName', '/Users/AuthenticateByName',
{ {
Username: Username, Username,
Pw: Password, Pw: Password,
} },
{ headers }
); );
return account.data; };
try {
return await authenticate(true);
} catch (e) { } catch (e) {
throw new Error('Unauthorized'); logger.debug(`Failed to authenticate with headers: ${e.message}`, {
label: 'Jellyfin API',
ip: ClientIP,
});
}
try {
return await authenticate(false);
} catch (e) {
const status = e.response?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
} }
} }
@@ -126,69 +184,106 @@ class JellyfinAPI {
return; return;
} }
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> { public async getServerName(): Promise<string> {
try { try {
const account = await this.axios.get<JellyfinUserResponse>( const serverResponse = await this.get<JellyfinUserResponse>(
"/System/Info/Public'}" '/System/Info/Public'
); );
return account.data.ServerName;
return serverResponse.ServerName;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, `Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('girl idk');
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
} }
} }
public async getUsers(): Promise<JellyfinUserListResponse> { public async getUsers(): Promise<JellyfinUserListResponse> {
try { try {
const account = await this.axios.get(`/Users`); const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
return { users: account.data };
return { users: userReponse };
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getUser(): Promise<JellyfinUserResponse> { public async getUser(): Promise<JellyfinUserResponse> {
try { try {
const account = await this.axios.get<JellyfinUserResponse>( const userReponse = await this.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}` `/Users/${this.userId ?? 'Me'}`
); );
return account.data; return userReponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getLibraries(): Promise<JellyfinLibrary[]> { public async getLibraries(): Promise<JellyfinLibrary[]> {
try { try {
// TODO: Try to fix automatic grouping without fucking up LDAP users const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`);
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
const account = await this.axios.get<any>( return this.mapLibraries(mediaFolderResponse.Items);
`/Users/${this.userId ?? 'Me'}/Views` } catch (mediaFoldersResponseError) {
); // fallback to user views to get libraries
// this only and maybe/depending on factors affects LDAP users
try {
const mediaFolderResponse = await this.get<any>(
`/Users/${this.userId ?? 'Me'}/Views`
);
const response: JellyfinLibrary[] = account.data.Items.filter( return this.mapLibraries(mediaFolderResponse.Items);
(Item: any) => { } catch (e) {
return ( logger.error(
Item.Type === 'CollectionFolder' && `Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
Item.CollectionType !== 'music' && { label: 'Jellyfin API' }
Item.CollectionType !== 'books' && );
Item.CollectionType !== 'musicvideos' &&
Item.CollectionType !== 'homevideos' return [];
); }
} }
).map((Item: any) => { }
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
const excludedTypes = [
'music',
'books',
'musicvideos',
'homevideos',
'boxsets',
];
return mediaFolders
.filter((Item: JellyfinMediaFolder) => {
return (
Item.Type === 'CollectionFolder' &&
!excludedTypes.includes(Item.CollectionType)
);
})
.map((Item: JellyfinMediaFolder) => {
return <JellyfinLibrary>{ return <JellyfinLibrary>{
key: Item.Id, key: Item.Id,
title: Item.Name, title: Item.Name,
@@ -196,24 +291,15 @@ class JellyfinAPI {
agent: 'jellyfin', agent: 'jellyfin',
}; };
}); });
return response;
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
}
} }
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}` `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
); );
return contents.data.Items.filter( return libraryItemsResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -221,55 +307,64 @@ class JellyfinAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}` `/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
); );
return contents.data; return itemResponse;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> { public async getItemData(
id: string
): Promise<JellyfinLibraryItemExtended | undefined> {
try { try {
const contents = await this.axios.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/${id}` `/Users/${this.userId}/Items/${id}`
); );
return contents.data; return itemResponse;
} catch (e) { } catch (e) {
if (availabilitySync.running) {
if (e.response && e.response.status === 500) {
return undefined;
}
}
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token'); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> { public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`); const seasonResponse = await this.get<any>(`/Shows/${seriesID}/Seasons`);
return contents.data.Items.filter( return seasonResponse.Items;
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -278,11 +373,11 @@ class JellyfinAPI {
seasonID: string seasonID: string
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const contents = await this.axios.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
); );
return contents.data.Items.filter( return episodeResponse.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -290,7 +385,8 @@ class JellyfinAPI {
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
} }

View File

@@ -0,0 +1,9 @@
export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN',
}

View File

@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { import {
AfterLoad, AfterLoad,
Column, Column,
@@ -151,11 +152,11 @@ class Media {
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null; public ratingKey4k?: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public jellyfinMediaId?: string; public jellyfinMediaId?: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: 'varchar' })
public jellyfinMediaId4k?: string; public jellyfinMediaId4k?: string | null;
public serviceUrl?: string; public serviceUrl?: string;
public serviceUrl4k?: string; public serviceUrl4k?: string;
@@ -211,15 +212,12 @@ class Media {
} else { } else {
const pageName = const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, hostname, externalHostname } = getSettings().jellyfin; const { serverId, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) { if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -23,19 +23,25 @@ import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import type CacheableLookupType from 'cacheable-lookup';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csurf from 'csurf'; import csurf from 'csurf';
import { lookup } from 'dns';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import express from 'express'; import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator'; import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import http from 'node:http';
import https from 'node:https';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -46,6 +52,25 @@ const handle = app.getRequestHandler();
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
.default as typeof CacheableLookupType;
const cacheable = new CacheableLookup();
const originalLookup = cacheable.lookup;
// if hostname is localhost use dns.lookup instead of cacheable-lookup
cacheable.lookup = (...args: any) => {
const [hostname] = args;
if (hostname === 'localhost') {
lookup(...(args as Parameters<typeof lookup>));
} else {
originalLookup(...(args as Parameters<typeof originalLookup>));
}
};
cacheable.install(http.globalAgent);
cacheable.install(https.globalAgent);
const dbConnection = await dataSource.initialize(); const dbConnection = await dataSource.initialize();
// Run migrations in production // Run migrations in production
@@ -59,6 +84,16 @@ app
const settings = getSettings().load(); const settings = getSettings().load();
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Overwrite DNS servers
if (settings.main.overwriteDnsServers) {
cacheable.servers = settings.main.overwriteDnsServers
.split(',')
.map((server) => server.trim());
logger.info('Using custom DNS servers', {
label: 'Settings',
});
}
// Migrate library types // Migrate library types
if ( if (
settings.plex.libraries.length > 1 && settings.plex.libraries.length > 1 &&

View File

@@ -24,6 +24,7 @@ export interface PublicSettingsResponse {
jellyfinHost?: string; jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
jellyfinForgotPasswordUrl?: string;
initialized: boolean; initialized: boolean;
applicationTitle: string; applicationTitle: string;
applicationUrl: string; applicationUrl: string;

View File

@@ -1,4 +1,5 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { import {
@@ -167,7 +168,7 @@ export const startJobs = (): void => {
}); });
// Checks if media is still available in plex/sonarr/radarr libs // Checks if media is still available in plex/sonarr/radarr libs
/* scheduledJobs.push({ scheduledJobs.push({
id: 'availability-sync', id: 'availability-sync',
name: 'Media Availability Sync', name: 'Media Availability Sync',
type: 'process', type: 'process',
@@ -182,7 +183,6 @@ export const startJobs = (): void => {
running: () => availabilitySync.running, running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(), cancelFn: () => availabilitySync.cancel(),
}); });
*/
// Run download sync every minute // Run download sync every minute
scheduledJobs.push({ scheduledJobs.push({

View File

@@ -1,9 +1,12 @@
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import type { PlexMetadata } from '@server/api/plexapi'; import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest'; import MediaRequest from '@server/entity/MediaRequest';
@@ -13,19 +16,26 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync { class AvailabilitySync {
public running = false; public running = false;
private plexClient: PlexAPI; private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]>; private plexSeasonsCache: Record<string, PlexMetadata[]>;
private jellyfinClient: JellyfinAPI;
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
private sonarrSeasonsCache: Record<string, SonarrSeason[]>; private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
private radarrServers: RadarrSettings[]; private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[]; private sonarrServers: SonarrSettings[];
async run() { async run() {
const settings = getSettings(); const settings = getSettings();
const mediaServerType = getSettings().main.mediaServerType;
this.running = true; this.running = true;
this.plexSeasonsCache = {}; this.plexSeasonsCache = {};
this.jellyfinSeasonsCache = {};
this.sonarrSeasonsCache = {}; this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
@@ -37,13 +47,53 @@ class AvailabilitySync {
const pageSize = 50; const pageSize = 50;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (admin) { // If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
let admin = null;
if (mediaServerType === MediaServerType.PLEX) {
admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
admin = await userRepository.findOne({
where: { id: 1 },
select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' },
});
}
if (mediaServerType === MediaServerType.PLEX) {
if (admin && admin.plexToken) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
} else {
logger.error('Plex admin is not configured.');
}
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
getHostname(),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);
this.jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
} else {
logger.error('Jellyfin admin is not configured.');
}
} else { } else {
logger.error('An admin is not configured.'); logger.error('An admin is not configured.');
} }
@@ -60,41 +110,84 @@ class AvailabilitySync {
let movieExists = false; let movieExists = false;
let movieExists4k = false; let movieExists4k = false;
const { existsInPlex } = await this.mediaExistsInPlex(media, false); // if (mediaServerType === MediaServerType.PLEX) {
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex( // await this.mediaExistsInPlex(media, false);
media, // } else if (
true // mediaServerType === MediaServerType.JELLYFIN ||
); // mediaServerType === MediaServerType.EMBY
// ) {
// await this.mediaExistsInJellyfin(media, false);
// }
const existsInRadarr = await this.mediaExistsInRadarr(media, false); const existsInRadarr = await this.mediaExistsInRadarr(media, false);
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
if (existsInPlex || existsInRadarr) { // plex
movieExists = true; if (mediaServerType === MediaServerType.PLEX) {
logger.info( const { existsInPlex } = await this.mediaExistsInPlex(media, false);
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, const { existsInPlex: existsInPlex4k } =
{ await this.mediaExistsInPlex(media, true);
label: 'AvailabilitySync',
} if (existsInPlex || existsInRadarr) {
); movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
if (existsInPlex4k || existsInRadarr4k) { //jellyfin
movieExists4k = true; if (
logger.info( mediaServerType === MediaServerType.JELLYFIN ||
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, mediaServerType === MediaServerType.EMBY
{ ) {
label: 'AvailabilitySync', const { existsInJellyfin } = await this.mediaExistsInJellyfin(
} media,
false
); );
const { existsInJellyfin: existsInJellyfin4k } =
await this.mediaExistsInJellyfin(media, true);
if (existsInJellyfin || existsInRadarr) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInJellyfin4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
if (!movieExists && media.status === MediaStatus.AVAILABLE) { if (!movieExists && media.status === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, false); await this.mediaUpdater(media, false, mediaServerType);
} }
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, true); await this.mediaUpdater(media, true, mediaServerType);
} }
} }
@@ -104,6 +197,8 @@ class AvailabilitySync {
let showExists = false; let showExists = false;
let showExists4k = false; let showExists4k = false;
//plex
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
await this.mediaExistsInPlex(media, false); await this.mediaExistsInPlex(media, false);
const { const {
@@ -111,6 +206,16 @@ class AvailabilitySync {
seasonsMap: plexSeasonsMap4k = new Map(), seasonsMap: plexSeasonsMap4k = new Map(),
} = await this.mediaExistsInPlex(media, true); } = await this.mediaExistsInPlex(media, true);
//jellyfin
const {
existsInJellyfin,
seasonsMap: jellyfinSeasonsMap = new Map(),
} = await this.mediaExistsInJellyfin(media, false);
const {
existsInJellyfin: existsInJellyfin4k,
seasonsMap: jellyfinSeasonsMap4k = new Map(),
} = await this.mediaExistsInJellyfin(media, true);
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
await this.mediaExistsInSonarr(media, false); await this.mediaExistsInSonarr(media, false);
const { const {
@@ -118,24 +223,60 @@ class AvailabilitySync {
seasonsMap: sonarrSeasonsMap4k, seasonsMap: sonarrSeasonsMap4k,
} = await this.mediaExistsInSonarr(media, true); } = await this.mediaExistsInSonarr(media, true);
if (existsInPlex || existsInSonarr) { //plex
showExists = true; if (mediaServerType === MediaServerType.PLEX) {
logger.info( if (existsInPlex || existsInSonarr) {
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, showExists = true;
{ logger.info(
label: 'AvailabilitySync', `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
} {
); label: 'AvailabilitySync',
}
);
}
} }
if (existsInPlex4k || existsInSonarr4k) { if (mediaServerType === MediaServerType.PLEX) {
showExists4k = true; if (existsInPlex4k || existsInSonarr4k) {
logger.info( showExists4k = true;
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, logger.info(
{ `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
label: 'AvailabilitySync', {
} label: 'AvailabilitySync',
); }
);
}
}
//jellyfin
if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (existsInJellyfin || existsInSonarr) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
}
if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (existsInJellyfin4k || existsInSonarr4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
// Here we will create a final map that will cross compare // Here we will create a final map that will cross compare
@@ -155,11 +296,45 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false) filteredSeasonsMap.set(season.seasonNumber, false)
); );
const finalSeasons = new Map([ // non-4k
...filteredSeasonsMap, const finalSeasons: Map<number, boolean> = new Map();
...plexSeasonsMap,
...sonarrSeasonsMap, if (mediaServerType === MediaServerType.PLEX) {
]); plexSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map(); const filteredSeasonsMap4k: Map<number, boolean> = new Map();
@@ -173,18 +348,64 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false) filteredSeasonsMap4k.set(season.seasonNumber, false)
); );
const finalSeasons4k = new Map([ // 4k
...filteredSeasonsMap4k, const finalSeasons4k: Map<number, boolean> = new Map();
...plexSeasonsMap4k,
...sonarrSeasonsMap4k, if (mediaServerType === MediaServerType.PLEX) {
]); plexSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
}
// TODO: Figure out how to run seasonUpdater for each season
if ([...finalSeasons.values()].includes(false)) { if ([...finalSeasons.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons, false); await this.seasonUpdater(
media,
finalSeasons,
false,
mediaServerType
);
} }
if ([...finalSeasons4k.values()].includes(false)) { if ([...finalSeasons4k.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons4k, true); await this.seasonUpdater(
media,
finalSeasons4k,
true,
mediaServerType
);
} }
if ( if (
@@ -192,7 +413,7 @@ class AvailabilitySync {
(media.status === MediaStatus.AVAILABLE || (media.status === MediaStatus.AVAILABLE ||
media.status === MediaStatus.PARTIALLY_AVAILABLE) media.status === MediaStatus.PARTIALLY_AVAILABLE)
) { ) {
await this.mediaUpdater(media, false); await this.mediaUpdater(media, false, mediaServerType);
} }
if ( if (
@@ -200,7 +421,7 @@ class AvailabilitySync {
(media.status4k === MediaStatus.AVAILABLE || (media.status4k === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE) media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
) { ) {
await this.mediaUpdater(media, true); await this.mediaUpdater(media, true, mediaServerType);
} }
} }
} }
@@ -272,7 +493,11 @@ class AvailabilitySync {
return mediaStatus; return mediaStatus;
} }
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> { private async mediaUpdater(
media: Media,
is4k: boolean,
mediaServerType: MediaServerType
): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
@@ -320,17 +545,32 @@ class AvailabilitySync {
mediaStatus === MediaStatus.PROCESSING mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null; : null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] = if (mediaServerType === MediaServerType.PLEX) {
mediaStatus === MediaStatus.PROCESSING media[is4k ? 'ratingKey4k' : 'ratingKey'] =
? media[is4k ? 'ratingKey4k' : 'ratingKey'] mediaStatus === MediaStatus.PROCESSING
: null; ? media[is4k ? 'ratingKey4k' : 'ratingKey']
: null;
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
: null;
}
logger.info( logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${ `The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show' media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${ } [TMDB ID ${media.tmdbId}] was not found in any ${
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
} and Plex instance. Status will be changed to unknown.`, } and ${
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
} instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
@@ -358,7 +598,8 @@ class AvailabilitySync {
private async seasonUpdater( private async seasonUpdater(
media: Media, media: Media,
seasons: Map<number, boolean>, seasons: Map<number, boolean>,
is4k: boolean is4k: boolean,
mediaServerType: MediaServerType
): Promise<void> { ): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const seasonRequestRepository = getRepository(SeasonRequest); const seasonRequestRepository = getRepository(SeasonRequest);
@@ -370,6 +611,8 @@ class AvailabilitySync {
); );
const seasonKeys = [...seasonsPendingRemoval.keys()]; const seasonKeys = [...seasonsPendingRemoval.keys()];
// let isSeasonRemoved = false;
try { try {
// Need to check and see if there are any related season // Need to check and see if there are any related season
// requests. If they are, we will need to delete them. // requests. If they are, we will need to delete them.
@@ -420,7 +663,13 @@ class AvailabilitySync {
media.tmdbId media.tmdbId
}] was not found in any ${ }] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to unknown.`, } and ${
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
} instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
} catch (ex) { } catch (ex) {
@@ -604,6 +853,7 @@ class AvailabilitySync {
return seasonExists; return seasonExists;
} }
// Plex
private async mediaExistsInPlex( private async mediaExistsInPlex(
media: Media, media: Media,
is4k: boolean is4k: boolean
@@ -719,6 +969,123 @@ class AvailabilitySync {
return seasonExistsInPlex; return seasonExistsInPlex;
} }
// Jellyfin
private async mediaExistsInJellyfin(
media: Media,
is4k: boolean
): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map<number, boolean> }> {
const ratingKey = media.jellyfinMediaId;
const ratingKey4k = media.jellyfinMediaId4k;
let existsInJellyfin = false;
let preventSeasonSearch = false;
// Check each jellyfin instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInJellyfin
try {
let jellyfinMedia: JellyfinLibraryItem | undefined;
if (ratingKey && !is4k) {
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey);
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
this.jellyfinSeasonsCache[ratingKey] =
await this.jellyfinClient?.getSeasons(ratingKey);
}
}
if (ratingKey4k && is4k) {
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k);
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
this.jellyfinSeasonsCache[ratingKey4k] =
await this.jellyfinClient?.getSeasons(ratingKey4k);
}
}
if (jellyfinMedia) {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
// Here we check each season in jellyfin for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
if (media.mediaType === 'tv') {
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInJellyfin(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInJellyfin, seasonsMap };
}
return { existsInJellyfin };
}
private async seasonExistsInJellyfin(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
const ratingKey = media.jellyfinMediaId;
const ratingKey4k = media.jellyfinMediaId4k;
let seasonExistsInJellyfin = false;
// Check each jellyfin instance to see if the season exists
let jellyfinSeasons: JellyfinLibraryItem[] | undefined;
if (ratingKey && !is4k) {
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey];
}
if (ratingKey4k && is4k) {
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k];
}
const seasonIsAvailable = jellyfinSeasons?.find(
(jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber
);
if (seasonIsAvailable) {
seasonExistsInJellyfin = true;
}
return seasonExistsInJellyfin;
}
} }
const availabilitySync = new AvailabilitySync(); const availabilitySync = new AvailabilitySync();

View File

@@ -141,7 +141,7 @@ class WebhookAgent
const payloadString = Buffer.from( const payloadString = Buffer.from(
this.getSettings().options.jsonPayload, this.getSettings().options.jsonPayload,
'base64' 'base64'
).toString('ascii'); ).toString('utf8');
const parsedJSON = JSON.parse(JSON.parse(payloadString)); const parsedJSON = JSON.parse(JSON.parse(payloadString));

View File

@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock'; import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto'; import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
@@ -62,7 +63,7 @@ class JellyfinScanner {
const metadata = await this.jfClient.getItemData(jellyfinitem.Id); const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media(); const newMedia = new Media();
if (!metadata.Id) { if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', { logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync', label: 'Plex Sync',
ratingKey: jellyfinitem.Id, ratingKey: jellyfinitem.Id,
@@ -83,13 +84,17 @@ class JellyfinScanner {
} }
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) > 2000; return (MediaStream.Width ?? 0) > 2000;
}); });
}); });
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => { const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) <= 2000; return (MediaStream.Width ?? 0) <= 2000;
}); });
}); });
@@ -168,9 +173,9 @@ class JellyfinScanner {
newMedia.jellyfinMediaId = newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k) hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id ? metadata.Id
: undefined; : null;
newMedia.jellyfinMediaId4k = newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : undefined; has4k && this.enable4kMovie ? metadata.Id : null;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`); this.log(`Saved ${metadata.Name}`);
} }
@@ -197,6 +202,14 @@ class JellyfinScanner {
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id; jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
const metadata = await this.jfClient.getItemData(Id); const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: jellyfinitem.Id,
});
return;
}
if (metadata.ProviderIds.Tvdb) { if (metadata.ProviderIds.Tvdb) {
tvShow = await this.tmdb.getShowByTvdbId({ tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb), tvdbId: Number(metadata.ProviderIds.Tvdb),
@@ -275,7 +288,7 @@ class JellyfinScanner {
episode.Id episode.Id
); );
ExtendedEpisodeData.MediaSources?.some((MediaSource) => { ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') { if (MediaStream.Type === 'Video') {
if ((MediaStream.Width ?? 0) >= 2000) { if ((MediaStream.Width ?? 0) >= 2000) {
@@ -453,8 +466,9 @@ class JellyfinScanner {
tmdbId: tvShow.id, tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id, tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''), mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: Id, jellyfinMediaId: isAllStandardSeasons ? Id : null,
jellyfinMediaId4k: Id, jellyfinMediaId4k:
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons status: isAllStandardSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: newSeasons.some( : newSeasons.some(
@@ -581,8 +595,10 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
} }
const hostname = getHostname();
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', hostname,
admin.jellyfinAuthToken, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );

View File

@@ -1,10 +1,11 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library { export interface Library {
id: string; id: string;
@@ -38,8 +39,12 @@ export interface PlexSettings {
export interface JellyfinSettings { export interface JellyfinSettings {
name: string; name: string;
hostname: string; ip: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string; externalHostname?: string;
jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
} }
@@ -113,6 +118,7 @@ export interface MainSettings {
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
locale: string; locale: string;
overwriteDnsServers?: string;
} }
interface PublicSettings { interface PublicSettings {
@@ -129,8 +135,8 @@ interface FullPublicSettings extends PublicSettings {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
mediaServerType: number; mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean; cacheImages: boolean;
@@ -272,7 +278,7 @@ export type JobId =
| 'image-cache-cleanup' | 'image-cache-cleanup'
| 'availability-sync'; | 'availability-sync';
interface AllSettings { export interface AllSettings {
clientId: string; clientId: string;
vapidPublic: string; vapidPublic: string;
vapidPrivate: string; vapidPrivate: string;
@@ -289,7 +295,7 @@ interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json` ? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../config/settings.json'); : path.join(__dirname, '../../../config/settings.json');
class Settings { class Settings {
private data: AllSettings; private data: AllSettings;
@@ -329,8 +335,12 @@ class Settings {
}, },
jellyfin: { jellyfin: {
name: '', name: '',
hostname: '', ip: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '', externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
}, },
@@ -534,6 +544,7 @@ class Settings {
applicationUrl: this.data.main.applicationUrl, applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable, hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin, localLogin: this.data.main.localLogin,
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
movie4kEnabled: this.data.radarr.some( movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault (radarr) => radarr.is4k && radarr.isDefault
), ),
@@ -543,8 +554,6 @@ class Settings {
region: this.data.main.region, region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType, mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic, vapidPublic: this.vapidPublic,
@@ -633,7 +642,11 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
this.data = merge(this.data, JSON.parse(data)); const parsedJson = JSON.parse(data);
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
this.save(); this.save();
} }
return this; return this;

View File

@@ -0,0 +1,30 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
...settings.jellyfin,
ip,
port: port || (useSsl ? 443 : 80),
useSsl,
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};
export default migrateHostname;

View File

@@ -0,0 +1,21 @@
import type { AllSettings } from '@server/lib/settings';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings;
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;
};

View File

@@ -1,5 +1,6 @@
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
@@ -9,8 +10,12 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -218,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string; username?: string;
password?: string; password?: string;
hostname?: string; hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string; email?: string;
}; };
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured //Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== '' settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) { } else if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' }); return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.hostname !== '' && body.hostname) { } else if (settings.jellyfin.ip !== '' && body.hostname) {
return res return res
.status(500) .status(500)
.json({ error: 'Jellyfin hostname already configured' }); .json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.hostname === '' && !body.hostname) { } else if (settings.jellyfin.ip === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' }); return res.status(500).json({ error: 'No hostname provided.' });
} }
try { try {
const hostname = const hostname =
settings.jellyfin.hostname !== '' settings.jellyfin.ip !== ''
? settings.jellyfin.hostname ? getHostname()
: body.hostname ?? ''; : getHostname({
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -257,41 +271,123 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64' 'base64'
); );
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
let jellyfinHost = const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/') const ip = req.ip;
? jellyfinHost.slice(0, -1) let clientIp;
: jellyfinHost;
if (ip) {
if (net.isIPv4(ip)) {
clientIp = ip;
} else if (net.isIPv6(ip)) {
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
}
}
const account = await jellyfinserver.login(
body.username,
body.password,
clientIp
);
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists // Next let's see if the user already exists
user = await userRepository.findOne({ user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
if (user) { if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permission
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
userType: UserType.JELLYFIN,
});
const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName;
settings.jellyfin.serverId = account.User.ServerId;
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save();
startJobs();
await userRepository.save(user);
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {
logger.info(
`Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
} user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
}`,
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// Let's check if their authtoken is up to date // Let's check if their authtoken is up to date
if (user.jellyfinAuthToken !== account.AccessToken) { if (user.jellyfinAuthToken !== account.AccessToken) {
user.jellyfinAuthToken = account.AccessToken; user.jellyfinAuthToken = account.AccessToken;
} }
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else { } else {
user.avatar = '/os_logo_square.png'; user.avatar = gravatarUrl(user.email, {
default: 'mm',
size: 200,
});
} }
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) { if (user.username === account.User.Name) {
user.username = ''; user.username = '';
} }
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
// if (process.env.JELLYFIN_TYPE === 'emby') {
// settings.main.mediaServerType = MediaServerType.EMBY;
// settings.save();
// }
await userRepository.save(user); await userRepository.save(user);
} else if (!settings.main.newPlexLogin) { } else if (!settings.main.newPlexLogin) {
logger.warn( logger.warn(
@@ -307,69 +403,38 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
status: 403, status: 403,
message: 'Access denied.', message: 'Access denied.',
}); });
} else { } else if (!user) {
// Here we check if it's the first user. If it is, we create the user with no check logger.info(
// and give them admin permissions 'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
const totalUsers = await userRepository.count(); {
if (totalUsers === 0) { label: 'API',
logger.info( ip: req.ip,
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user = new User({
email: body.email,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});
await userRepository.save(user);
//Update hostname in settings if it doesn't exist (initial configuration)
//Also set mediaservertype to JELLYFIN
if (settings.jellyfin.hostname === '') {
settings.main.mediaServerType = MediaServerType.JELLYFIN;
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.serverId = account.User.ServerId;
settings.save();
startJobs();
} }
);
if (!body.email) {
throw new Error('add_email');
} }
if (!user) { user = new User({
if (!body.email) { email: body.email,
throw new Error('add_email'); jellyfinUsername: account.User.Name,
} jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
user = new User({ jellyfinAuthToken: account.AccessToken,
email: body.email, permissions: settings.main.defaultPermissions,
jellyfinUsername: account.User.Name, avatar: account.User.PrimaryImageTag
jellyfinUserId: account.User.Id, ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
jellyfinDeviceId: deviceId, : gravatarUrl(body.email, { default: 'mm', size: 200 }),
jellyfinAuthToken: account.AccessToken, userType: UserType.JELLYFIN,
permissions: settings.main.defaultPermissions, });
avatar: account.User.PrimaryImageTag //initialize Jellyfin/Emby users with local login
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` const passedExplicitPassword = body.password && body.password.length > 0;
: '/os_logo_square.png', if (passedExplicitPassword) {
userType: UserType.JELLYFIN, await user.setPassword(body.password ?? '');
});
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword =
body.password && body.password.length > 0;
if (passedExplicitPassword) {
await user.setPassword(body.password ?? '');
}
await userRepository.save(user);
} }
await userRepository.save(user);
} }
// Set logged in session // Set logged in session
@@ -379,33 +444,68 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {}); return res.status(200).json(user?.filter() ?? {});
} catch (e) { } catch (e) {
if (e.message === 'Unauthorized') { switch (e.errorCode) {
logger.warn( case ApiErrorCode.InvalidUrl:
'Failed login attempt from user with incorrect Jellyfin credentials', logger.error(
{ `The provided ${
label: 'Auth', process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
account: { } is invalid or the server is not reachable.`,
ip: req.ip, {
email: body.username, label: 'Auth',
password: '__REDACTED__', error: e.errorCode,
}, status: e.statusCode,
} hostname: getHostname({
); useSsl: body.useSsl,
return next({ ip: body.hostname,
status: 401, port: body.port,
message: 'Unauthorized', urlBase: body.urlBase,
}); }),
} else if (e.message === 'add_email') { }
return next({ );
status: 406, return next({
message: 'CREDENTIAL_ERROR_ADD_EMAIL', status: e.statusCode,
}); message: e.errorCode,
} else { });
logger.error(e.message, { label: 'Auth' });
return next({ case ApiErrorCode.InvalidCredentials:
status: 500, logger.warn(
message: 'Something went wrong.', 'Failed login attempt from user with incorrect Jellyfin credentials',
}); {
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
password: '__REDACTED__',
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
case ApiErrorCode.NotAdmin:
logger.warn(
'Failed login attempt from user without admin permissions',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default:
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong.',
});
} }
} }
}); });

View File

@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
@@ -24,11 +25,14 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover'; import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume'; import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule'; import { rescheduleJob } from 'node-schedule';
import path from 'path'; import path from 'path';
@@ -251,16 +255,64 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin); res.status(200).json(settings.jellyfin);
}); });
settingsRoutes.post('/jellyfin', (req, res) => { settingsRoutes.post('/jellyfin', async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings(); const settings = getSettings();
settings.jellyfin = merge(settings.jellyfin, req.body); try {
settings.save(); const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
if (!result?.Id) {
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
}
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
status: e.statusCode,
errorMessage: ApiErrorCode.InvalidUrl,
});
return next({
status: e.statusCode,
message: ApiErrorCode.InvalidUrl,
});
} else {
logger.error('Something went wrong', {
label: 'API',
errorMessage: e.message,
});
return next({
status: e.statusCode ?? 500,
message: ApiErrorCode.Unknown,
});
}
}
return res.status(200).json(settings.jellyfin); return res.status(200).json(settings.jellyfin);
}); });
settingsRoutes.get('/jellyfin/library', async (req, res) => { settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
if (req.query.sync) { if (req.query.sync) {
@@ -271,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '', getHostname(),
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -280,6 +332,22 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
const libraries = await jellyfinClient.getLibraries(); const libraries = await jellyfinClient.getLibraries();
if (libraries.length === 0) {
// Check if no libraries are found due to the fallback to user views
// This only affects LDAP users
const account = await jellyfinClient.getUser();
// Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) {
return next({
status: 501,
message: ApiErrorCode.SyncErrorGroupedFolders,
});
}
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
}
const newLibraries: Library[] = libraries.map((library) => { const newLibraries: Library[] = libraries.map((library) => {
const existing = settings.jellyfin.libraries.find( const existing = settings.jellyfin.libraries.find(
(l) => l.id === library.key && l.name === library.title (l) => l.id === library.key && l.name === library.title
@@ -308,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const { externalHostname } = getSettings().jellyfin;
const { hostname, externalHostname } = getSettings().jellyfin; const jellyfinHost =
let jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : getHostname();
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -325,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -337,7 +400,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
id: user.Id, id: user.Id,
thumb: user.PrimaryImageTag thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: '/os_logo_square.png', : gravatarUrl(user.Name, { default: 'mm', size: 200 }),
email: user.Name, email: user.Name,
})); }));

View File

@@ -275,7 +275,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
...webhookSettings.options, ...webhookSettings.options,
jsonPayload: JSON.parse( jsonPayload: JSON.parse(
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString( Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
'ascii' 'utf8'
) )
), ),
}, },

View File

@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
@@ -496,7 +497,6 @@ router.post(
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -504,15 +504,14 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { hostname, externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
let jellyfinHost = const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();
@@ -537,7 +536,10 @@ router.post(
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: '/os_logo_square.png', : gravatarUrl(jellyfinUser?.Name ?? '', {
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });

View File

@@ -98,6 +98,7 @@ userSettingsRoutes.post<
} }
user.username = req.body.username; user.username = req.body.username;
user.email = req.body.email ?? user.email;
// Update quota values only if the user has the correct permissions // Update quota values only if the user has the correct permissions
if ( if (
@@ -127,20 +128,19 @@ userSettingsRoutes.post<
user.settings.originalLanguage = req.body.originalLanguage; user.settings.originalLanguage = req.body.originalLanguage;
user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies;
user.settings.watchlistSyncTv = req.body.watchlistSyncTv; user.settings.watchlistSyncTv = req.body.watchlistSyncTv;
user.email = req.body.email ?? user.email;
} }
await userRepository.save(user); const savedUser = await userRepository.save(user);
return res.status(200).json({ return res.status(200).json({
username: user.username, username: savedUser.username,
discordId: user.settings.discordId, discordId: savedUser.settings?.discordId,
locale: user.settings.locale, locale: savedUser.settings?.locale,
region: user.settings.region, region: savedUser.settings?.region,
originalLanguage: user.settings.originalLanguage, originalLanguage: savedUser.settings?.originalLanguage,
watchlistSyncMovies: user.settings.watchlistSyncMovies, watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies,
watchlistSyncTv: user.settings.watchlistSyncTv, watchlistSyncTv: savedUser.settings?.watchlistSyncTv,
email: user.email, email: savedUser.email,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

9
server/types/error.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { ApiErrorCode } from '@server/constants/error';
export class ApiError extends Error {
constructor(public statusCode: number, public errorCode: ApiErrorCode) {
super();
this.name = 'apiError';
}
}

View File

@@ -0,0 +1,18 @@
import { getSettings } from '@server/lib/settings';
interface HostnameParams {
useSsl?: boolean;
ip?: string;
port?: number;
urlBase?: string;
}
export const getHostname = (params?: HostnameParams): string => {
const settings = params ? params : getSettings().jellyfin;
const { useSsl, ip, port, urlBase } = settings;
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
return hostname;
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,6 +1,7 @@
import EmbyLogo from '@app/assets/services/emby.svg'; import EmbyLogo from '@app/assets/services/emby.svg';
import ImdbLogo from '@app/assets/services/imdb.svg'; import ImdbLogo from '@app/assets/services/imdb.svg';
import JellyfinLogo from '@app/assets/services/jellyfin.svg'; import JellyfinLogo from '@app/assets/services/jellyfin.svg';
import LetterboxdLogo from '@app/assets/services/letterboxd.svg';
import PlexLogo from '@app/assets/services/plex.svg'; import PlexLogo from '@app/assets/services/plex.svg';
import RTLogo from '@app/assets/services/rt.svg'; import RTLogo from '@app/assets/services/rt.svg';
import TmdbLogo from '@app/assets/services/tmdb.svg'; import TmdbLogo from '@app/assets/services/tmdb.svg';
@@ -103,6 +104,16 @@ const ExternalLinkBlock = ({
<TraktLogo /> <TraktLogo />
</a> </a>
)} )}
{tmdbId && mediaType === MediaType.MOVIE && (
<a
href={`https://letterboxd.com/tmdb/${tmdbId}`}
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<LetterboxdLogo />
</a>
)}
</div> </div>
); );
}; };

View File

@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
@@ -13,7 +14,10 @@ import * as Yup from 'yup';
const messages = defineMessages({ const messages = defineMessages({
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
host: '{mediaServerName} URL', hostname: '{mediaServerName} URL',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
email: 'Email', email: 'Email',
emailtooltip: emailtooltip:
'Address does not need to be associated with your {mediaServerName} instance.', 'Address does not need to be associated with your {mediaServerName} instance.',
@@ -23,8 +27,15 @@ const messages = defineMessages({
validationemailformat: 'Valid email required', validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required', validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required', validationpasswordrequired: 'Password required',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…', signingin: 'Signing in…',
signin: 'Sign In', signin: 'Sign In',
initialsigningin: 'Connecting…', initialsigningin: 'Connecting…',
@@ -48,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
if (initial) { if (initial) {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
host: Yup.string() hostname: Yup.string().required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
),
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
.matches( .matches(
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, /^(\/[^/].*[^/]$)/,
intl.formatMessage(messages.validationhostformat) intl.formatMessage(messages.validationUrlBaseLeadingSlash)
) )
.required( .matches(
intl.formatMessage(messages.validationhostrequired, { /^(.*[^/])$/,
mediaServerName: intl.formatMessage(messages.validationUrlBaseTrailingSlash)
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
), ),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
@@ -72,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
mediaServerName: mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}; };
return ( return (
<Formik <Formik
initialValues={{ initialValues={{
username: '', username: '',
password: '', password: '',
host: '', hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
email: '', email: '',
}} }}
validationSchema={LoginSchema} validationSchema={LoginSchema}
@@ -86,16 +108,31 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', { await axios.post('/api/v1/auth/jellyfin', {
username: values.username, username: values.username,
password: values.password, password: values.password,
hostname: values.host, hostname: values.hostname,
port: values.port,
useSsl: values.useSsl,
urlBase: values.urlBase,
email: values.email, email: values.email,
}); });
} catch (e) { } catch (e) {
let errorMessage = null;
switch (e.response?.data?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;
case ApiErrorCode.InvalidCredentials:
errorMessage = messages.credentialerror;
break;
case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror;
break;
default:
errorMessage = messages.loginerror;
break;
}
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(errorMessage, mediaServerFormatValues),
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{ {
autoDismiss: true, autoDismiss: true,
appearance: 'error', appearance: 'error',
@@ -106,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
} }
}} }}
> >
{({ errors, touched, isSubmitting, isValid }) => ( {({
errors,
touched,
values,
setFieldValue,
isSubmitting,
isValid,
}) => (
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label"> <div className="flex flex-col sm:flex-row sm:gap-4">
{intl.formatMessage(messages.host, mediaServerFormatValues)} <div className="w-full">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
className="rounded-r-only flex-1"
placeholder={intl.formatMessage(
messages.hostname,
mediaServerFormatValues
)}
/>
</div>
{errors.hostname && touched.hostname && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="flex-1">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
</label>
<div className="mt-1 sm:mt-0">
<Field
id="port"
name="port"
inputMode="numeric"
type="text"
className="short flex-1"
placeholder={intl.formatMessage(messages.port)}
/>
{errors.port && touched.port && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
</div>
<label htmlFor="useSsl" className="text-label mt-2">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="mt-1 mb-2 sm:col-span-2">
<div className="flex rounded-md shadow-sm">
<Field
id="useSsl"
name="useSsl"
type="checkbox"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<label htmlFor="urlBase" className="text-label mt-1">
{intl.formatMessage(messages.urlBase)}
</label> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="host"
name="host"
type="text" type="text"
placeholder={intl.formatMessage( inputMode="url"
messages.host, id="urlBase"
mediaServerFormatValues name="urlBase"
)} placeholder={intl.formatMessage(messages.urlBase)}
/> />
</div> </div>
{errors.host && touched.host && ( {errors.urlBase && touched.urlBase && (
<div className="error">{errors.host}</div> <div className="error">{errors.urlBase}</div>
)} )}
</div> </div>
<label <label
htmlFor="email" htmlFor="email"
className="text-label" className="text-label inline-flex gap-1 align-middle"
style={{ display: 'inline-flex' }}
> >
{intl.formatMessage(messages.email)} {intl.formatMessage(messages.email)}
<span className="label-tip"> <span className="label-tip">
@@ -147,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
</Tooltip> </Tooltip>
</span> </span>
</label> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
<Field <Field
id="email" id="email"
@@ -222,6 +327,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
const baseUrl = settings.currentSettings.jellyfinExternalHost const baseUrl = settings.currentSettings.jellyfinExternalHost
? settings.currentSettings.jellyfinExternalHost ? settings.currentSettings.jellyfinExternalHost
: settings.currentSettings.jellyfinHost; : settings.currentSettings.jellyfinHost;
const jellyfinForgotPasswordUrl =
settings.currentSettings.jellyfinForgotPasswordUrl;
return ( return (
<div> <div>
<Formik <Formik
@@ -298,11 +405,15 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<Button <Button
as="a" as="a"
buttonType="ghost" buttonType="ghost"
href={`${baseUrl}/web/index.html#!/${ href={
process.env.JELLYFIN_TYPE === 'emby' jellyfinForgotPasswordUrl
? 'startup/' ? `${jellyfinForgotPasswordUrl}`
: '' : `${baseUrl}/web/index.html#!/${
}forgotpassword.html`} process.env.JELLYFIN_TYPE === 'emby'
? 'startup/'
: ''
}forgotpassword.html`
}
> >
{intl.formatMessage(messages.forgotpassword)} {intl.formatMessage(messages.forgotpassword)}
</Button> </Button>

View File

@@ -434,33 +434,38 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && ( {hasPermission(Permission.MANAGE_REQUESTS) &&
<Tooltip content={intl.formatMessage(messages.managemovie)}> data.mediaInfo &&
<Button (data.mediaInfo.jellyfinMediaId ||
buttonType="ghost" data.mediaInfo.jellyfinMediaId4k ||
onClick={() => setShowManager(true)} data.mediaInfo.status !== MediaStatus.UNKNOWN ||
className="relative ml-2 first:ml-0" data.mediaInfo.status4k !== MediaStatus.UNKNOWN) && (
> <Tooltip content={intl.formatMessage(messages.managemovie)}>
<CogIcon className="!mr-0" /> <Button
{hasPermission( buttonType="ghost"
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], onClick={() => setShowManager(true)}
{ className="relative ml-2 first:ml-0"
type: 'or', >
} <CogIcon className="!mr-0" />
) && {hasPermission(
( [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
data.mediaInfo?.issues.filter( {
(issue) => issue.status === IssueStatus.OPEN type: 'or',
) ?? [] }
).length > 0 && ( ) &&
<> (
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" /> data.mediaInfo?.issues.filter(
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" /> (issue) => issue.status === IssueStatus.OPEN
</> ) ?? []
)} ).length > 0 && (
</Button> <>
</Tooltip> <div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
)} <div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
</Tooltip>
)}
</div> </div>
</div> </div>
<div className="media-overview"> <div className="media-overview">

View File

@@ -4,6 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import LibraryItem from '@app/components/Settings/LibraryItem'; import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import type { JellyfinSettings } from '@server/lib/settings'; import type { JellyfinSettings } from '@server/lib/settings';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
@@ -30,10 +31,19 @@ const messages = defineMessages({
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!', jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: '{mediaServerName} Settings', jellyfinSettings: '{mediaServerName} Settings',
jellyfinSettingsDescription: jellyfinSettingsDescription:
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL.', 'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
externalUrl: 'External URL', externalUrl: 'External URL',
internalUrl: 'Internal URL', hostname: 'Hostname or IP Address',
validationUrl: 'You must provide a valid URL', port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
jellyfinForgotPasswordUrl: 'Forgot Password URL',
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
jellyfinSyncFailedAutomaticGroupedFolders:
'Custom authentication with Automatic Library Grouping not supported',
jellyfinSyncFailedGenericError:
'Something went wrong while syncing libraries',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
syncing: 'Syncing', syncing: 'Syncing',
syncJellyfin: 'Sync Libraries', syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan', manualscanJellyfin: 'Manual Library Scan',
@@ -44,6 +54,12 @@ const messages = defineMessages({
librariesRemaining: 'Libraries Remaining: {count}', librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan', startscan: 'Start Scan',
cancelscan: 'Cancel Scan', cancelscan: 'Cancel Scan',
validationUrl: 'You must provide a valid URL',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
}); });
interface Library { interface Library {
@@ -59,6 +75,7 @@ interface SyncStatus {
currentLibrary?: Library; currentLibrary?: Library;
libraries: Library[]; libraries: Library[];
} }
interface SettingsJellyfinProps { interface SettingsJellyfinProps {
showAdvancedSettings?: boolean; showAdvancedSettings?: boolean;
onComplete?: () => void; onComplete?: () => void;
@@ -69,6 +86,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
showAdvancedSettings, showAdvancedSettings,
}) => { }) => {
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const toasts = useToasts();
const { const {
data, data,
@@ -86,14 +104,50 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
const { publicRuntimeConfig } = getConfig(); const { publicRuntimeConfig } = getConfig();
const JellyfinSettingsSchema = Yup.object().shape({ const JellyfinSettingsSchema = Yup.object().shape({
jellyfinExternalUrl: Yup.string().matches( hostname: Yup.string()
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, .nullable()
intl.formatMessage(messages.validationUrl) .required(intl.formatMessage(messages.validationHostnameRequired))
), .matches(
jellyfinInternalUrl: Yup.string().matches( /^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm, intl.formatMessage(messages.validationHostnameRequired)
intl.formatMessage(messages.validationUrl) ),
), port: Yup.number().when(['hostname'], {
is: (value: unknown) => !!value,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationPortRequired))
.nullable()
.required(intl.formatMessage(messages.validationPortRequired)),
otherwise: Yup.number()
.typeError(intl.formatMessage(messages.validationPortRequired))
.nullable(),
}),
urlBase: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
jellyfinExternalUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
jellyfinForgotPasswordUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
}); });
const activeLibraries = const activeLibraries =
@@ -112,11 +166,43 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
params.enable = activeLibraries.join(','); params.enable = activeLibraries.join(',');
} }
await axios.get('/api/v1/settings/jellyfin/library', { try {
params, await axios.get('/api/v1/settings/jellyfin/library', {
}); params,
setIsSyncing(false); });
revalidate(); setIsSyncing(false);
revalidate();
} catch (e) {
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
toasts.addToast(
intl.formatMessage(
messages.jellyfinSyncFailedAutomaticGroupedFolders
),
{
autoDismiss: true,
appearance: 'warning',
}
);
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
toasts.addToast(
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
toasts.addToast(
intl.formatMessage(messages.jellyfinSyncFailedGenericError),
{
autoDismiss: true,
appearance: 'error',
}
);
}
setIsSyncing(false);
revalidate();
}
}; };
const startScan = async () => { const startScan = async () => {
@@ -351,15 +437,23 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{
jellyfinInternalUrl: data?.hostname || '', hostname: data?.ip,
port: data?.port ?? 8096,
useSsl: data?.useSsl,
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '', jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
}} }}
validationSchema={JellyfinSettingsSchema} validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await axios.post('/api/v1/settings/jellyfin', { await axios.post('/api/v1/settings/jellyfin', {
hostname: values.jellyfinInternalUrl, ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl, externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
} as JellyfinSettings); } as JellyfinSettings);
addToast( addToast(
@@ -375,44 +469,127 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
} }
); );
} catch (e) { } catch (e) {
addToast( if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
intl.formatMessage(messages.jellyfinSettingsFailure, { addToast(
mediaServerName: intl.formatMessage(messages.invalidurlerror, {
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' mediaServerName:
? 'Emby' publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
: 'Jellyfin', ? 'Emby'
}), : 'Jellyfin',
{ }),
autoDismiss: true, {
appearance: 'error', autoDismiss: true,
} appearance: 'error',
); }
);
} else {
addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally { } finally {
revalidate(); revalidate();
} }
}} }}
> >
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => { {({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return ( return (
<form className="section" onSubmit={handleSubmit}> <form className="section" onSubmit={handleSubmit}>
<div className="form-row"> <div className="form-row">
<label htmlFor="jellyfinInternalUrl" className="text-label"> <label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.internalUrl)} {intl.formatMessage(messages.hostname)}
<span className="text-red-500">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
type="text"
inputMode="url"
id="hostname"
name="hostname"
className="rounded-r-only"
/>
</div>
{errors.hostname &&
touched.hostname &&
typeof errors.hostname === 'string' && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field
type="text"
inputMode="numeric"
id="port"
name="port"
className="short"
/>
{errors.port &&
touched.port &&
typeof errors.port === 'string' && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="useSsl" className="checkbox-label">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="useSsl"
name="useSsl"
onChange={() => {
setFieldValue('useSsl', !values.useSsl);
setFieldValue('port', values.useSsl ? 8096 : 443);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="urlBase" className="text-label">
{intl.formatMessage(messages.urlBase)}
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field">
<Field <Field
type="text" type="text"
inputMode="url" inputMode="url"
id="jellyfinInternalUrl" id="urlBase"
name="jellyfinInternalUrl" name="urlBase"
/> />
</div> </div>
{errors.jellyfinInternalUrl && {errors.urlBase &&
touched.jellyfinInternalUrl && ( touched.urlBase &&
<div className="error"> typeof errors.urlBase === 'string' && (
{errors.jellyfinInternalUrl} <div className="error">{errors.urlBase}</div>
</div>
)} )}
</div> </div>
</div> </div>
@@ -437,6 +614,30 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="form-row">
<label
htmlFor="jellyfinForgotPasswordUrl"
className="text-label"
>
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinForgotPasswordUrl"
name="jellyfinForgotPasswordUrl"
/>
</div>
{errors.jellyfinForgotPasswordUrl &&
touched.jellyfinForgotPasswordUrl && (
<div className="error">
{errors.jellyfinForgotPasswordUrl}
</div>
)}
</div>
</div>
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -55,6 +55,9 @@ const messages = defineMessages({
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests', partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language', locale: 'Display Language',
overwriteDnsServers: 'Overwrite Default DNS Servers',
overwriteDnsServersTip:
'A comma-separated list of DNS server to use instead of the default ones',
}); });
const SettingsMain = () => { const SettingsMain = () => {
@@ -134,6 +137,7 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled, partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy, trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages, cacheImages: data?.cacheImages,
overwriteDnsServers: data?.overwriteDnsServers,
}} }}
enableReinitialize enableReinitialize
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
@@ -150,6 +154,7 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled, partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
cacheImages: values.cacheImages, cacheImages: values.cacheImages,
overwriteDnsServers: values.overwriteDnsServers,
}); });
mutate('/api/v1/settings/public'); mutate('/api/v1/settings/public');
mutate('/api/v1/status'); mutate('/api/v1/status');
@@ -427,6 +432,37 @@ const SettingsMain = () => {
/> />
</div> </div>
</div> </div>
<div className="form-row">
<label
htmlFor="overwriteDnsServers"
className="checkbox-label"
>
<span className="mr-2">
{intl.formatMessage(messages.overwriteDnsServers)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.overwriteDnsServersTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="overwriteDnsServers"
name="overwriteDnsServers"
type="text"
/>
</div>
{errors.overwriteDnsServers &&
touched.overwriteDnsServers &&
typeof errors.overwriteDnsServers === 'string' && (
<div className="error">
{errors.overwriteDnsServers}
</div>
)}
</div>
</div>
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="ml-3 inline-flex rounded-md shadow-sm">

View File

@@ -1,10 +1,8 @@
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput'; import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { MediaServerType } from '@server/constants/server'; import type { SonarrSettings } from '@server/lib/settings';
import { type SonarrSettings } from '@server/lib/settings';
import axios from 'axios'; import axios from 'axios';
import { Field, Formik } from 'formik'; import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
@@ -111,7 +109,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const { addToast } = useToasts(); const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false); const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const settings = useSettings();
const [testResponse, setTestResponse] = useState<TestResponse>({ const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [], profiles: [],
rootFolders: [], rootFolders: [],
@@ -258,9 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
animeTags: sonarr?.animeTags ?? [], animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false, isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false, is4k: sonarr?.is4k ?? false,
enableSeasonFolders: enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
sonarr?.enableSeasonFolders ??
settings.currentSettings.mediaServerType !== MediaServerType.PLEX,
externalUrl: sonarr?.externalUrl, externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false, syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch, enableSearch: !sonarr?.preventSearch,
@@ -966,24 +961,11 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
> >
{intl.formatMessage(messages.seasonfolders)} {intl.formatMessage(messages.seasonfolders)}
</label> </label>
<div <div className="form-input-area">
className={`form-input-area ${
settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN ||
settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? 'opacity-50'
: 'opacity-100'
}`}
>
<Field <Field
type="checkbox" type="checkbox"
id="enableSeasonFolders" id="enableSeasonFolders"
name="enableSeasonFolders" name="enableSeasonFolders"
disabled={
settings.currentSettings.mediaServerType !==
MediaServerType.PLEX
}
/> />
</div> </div>
</div> </div>

View File

@@ -53,6 +53,8 @@ const messages = defineMessages({
discordId: 'Discord User ID', discordId: 'Discord User ID',
discordIdTip: discordIdTip:
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account', 'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
validationemailrequired: 'Email required',
validationemailformat: 'Valid email required',
validationDiscordId: 'You must provide a valid Discord user ID', validationDiscordId: 'You must provide a valid Discord user ID',
plexwatchlistsyncmovies: 'Auto-Request Movies', plexwatchlistsyncmovies: 'Auto-Request Movies',
plexwatchlistsyncmoviestip: plexwatchlistsyncmoviestip:
@@ -88,6 +90,9 @@ const UserGeneralSettings = () => {
); );
const UserGeneralSettingsSchema = Yup.object().shape({ const UserGeneralSettingsSchema = Yup.object().shape({
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
discordId: Yup.string() discordId: Yup.string()
.nullable() .nullable()
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)), .matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),

View File

@@ -9,6 +9,7 @@ export type AvailableLocale =
| 'en' | 'en'
| 'el' | 'el'
| 'es' | 'es'
| 'es-MX'
| 'fr' | 'fr'
| 'hr' | 'hr'
| 'hu' | 'hu'
@@ -59,6 +60,10 @@ export const availableLanguages: AvailableLanguageObject = {
code: 'es', code: 'es',
display: 'Español', display: 'Español',
}, },
'es-MX': {
code: 'es-MX',
display: 'Español (Latinoamérica)',
},
fr: { fr: {
code: 'fr', code: 'fr',
display: 'Français', display: 'Français',

View File

@@ -155,7 +155,7 @@
"components.TvDetails.overview": "Přehled", "components.TvDetails.overview": "Přehled",
"components.TvDetails.cast": "Obsazení", "components.TvDetails.cast": "Obsazení",
"components.TvDetails.anime": "Anime", "components.TvDetails.anime": "Anime",
"components.StatusChacker.reloadJellyseerr": "Znovu načíst", "components.StatusChacker.reloadOverseerr": "Znovu načíst",
"components.Setup.tip": "Tip", "components.Setup.tip": "Tip",
"components.Setup.setup": "Konfigurace", "components.Setup.setup": "Konfigurace",
"components.Setup.finishing": "Dokončování…", "components.Setup.finishing": "Dokončování…",

View File

@@ -724,7 +724,7 @@
"components.StatusBadge.status4k": "4K {status}", "components.StatusBadge.status4k": "4K {status}",
"components.Setup.tip": "Tip", "components.Setup.tip": "Tip",
"components.Setup.welcome": "Velkommen til Jellyseerr", "components.Setup.welcome": "Velkommen til Jellyseerr",
"components.StatusChacker.reloadJellyseerr": "Genindlæs", "components.StatusChacker.reloadOverseerr": "Genindlæs",
"components.TvDetails.anime": "Anime", "components.TvDetails.anime": "Anime",
"components.TvDetails.cast": "Roller", "components.TvDetails.cast": "Roller",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutter", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minutter",

File diff suppressed because it is too large Load Diff

View File

@@ -634,7 +634,7 @@
"components.TvDetails.anime": "Anime", "components.TvDetails.anime": "Anime",
"components.TvDetails.TvCrew.fullseriescrew": "Όλο το Πλήρωμα της Σειράς", "components.TvDetails.TvCrew.fullseriescrew": "Όλο το Πλήρωμα της Σειράς",
"components.TvDetails.TvCast.fullseriescast": "Όλοι οι Ηθοποιοί της Σειράς", "components.TvDetails.TvCast.fullseriescast": "Όλοι οι Ηθοποιοί της Σειράς",
"components.StatusChacker.reloadJellyseerr": "Επαναφόρτωση", "components.StatusChacker.reloadOverseerr": "Επαναφόρτωση",
"components.StatusChacker.newversionavailable": "Ενημέρωση εφαρμογής", "components.StatusChacker.newversionavailable": "Ενημέρωση εφαρμογής",
"components.StatusChacker.newversionDescription": "Το Jellyseerr έχει ενημερωθεί! Κάνε κλικ στο παρακάτω κουμπί για να φορτώσει ξανά η σελίδα.", "components.StatusChacker.newversionDescription": "Το Jellyseerr έχει ενημερωθεί! Κάνε κλικ στο παρακάτω κουμπί για να φορτώσει ξανά η σελίδα.",
"components.StatusBadge.status4k": "4K {status}", "components.StatusBadge.status4k": "4K {status}",

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