Compare commits
125 Commits
fix-local-
...
preview-ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da85ca0d1 | ||
|
|
d5e37e4f3f | ||
|
|
f5a50914c8 | ||
|
|
e30a3ea74f | ||
|
|
cc80bf2c56 | ||
|
|
2c52dbcea3 | ||
|
|
1168c77cad | ||
|
|
182aeaa636 | ||
|
|
a58811e675 | ||
|
|
37ba36f2df | ||
|
|
e50df22cbf | ||
|
|
878afb91df | ||
|
|
ffb20ebe93 | ||
|
|
cb7e2f073e | ||
|
|
588b1e36dd | ||
|
|
99ee19c714 | ||
|
|
fac81f75c9 | ||
|
|
ea43e3ba1e | ||
|
|
f3e180afb1 | ||
|
|
b5738b49d6 | ||
|
|
966a721c54 | ||
|
|
43f8260675 | ||
|
|
50700002e2 | ||
|
|
f7de2418e5 | ||
|
|
5c212ae2a8 | ||
|
|
6b248d97a7 | ||
|
|
5efa1d7a46 | ||
|
|
cb8cadae71 | ||
|
|
4d14a15fb6 | ||
|
|
06e465d052 | ||
|
|
5fb1c687fc | ||
|
|
65239a922f | ||
|
|
48d178c1e9 | ||
|
|
5d6e7f09a2 | ||
|
|
c680202008 | ||
|
|
63d8f550c4 | ||
|
|
9ab5fa5972 | ||
|
|
38ad875dd7 | ||
|
|
a9741fa36d | ||
|
|
b5a069901a | ||
|
|
9aeb3604e6 | ||
|
|
6eb88f8674 | ||
|
|
46ee8a4ca1 | ||
|
|
f52939e4cd | ||
|
|
d31a2c37e6 | ||
|
|
20863d4a8d | ||
|
|
4757f1c3e5 | ||
|
|
1f1ad72e9e | ||
|
|
c3ddc860b6 | ||
|
|
2bd125d9a5 | ||
|
|
7a5e8d69bf | ||
|
|
650c339d74 | ||
|
|
4ef5a3c7c5 | ||
|
|
a791b53953 | ||
|
|
68467ced9d | ||
|
|
296aee6338 | ||
|
|
0a4b38e50d | ||
|
|
bcc84d8551 | ||
|
|
783fda9621 | ||
|
|
d765055da8 | ||
|
|
fed66f0702 | ||
|
|
461202da75 | ||
|
|
0bbcfdc4f9 | ||
|
|
f486fb5e75 | ||
|
|
10082292e8 | ||
|
|
c0a0b9c8a8 | ||
|
|
d9d07c705a | ||
|
|
0eea1090df | ||
|
|
cd0fa3e223 | ||
|
|
9c68616343 | ||
|
|
0c2713213c | ||
|
|
3856061fe1 | ||
|
|
0900a95532 | ||
|
|
0c86684bc2 | ||
|
|
010df62776 | ||
|
|
530be4272c | ||
|
|
c2e87714b4 | ||
|
|
eee9a025d2 | ||
|
|
aed011a557 | ||
|
|
ea47dd3571 | ||
|
|
4c9013729e | ||
|
|
3eb1bb3d8f | ||
|
|
db84f6529a | ||
|
|
4f81788386 | ||
|
|
72d3f9b908 | ||
|
|
333ffed7f0 | ||
|
|
8641a26771 | ||
|
|
7329524868 | ||
|
|
908dcb487a | ||
|
|
d486d58d3d | ||
|
|
d8b08f4c6b | ||
|
|
a48a337e0f | ||
|
|
981f5e679c | ||
|
|
7af193b8f6 | ||
|
|
6040e16645 | ||
|
|
3877301fc8 | ||
|
|
092a1458a4 | ||
|
|
1c68111b12 | ||
|
|
0e777ddb1e | ||
|
|
52c689b080 | ||
|
|
1a11f085ba | ||
|
|
c0234582a6 | ||
|
|
fd958d6347 | ||
|
|
6586db52dc | ||
|
|
a41cb8b004 | ||
|
|
de66222e7a | ||
|
|
eb790cb466 | ||
|
|
0680931332 | ||
|
|
ff2821471e | ||
|
|
e032c02f5f | ||
|
|
f8c4def229 | ||
|
|
a0415e7b6b | ||
|
|
b5f672785a | ||
|
|
770d788fd7 | ||
|
|
c58261c841 | ||
|
|
ccfcdea1f6 | ||
|
|
8ec8f2ac57 | ||
|
|
91f97f96ab | ||
|
|
f4051a1e5d | ||
|
|
f564cddff4 | ||
|
|
cfcce6acf0 | ||
|
|
97396c2f57 | ||
|
|
0dfe050ba1 | ||
|
|
13dd3cad54 | ||
|
|
ce9802d5d4 |
@@ -277,6 +277,132 @@
|
|||||||
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "j0srisk",
|
||||||
|
"name": "Joseph Risk",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||||
|
"profile": "http://josephrisk.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Loetwiek",
|
||||||
|
"name": "Loetwiek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||||
|
"profile": "https://github.com/Loetwiek",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fuochi",
|
||||||
|
"name": "Fuochi",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||||
|
"profile": "https://github.com/Fuochi",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ module.exports = {
|
|||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
||||||
'plugin:jsx-a11y/recommended',
|
'plugin:jsx-a11y/recommended',
|
||||||
'plugin:react/recommended',
|
'plugin:@next/next/recommended',
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:react/jsx-runtime',
|
|
||||||
'prettier',
|
'prettier',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
|||||||
github: [Fallenbagel]
|
buy_me_a_coffee: fallen.bagel
|
||||||
|
|||||||
41
.github/workflows/ci.yml
vendored
@@ -13,20 +13,35 @@ jobs:
|
|||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
container: node:18.18-alpine
|
container: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
- name: Pnpm Setup
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
run: yarn
|
run: pnpm install
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: pnpm lint
|
||||||
- name: Formatting
|
- name: Formatting
|
||||||
run: yarn format:check
|
run: pnpm format:check
|
||||||
- name: Build
|
- name: Build
|
||||||
run: yarn build
|
run: pnpm build
|
||||||
|
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
@@ -34,18 +49,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,11 +71,11 @@ 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
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
2
.github/workflows/codeql.yml
vendored
@@ -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
@@ -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 }}'
|
||||||
18
.github/workflows/cypress.yml
vendored
@@ -13,12 +13,20 @@ 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: Set up Node.js
|
||||||
uses: cypress-io/github-action@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
build: yarn cypress:build
|
node-version: 20
|
||||||
start: yarn start
|
- name: Pnpm Setup
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- name: Cypress run
|
||||||
|
uses: cypress-io/github-action@v6
|
||||||
|
with:
|
||||||
|
build: pnpm cypress:build
|
||||||
|
start: pnpm start
|
||||||
wait-on: 'http://localhost:5055'
|
wait-on: 'http://localhost:5055'
|
||||||
record: true
|
record: true
|
||||||
env:
|
env:
|
||||||
|
|||||||
12
.github/workflows/preview.yml
vendored
@@ -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
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
136
.github/workflows/release.yml
vendored
@@ -10,24 +10,39 @@ 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: 20
|
||||||
- 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: Pnpm Setup
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: sh
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: pnpm install
|
||||||
- name: Release
|
- name: Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
@@ -35,60 +50,59 @@ 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
|
# steps:
|
||||||
steps:
|
# - name: Checkout Code
|
||||||
- name: Checkout Code
|
# uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v3
|
# 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@v3
|
||||||
uses: docker/setup-qemu-action@v1
|
# 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@v4
|
||||||
uses: actions/upload-artifact@v2
|
# 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
|
||||||
|
|||||||
21
.github/workflows/snap.yaml
vendored
@@ -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 }}
|
||||||
|
|
||||||
@@ -26,10 +30,9 @@ jobs:
|
|||||||
architecture:
|
architecture:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- 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 +43,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 +52,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 }}
|
||||||
|
|||||||
4
.github/workflows/support.yml
vendored
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
18
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18.18-alpine AS BUILD_IMAGE
|
FROM node:20-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,22 +10,24 @@ RUN \
|
|||||||
'linux/arm64' | 'linux/arm/v7') \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
apk update && \
|
apk update && \
|
||||||
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||||
yarn global add node-gyp \
|
npm install --global node-gyp \
|
||||||
;; \
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
Run npm install --global pnpm
|
||||||
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
ARG COMMIT_TAG
|
ARG COMMIT_TAG
|
||||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||||
|
|
||||||
RUN yarn build
|
RUN pnpm build
|
||||||
|
|
||||||
# remove development dependencies
|
# remove development dependencies
|
||||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
RUN pnpm prune --prod --ignore-scripts
|
||||||
|
|
||||||
RUN rm -rf src server .next/cache
|
RUN rm -rf src server .next/cache
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:18.18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Metadata for Github Package Registry
|
# Metadata for Github Package Registry
|
||||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
||||||
@@ -47,6 +49,6 @@ RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
|||||||
COPY --from=BUILD_IMAGE /app ./
|
COPY --from=BUILD_IMAGE /app ./
|
||||||
|
|
||||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|
||||||
EXPOSE 5055
|
EXPOSE 5055
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18.18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
57
README.md
@@ -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):
|
||||||
@@ -48,8 +53,8 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
|
|||||||
|
|
||||||
Pre-requisites:
|
Pre-requisites:
|
||||||
|
|
||||||
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
- Nodejs [v20](https://nodejs.org/en/download)
|
||||||
- [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 [v20](https://nodejs.org/en/download)
|
||||||
- [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>
|
||||||
@@ -358,6 +378,9 @@ 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://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
|
|
||||||
return {
|
|
||||||
presets: [
|
|
||||||
[
|
|
||||||
'next/babel',
|
|
||||||
{
|
|
||||||
'preset-env': {
|
|
||||||
useBuiltIns: 'entry',
|
|
||||||
corejs: '3',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
'react-intl-auto',
|
|
||||||
{
|
|
||||||
removePrefix: 'src/',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import './commands';
|
|||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
if (Cypress.env('SEED_DATABASE')) {
|
if (Cypress.env('SEED_DATABASE')) {
|
||||||
cy.exec('yarn cypress:prepare');
|
cy.exec('pnpm cypress:prepare');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ module.exports = {
|
|||||||
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['image.tmdb.org'],
|
remotePatterns: [
|
||||||
|
{ hostname: 'gravatar.com' },
|
||||||
|
{ hostname: 'image.tmdb.org' },
|
||||||
|
{ hostname: '*', protocol: 'https' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
46
package.json
@@ -3,26 +3,27 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"preinstall": "npx only-allow pnpm",
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "yarn build:next && yarn build:server",
|
"build": "pnpm build:next && pnpm build:server",
|
||||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||||
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
|
||||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||||
"format": "prettier --loglevel warn --write --cache .",
|
"format": "prettier --loglevel warn --write --cache .",
|
||||||
"format:check": "prettier --check --cache .",
|
"format:check": "prettier --check --cache .",
|
||||||
"typecheck": "yarn typecheck:server && yarn typecheck:client",
|
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
|
||||||
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
|
||||||
"typecheck:client": "tsc --noEmit",
|
"typecheck:client": "tsc --noEmit",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"cypress:open": "cypress open",
|
"cypress:open": "cypress open",
|
||||||
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||||
"cypress:build": "yarn build && yarn cypress:prepare"
|
"cypress:build": "pnpm build && pnpm cypress:prepare"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
"@formatjs/intl-utils": "3.8.4",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
|
"@formatjs/swc-plugin-experimental": "^0.4.0",
|
||||||
"@headlessui/react": "1.7.12",
|
"@headlessui/react": "1.7.12",
|
||||||
"@heroicons/react": "2.0.16",
|
"@heroicons/react": "2.0.16",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
@@ -44,6 +46,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",
|
||||||
@@ -58,11 +61,10 @@
|
|||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "2.2.9",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"intl": "1.2.5",
|
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "12.3.4",
|
"next": "^14.2.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
@@ -70,13 +72,13 @@
|
|||||||
"openpgp": "5.7.0",
|
"openpgp": "5.7.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
"react-aria": "3.23.0",
|
"react-aria": "3.23.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-intersection-observer": "9.4.3",
|
"react-intersection-observer": "9.4.3",
|
||||||
"react-intl": "6.2.10",
|
"react-intl": "^6.6.8",
|
||||||
"react-markdown": "8.0.5",
|
"react-markdown": "8.0.5",
|
||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-select": "5.7.0",
|
"react-select": "5.7.0",
|
||||||
@@ -88,9 +90,10 @@
|
|||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.3.8",
|
||||||
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.0.4",
|
"swr": "2.2.5",
|
||||||
"typeorm": "0.3.12",
|
"typeorm": "0.3.12",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
@@ -101,7 +104,6 @@
|
|||||||
"zod": "3.20.6"
|
"zod": "3.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "7.21.0",
|
|
||||||
"@commitlint/cli": "17.4.4",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "17.4.4",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@semantic-release/changelog": "6.0.2",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
@@ -122,8 +124,8 @@
|
|||||||
"@types/node": "17.0.36",
|
"@types/node": "17.0.36",
|
||||||
"@types/node-schedule": "2.1.0",
|
"@types/node-schedule": "2.1.0",
|
||||||
"@types/nodemailer": "6.4.7",
|
"@types/nodemailer": "6.4.7",
|
||||||
"@types/react": "18.0.28",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-transition-group": "4.4.5",
|
"@types/react-transition-group": "4.4.5",
|
||||||
"@types/secure-random-password": "0.2.1",
|
"@types/secure-random-password": "0.2.1",
|
||||||
"@types/semver": "7.3.13",
|
"@types/semver": "7.3.13",
|
||||||
@@ -135,15 +137,13 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/parser": "5.54.0",
|
"@typescript-eslint/parser": "5.54.0",
|
||||||
"autoprefixer": "10.4.13",
|
"autoprefixer": "10.4.13",
|
||||||
"babel-plugin-react-intl": "8.2.25",
|
|
||||||
"babel-plugin-react-intl-auto": "3.3.0",
|
|
||||||
"commitizen": "4.3.0",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "12.7.0",
|
"cypress": "12.7.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.35.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "12.3.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
"eslint-config-prettier": "8.6.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.9.0",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
@@ -151,7 +151,6 @@
|
|||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"extract-react-intl-messages": "4.1.1",
|
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "2.0.20",
|
||||||
@@ -167,10 +166,12 @@
|
|||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"engines": {
|
||||||
|
"node": "^20.0.0",
|
||||||
|
"pnpm": "^9.0.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/react": "18.0.28",
|
|
||||||
"@types/react-dom": "18.0.11",
|
|
||||||
"@types/express-session": "1.17.6"
|
"@types/express-session": "1.17.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -236,8 +237,7 @@
|
|||||||
],
|
],
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"linux/amd64",
|
"linux/amd64",
|
||||||
"linux/arm64",
|
"linux/arm64"
|
||||||
"linux/arm/v7"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@semantic-release/github"
|
"@semantic-release/github"
|
||||||
|
|||||||
26267
pnpm-lock.yaml
generated
Normal file
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 38 KiB |
BIN
public/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
BIN
public/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
BIN
public/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 33 KiB |
BIN
public/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 37 KiB |
BIN
public/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 821 B |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 137 KiB |
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
server/constants/error.ts
Normal 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',
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -121,7 +146,7 @@ app
|
|||||||
try {
|
try {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
|
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
|
||||||
if (descriptor?.writable === true) {
|
if (descriptor?.writable === true) {
|
||||||
req.ip = getClientIp(req) ?? '';
|
(req as any).ip = getClientIp(req) ?? '';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to attach the ip to the request', {
|
logger.error('Failed to attach the ip to the request', {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
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 {
|
||||||
|
jellyfinFullScanner,
|
||||||
|
jellyfinRecentScanner,
|
||||||
|
} from '@server/lib/scanners/jellyfin';
|
||||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||||
@@ -10,7 +15,6 @@ import watchlistSync from '@server/lib/watchlistsync';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import random from 'lodash/random';
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
id: JobId;
|
id: JobId;
|
||||||
@@ -73,38 +77,38 @@ export const startJobs = (): void => {
|
|||||||
// Run recently added jellyfin sync every 5 minutes
|
// Run recently added jellyfin sync every 5 minutes
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-recently-added-scan',
|
id: 'jellyfin-recently-added-scan',
|
||||||
name: 'Jellyfin Recently Added Sync',
|
name: 'Jellyfin Recently Added Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'minutes',
|
interval: 'minutes',
|
||||||
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-scan'].schedule,
|
jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
() => {
|
() => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
logger.info('Starting scheduled job: Jellyfin Recently Added Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
jobJellyfinRecentSync.run();
|
jellyfinRecentScanner.run();
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
running: () => jobJellyfinRecentSync.status().running,
|
running: () => jellyfinRecentScanner.status().running,
|
||||||
cancelFn: () => jobJellyfinRecentSync.cancel(),
|
cancelFn: () => jellyfinRecentScanner.cancel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run full jellyfin sync every 24 hours
|
// Run full jellyfin sync every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'jellyfin-full-scan',
|
id: 'jellyfin-full-scan',
|
||||||
name: 'Jellyfin Full Library Sync',
|
name: 'Jellyfin Full Library Scan',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'hours',
|
interval: 'hours',
|
||||||
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
logger.info('Starting scheduled job: Jellyfin Full Scan', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
jobJellyfinFullSync.run();
|
jellyfinFullScanner.run();
|
||||||
}),
|
}),
|
||||||
running: () => jobJellyfinFullSync.status().running,
|
running: () => jellyfinFullScanner.status().running,
|
||||||
cancelFn: () => jobJellyfinFullSync.cancel(),
|
cancelFn: () => jellyfinFullScanner.cancel(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,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',
|
||||||
@@ -179,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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
interface PushoverPayload {
|
interface PushoverImagePayload {
|
||||||
|
attachment_base64: string;
|
||||||
|
attachment_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushoverPayload extends PushoverImagePayload {
|
||||||
token: string;
|
token: string;
|
||||||
user: string;
|
user: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -43,10 +48,36 @@ class PushoverAgent
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNotificationPayload(
|
private async getImagePayload(
|
||||||
|
imageUrl: string
|
||||||
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(imageUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||||
|
const contentType = (
|
||||||
|
response.headers['Content-Type'] || response.headers['content-type']
|
||||||
|
)?.toString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachment_base64: base64,
|
||||||
|
attachment_type: contentType,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error getting image payload', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e.response?.data,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNotificationPayload(
|
||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Partial<PushoverPayload> {
|
): Promise<Partial<PushoverPayload>> {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
|
|
||||||
const title = payload.event ?? payload.subject;
|
const title = payload.event ?? payload.subject;
|
||||||
@@ -122,6 +153,16 @@ class PushoverAgent
|
|||||||
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
let attachment_base64;
|
||||||
|
let attachment_type;
|
||||||
|
if (payload.image) {
|
||||||
|
const imagePayload = await this.getImagePayload(payload.image);
|
||||||
|
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
||||||
|
attachment_base64 = imagePayload.attachment_base64;
|
||||||
|
attachment_type = imagePayload.attachment_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -129,6 +170,8 @@ class PushoverAgent
|
|||||||
url_title,
|
url_title,
|
||||||
priority,
|
priority,
|
||||||
html: 1,
|
html: 1,
|
||||||
|
attachment_base64,
|
||||||
|
attachment_type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +181,10 @@ class PushoverAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
const endpoint = 'https://api.pushover.net/1/messages.json';
|
const endpoint = 'https://api.pushover.net/1/messages.json';
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = await this.getNotificationPayload(
|
||||||
|
type,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
// Send system notification
|
// Send system notification
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ interface SyncStatus {
|
|||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class JobJellyfinSync {
|
class JellyfinScanner {
|
||||||
private sessionId: string;
|
private sessionId: string;
|
||||||
private tmdb: TheMovieDb;
|
private tmdb: TheMovieDb;
|
||||||
private jfClient: JellyfinAPI;
|
private jfClient: JellyfinAPI;
|
||||||
@@ -62,7 +63,7 @@ class JobJellyfinSync {
|
|||||||
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 JobJellyfinSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 JobJellyfinSync {
|
|||||||
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 JobJellyfinSync {
|
|||||||
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 JobJellyfinSync {
|
|||||||
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 JobJellyfinSync {
|
|||||||
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 JobJellyfinSync {
|
|||||||
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
|
||||||
);
|
);
|
||||||
@@ -675,7 +691,7 @@ class JobJellyfinSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jobJellyfinFullSync = new JobJellyfinSync();
|
export const jellyfinFullScanner = new JellyfinScanner();
|
||||||
export const jobJellyfinRecentSync = new JobJellyfinSync({
|
export const jellyfinRecentScanner = new JellyfinScanner({
|
||||||
isRecentOnly: true,
|
isRecentOnly: true,
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -129,8 +134,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 +277,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 +294,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 +334,12 @@ class Settings {
|
|||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
ip: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
},
|
},
|
||||||
@@ -534,6 +543,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 +553,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 +641,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;
|
||||||
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal 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;
|
||||||
21
server/lib/settings/migrator.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const collection = await tmdb.getCollection({
|
const collection = await tmdb.getCollection({
|
||||||
collectionId: Number(req.params.id),
|
collectionId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ discoverRoutes.get<{ language: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
originalLanguage: req.params.language,
|
originalLanguage: req.params.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const genre = genres.find(
|
const genre = genres.find(
|
||||||
@@ -224,7 +224,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
genre: req.params.genreId as string,
|
genre: req.params.genreId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ discoverRoutes.get<{ studioId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
studio: req.params.studioId as string,
|
studio: req.params.studioId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
primaryReleaseDateGte: date,
|
primaryReleaseDateGte: date,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +447,7 @@ discoverRoutes.get<{ language: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
originalLanguage: req.params.language,
|
originalLanguage: req.params.language,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +492,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const genre = genres.find(
|
const genre = genres.find(
|
||||||
@@ -505,7 +505,7 @@ discoverRoutes.get<{ genreId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
genre: req.params.genreId,
|
genre: req.params.genreId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@ discoverRoutes.get<{ networkId: string }>(
|
|||||||
|
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
network: Number(req.params.networkId),
|
network: Number(req.params.networkId),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
firstAirDateGte: date,
|
firstAirDateGte: date,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const data = await tmdb.getAllTrending({
|
const data = await tmdb.getAllTrending({
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -698,7 +698,7 @@ discoverRoutes.get<{ keywordId: string }>(
|
|||||||
const data = await tmdb.getMoviesByKeyword({
|
const data = await tmdb.getMoviesByKeyword({
|
||||||
keywordId: Number(req.params.keywordId),
|
keywordId: Number(req.params.keywordId),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -743,7 +743,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
const mappedGenres: GenreSliderItem[] = [];
|
const mappedGenres: GenreSliderItem[] = [];
|
||||||
|
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -787,7 +787,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
|||||||
const mappedGenres: GenreSliderItem[] = [];
|
const mappedGenres: GenreSliderItem[] = [];
|
||||||
|
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getMovieGenres({
|
const genres = await tmdb.getMovieGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(genres);
|
return res.status(200).json(genres);
|
||||||
@@ -258,7 +258,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const genres = await tmdb.getTvGenres({
|
const genres = await tmdb.getTvGenres({
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(genres);
|
return res.status(200).json(genres);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const tmdbMovie = await tmdb.getMovie({
|
const tmdbMovie = await tmdb.getMovie({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
||||||
@@ -43,7 +43,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
|
|||||||
const results = await tmdb.getMovieRecommendations({
|
const results = await tmdb.getMovieRecommendations({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -85,7 +85,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
|||||||
const results = await tmdb.getMovieSimilar({
|
const results = await tmdb.getMovieSimilar({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const person = await tmdb.getPerson({
|
const person = await tmdb.getPerson({
|
||||||
personId: Number(req.params.id),
|
personId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
return res.status(200).json(mapPersonDetails(person));
|
return res.status(200).json(mapPersonDetails(person));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -38,7 +38,7 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
||||||
personId: Number(req.params.id),
|
personId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const castMedia = await Media.getRelatedMedia(
|
const castMedia = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
.match(searchProvider.pattern) as RegExpMatchArray;
|
.match(searchProvider.pattern) as RegExpMatchArray;
|
||||||
results = await searchProvider.search({
|
results = await searchProvider.search({
|
||||||
id,
|
id,
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
query: queryString,
|
query: queryString,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -29,7 +29,7 @@ searchRoutes.get('/', async (req, res, next) => {
|
|||||||
results = await tmdb.searchMulti({
|
results = await tmdb.searchMulti({
|
||||||
query: queryString,
|
query: queryString,
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -12,23 +13,26 @@ import type {
|
|||||||
LogsResultsResponse,
|
LogsResultsResponse,
|
||||||
SettingsAboutResponse,
|
SettingsAboutResponse,
|
||||||
} from '@server/interfaces/api/settingsInterfaces';
|
} from '@server/interfaces/api/settingsInterfaces';
|
||||||
import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
|
|
||||||
import { scheduledJobs } from '@server/job/schedule';
|
import { scheduledJobs } from '@server/job/schedule';
|
||||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
|
||||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||||
import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
import type { JobId, Library, MainSettings } 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 { 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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -345,16 +408,16 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
|
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
|
||||||
return res.status(200).json(jobJellyfinFullSync.status());
|
return res.status(200).json(jellyfinFullScanner.status());
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin/sync', (req, res) => {
|
settingsRoutes.post('/jellyfin/sync', (req, res) => {
|
||||||
if (req.body.cancel) {
|
if (req.body.cancel) {
|
||||||
jobJellyfinFullSync.cancel();
|
jellyfinFullScanner.cancel();
|
||||||
} else if (req.body.start) {
|
} else if (req.body.start) {
|
||||||
jobJellyfinFullSync.run();
|
jellyfinFullScanner.run();
|
||||||
}
|
}
|
||||||
return res.status(200).json(jobJellyfinFullSync.status());
|
return res.status(200).json(jellyfinFullScanner.status());
|
||||||
});
|
});
|
||||||
settingsRoutes.get('/tautulli', (_req, res) => {
|
settingsRoutes.get('/tautulli', (_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const tv = await tmdb.getTvShow({
|
const tv = await tmdb.getTvShow({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||||
@@ -40,7 +40,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
|||||||
const season = await tmdb.getTvSeason({
|
const season = await tmdb.getTvSeason({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
seasonNumber: Number(req.params.seasonNumber),
|
seasonNumber: Number(req.params.seasonNumber),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(mapSeasonWithEpisodes(season));
|
return res.status(200).json(mapSeasonWithEpisodes(season));
|
||||||
@@ -65,7 +65,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
|
|||||||
const results = await tmdb.getTvRecommendations({
|
const results = await tmdb.getTvRecommendations({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -106,7 +106,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
|
|||||||
const results = await tmdb.getTvSimilar({
|
const results = await tmdb.getTvSimilar({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
page: Number(req.query.page),
|
page: Number(req.query.page),
|
||||||
language: req.locale ?? (req.query.language as string),
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||