Compare commits
285 Commits
preview-pr
...
refactor-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5421a1003 | ||
|
|
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 | ||
|
|
b85d7f37b9 | ||
|
|
97396c2f57 | ||
|
|
a0ec992028 | ||
|
|
0dfe050ba1 | ||
|
|
13dd3cad54 | ||
|
|
ce9802d5d4 | ||
|
|
4005397f3d | ||
|
|
a67e4dbb80 | ||
|
|
cf5cf3f9ca | ||
|
|
8ae4391f37 | ||
|
|
bfd77e271a | ||
|
|
d90fc2de1c | ||
|
|
3b67d6b0e8 | ||
|
|
38348accb0 | ||
|
|
be335c39be | ||
|
|
c25c5cae38 | ||
|
|
2e059cefc0 | ||
|
|
e540b58f73 | ||
|
|
22b548bad2 | ||
|
|
c4adbdb3a8 | ||
|
|
e5d565b435 | ||
|
|
5c531011be | ||
|
|
f2b1fd24c2 | ||
|
|
4be95fade4 | ||
|
|
d02f5b0090 | ||
|
|
d5f2034e69 | ||
|
|
9059f15291 | ||
|
|
b168d04fe6 | ||
|
|
9a51c5b47b | ||
|
|
ab8efc91d5 | ||
|
|
c115f813e5 | ||
|
|
8967bb9f90 | ||
|
|
b316b9984d | ||
|
|
605a1de98f | ||
|
|
74d84a1cad | ||
|
|
8a7f39994f | ||
|
|
6e47834de0 | ||
|
|
14aafbe1d6 | ||
|
|
445604a615 | ||
|
|
fa28f05263 | ||
|
|
fd5338167a | ||
|
|
81b5e8afbd | ||
|
|
4fe4e377a6 | ||
|
|
87a59651b2 | ||
|
|
3a680c47b6 | ||
|
|
44444402a9 | ||
|
|
9140b8d98c | ||
|
|
2e20fbae1b | ||
|
|
6c0d75759f | ||
|
|
f483062d0e | ||
|
|
a7cf87f266 | ||
|
|
8ef7ec40ae | ||
|
|
3b74002f25 | ||
|
|
2b1427108c | ||
|
|
68b2388205 | ||
|
|
b20c334941 | ||
|
|
9f2ee0beeb | ||
|
|
24a3ee1e77 | ||
|
|
510a564a57 | ||
|
|
6540ba7226 | ||
|
|
3291cd08dd | ||
|
|
a08512ff71 | ||
|
|
345c67c750 | ||
|
|
bff97d2a70 | ||
|
|
62c289bd65 | ||
|
|
21cc64eee4 | ||
|
|
4a759e64fd | ||
|
|
f5122ec652 | ||
|
|
e9fafeaef8 | ||
|
|
8e2c6edd42 | ||
|
|
532f2882da | ||
|
|
9e73eaa5a3 | ||
|
|
8ef2815b44 | ||
|
|
63d4ab958a | ||
|
|
b031b58598 | ||
|
|
bdd45231e1 | ||
|
|
a38db77c8e | ||
|
|
21fa447da6 | ||
|
|
87bd130420 | ||
|
|
9a9ec41d92 | ||
|
|
e81a305f4d | ||
|
|
144980136e | ||
|
|
f6e90de708 | ||
|
|
95636c4825 | ||
|
|
aa05235392 | ||
|
|
84bfc5c363 | ||
|
|
2f2427f125 | ||
|
|
1ac2a2a909 | ||
|
|
44e368cb1b | ||
|
|
9bf20b76fa | ||
|
|
2a7128c390 | ||
|
|
8e93d351fd | ||
|
|
4acec9aeb9 | ||
|
|
51b655e364 | ||
|
|
f658e5ee66 | ||
|
|
9021e60d11 | ||
|
|
df510820fa | ||
|
|
26f90b0d7f | ||
|
|
d7ba80d502 | ||
|
|
96e90c1e7e | ||
|
|
559b7ff018 | ||
|
|
dd08f5e7cf | ||
|
|
0730e17932 | ||
|
|
a32307e6cf | ||
|
|
f9bd02553c | ||
|
|
d039e87da4 | ||
|
|
4347728a1b | ||
|
|
68f7f397d3 | ||
|
|
8c82a61450 | ||
|
|
67bde68596 | ||
|
|
3cb9494e62 | ||
|
|
f92231850c | ||
|
|
8f9d3f7fbd | ||
|
|
2b7dab2765 | ||
|
|
9ac56a4057 | ||
|
|
e8ee6f9e32 | ||
|
|
9348cdfd01 | ||
|
|
40c739c5a4 | ||
|
|
364fb46805 | ||
|
|
405f6bbb7f | ||
|
|
9a7a98b75e | ||
|
|
dc67aaaf53 | ||
|
|
31bc6ca612 | ||
|
|
b5acc09ba9 | ||
|
|
506ea92826 | ||
|
|
200d47bb43 | ||
|
|
be047427df | ||
|
|
e297d25603 | ||
|
|
89287af096 | ||
|
|
3a593d9d76 | ||
|
|
10737dd4ec | ||
|
|
7c03b831f5 | ||
|
|
cdf1e1ecc7 | ||
|
|
b9c0d5f46e | ||
|
|
a45fc86032 | ||
|
|
59dabed380 | ||
|
|
b40ba07a4d | ||
|
|
246887efa1 | ||
|
|
28a2c50495 | ||
|
|
c84ca43074 | ||
|
|
e2771a3011 | ||
|
|
3ea5076053 | ||
|
|
bd65f940e3 | ||
|
|
7bdd25e5a4 | ||
|
|
f6286359cf | ||
|
|
ac7fe1baf0 | ||
|
|
9c895f26e3 | ||
|
|
591533f850 | ||
|
|
127897b9d7 | ||
|
|
92507359b4 | ||
|
|
ca4c4440ae | ||
|
|
eb4306a2b8 | ||
|
|
baa847330d | ||
|
|
39372d2182 | ||
|
|
c484810f96 | ||
|
|
0c39057ca5 | ||
|
|
28d6e5f5ce | ||
|
|
e62a078298 | ||
|
|
3fd016808b | ||
|
|
b7282ce990 | ||
|
|
8685f5796a | ||
|
|
acc230fd20 | ||
|
|
30361f2ab7 | ||
|
|
6a8406b5e3 | ||
|
|
7980212bee | ||
|
|
317110855e | ||
|
|
048fa967f2 | ||
|
|
f7b4dfcac4 | ||
|
|
a686d31e4d | ||
|
|
cb63bf217b | ||
|
|
7eed23637d | ||
|
|
46e21c4e3e | ||
|
|
b4191f9c65 | ||
|
|
83b008c839 | ||
|
|
68c7b3650e | ||
|
|
2816c66300 | ||
|
|
01de972a8f | ||
|
|
da2d8fe35b | ||
|
|
a761b7dd35 | ||
|
|
4f89286fa8 | ||
|
|
d0836ce0ef | ||
|
|
4740476c9a | ||
|
|
c167d3ac38 | ||
|
|
2c3f533076 | ||
|
|
55baca57c1 | ||
|
|
0b797964a8 | ||
|
|
c1a47bd9de | ||
|
|
030cbc535a | ||
|
|
b0fd0f59c4 | ||
|
|
47287c3688 | ||
|
|
cc041b5e0a | ||
|
|
b4c74de7b3 | ||
|
|
9daceb7017 | ||
|
|
ff7f9725f8 | ||
|
|
cd7930eef9 | ||
|
|
24d94ef6fd | ||
|
|
04fbd00d4a | ||
|
|
33ec4436fb | ||
|
|
e848386d10 | ||
|
|
235cee1d28 | ||
|
|
8d4943997e | ||
|
|
2ab814574c | ||
|
|
c6b2dd3728 | ||
|
|
825fa75ee2 | ||
|
|
21231186d1 | ||
|
|
48f76662d5 | ||
|
|
4920670495 | ||
|
|
0a30cd356d | ||
|
|
1fe4bb8a04 | ||
|
|
21c1bbec90 | ||
|
|
ad69d6715e | ||
|
|
46cd4d01d9 | ||
|
|
672061cd64 | ||
|
|
df332cec84 | ||
|
|
d7fa35e066 | ||
|
|
f33eb862fd | ||
|
|
0a007ca805 | ||
|
|
24f268b6cb | ||
|
|
b8e3c07c47 | ||
|
|
aa84977680 | ||
|
|
e051b1dfea | ||
|
|
c27f96096a | ||
|
|
4bd87647d0 | ||
|
|
c1e10338c1 | ||
|
|
cd1cacad55 | ||
|
|
ac77b037d5 | ||
|
|
10eb69a7dc | ||
|
|
70b1540ae2 | ||
|
|
7522aa3174 | ||
|
|
77a33cb74d | ||
|
|
9637c3f4ab | ||
|
|
a15c85cbd1 | ||
|
|
a4d07f5afa | ||
|
|
f5191aded6 | ||
|
|
2520d8f739 |
1007
.all-contributorsrc
1007
.all-contributorsrc
File diff suppressed because it is too large
Load Diff
21
.gitattributes
vendored
21
.gitattributes
vendored
@@ -24,3 +24,24 @@
|
|||||||
*.woff binary
|
*.woff binary
|
||||||
*.pyc binary
|
*.pyc binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
|
|
||||||
|
#
|
||||||
|
## Theses files/directories should be excluded from git archives
|
||||||
|
#
|
||||||
|
|
||||||
|
.husky export-ignore
|
||||||
|
.vscode export-ignore
|
||||||
|
docs export-ignore
|
||||||
|
|
||||||
|
.git* export-ignore
|
||||||
|
*ignore export-ignore
|
||||||
|
*.md export-ignore
|
||||||
|
|
||||||
|
.all-contributorsrc export-ignore
|
||||||
|
.editorconfig export-ignore
|
||||||
|
Dockerfile.local export-ignore
|
||||||
|
docker-compose.yml export-ignore
|
||||||
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
|
public/os_logo_filled.png export-ignore
|
||||||
|
public/preview.jpg export-ignore
|
||||||
|
|||||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -1,7 +1,2 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
|
* @Fallenbagel
|
||||||
- @Fallenbagel
|
|
||||||
|
|
||||||
# i18n locale files
|
|
||||||
|
|
||||||
src/i18n/locale/ @Fallenbagel
|
|
||||||
|
|||||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
|||||||
github: [sct]
|
github: [Fallenbagel]
|
||||||
patreon: overseerr
|
|
||||||
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -19,7 +19,7 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Version
|
label: Version
|
||||||
description: What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
description: What version of Jellyseerr are you running? (You can find this in Settings → About → Version.)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -87,5 +87,5 @@ body:
|
|||||||
label: Code of Conduct
|
label: Code of Conduct
|
||||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md)
|
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md)
|
||||||
options:
|
options:
|
||||||
- label: I agree to follow Overseerr's Code of Conduct
|
- label: I agree to follow Jellyseerr's Code of Conduct
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,7 +2,7 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: 💬 Support via Discord
|
- name: 💬 Support via Discord
|
||||||
url: https://discord.gg/ckbvBtDJgC
|
url: https://discord.gg/ckbvBtDJgC
|
||||||
about: Chat with other users and the Overseerr dev team
|
about: Chat with other users and the Jellyseerr dev team
|
||||||
- name: 💬 Support via GitHub Discussions
|
- name: 💬 Support via GitHub Discussions
|
||||||
url: https://github.com/fallenbagel/jellyseerr/discussions
|
url: https://github.com/fallenbagel/jellyseerr/discussions
|
||||||
about: Ask questions and discuss with other community members
|
about: Ask questions and discuss with other community members
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -33,5 +33,5 @@ body:
|
|||||||
label: Code of Conduct
|
label: Code of Conduct
|
||||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md)
|
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/CODE_OF_CONDUCT.md)
|
||||||
options:
|
options:
|
||||||
- label: I agree to follow Overseerr's Code of Conduct
|
- label: I agree to follow Jellyseerr's Code of Conduct
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Lint & Test Build
|
name: Lint & Test Build
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
container: node:16.17-alpine
|
container: node:18.18-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Images
|
name: Build & Publish Docker Images
|
||||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -39,13 +39,6 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Cache Docker layers
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: /tmp/.buildx-cache
|
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-buildx-
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -57,6 +50,11 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Set lower case owner name
|
||||||
|
run: |
|
||||||
|
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||||
|
env:
|
||||||
|
OWNER: ${{ github.repository_owner }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -68,21 +66,13 @@ jobs:
|
|||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
fallenbagel/jellyseerr:develop
|
fallenbagel/jellyseerr:develop
|
||||||
cache-from: type=local,src=/tmp/.buildx-cache
|
ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
|
||||||
- # Temporary fix
|
|
||||||
# https://github.com/docker/build-push-action/issues/252
|
|
||||||
# https://github.com/moby/buildkit/issues/1896
|
|
||||||
name: Move cache
|
|
||||||
run: |
|
|
||||||
rm -rf /tmp/.buildx-cache
|
|
||||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build_and_push
|
needs: build_and_push
|
||||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|||||||
4
.github/workflows/preview.yml
vendored
4
.github/workflows/preview.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
name: Build & Publish Docker Preview Images
|
name: Build & Publish Docker Preview Images
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
|||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -5,7 +5,7 @@ on: workflow_dispatch
|
|||||||
jobs:
|
jobs:
|
||||||
semantic-release:
|
semantic-release:
|
||||||
name: Tag and release latest version
|
name: Tag and release latest version
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
env:
|
env:
|
||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
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-20.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: semantic-release
|
needs: semantic-release
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|||||||
8
.github/workflows/snap.yaml
vendored
8
.github/workflows/snap.yaml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
jobs:
|
jobs:
|
||||||
name: Job Check
|
name: Job Check
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
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
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
build-snap:
|
build-snap:
|
||||||
name: Build Snap Package (${{ matrix.architecture }})
|
name: Build Snap Package (${{ matrix.architecture }})
|
||||||
needs: jobs
|
needs: jobs
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -41,6 +41,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Set Up QEMU
|
- name: Set Up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Configure Git
|
||||||
|
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||||
- name: Build Snap Package
|
- name: Build Snap Package
|
||||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||||
id: build
|
id: build
|
||||||
@@ -67,7 +69,7 @@ jobs:
|
|||||||
name: Send Discord Notification
|
name: Send Discord Notification
|
||||||
needs: build-snap
|
needs: build-snap
|
||||||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v3
|
uses: technote-space/workflow-conclusion-action@v3
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.17-alpine AS BUILD_IMAGE
|
FROM node:18.18-alpine AS BUILD_IMAGE
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,10 +7,11 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
case "${TARGETPLATFORM}" in \
|
case "${TARGETPLATFORM}" in \
|
||||||
'linux/arm64' | 'linux/arm/v7') \
|
'linux/arm64' | 'linux/arm/v7') \
|
||||||
apk add --no-cache python3 make g++ && \
|
apk update && \
|
||||||
ln -s /usr/bin/python3 /usr/bin/python \
|
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||||
;; \
|
yarn global add node-gyp \
|
||||||
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
@@ -33,7 +34,10 @@ RUN touch config/DOCKER
|
|||||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||||
|
|
||||||
|
|
||||||
FROM node:16.17-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
|
# Metadata for Github Package Registry
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16.17-alpine
|
FROM node:18.18-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
321
README.md
321
README.md
@@ -2,20 +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 -->
|
||||||
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-34-orange.svg"/></a>
|
||||||
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a 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.
|
||||||
@@ -24,7 +32,7 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
|
|||||||
- Support for various notification agents.
|
- Support for various notification agents.
|
||||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||||
|
|
||||||
(Upcoming Features include: Multiple Server Instances, Music Support, and much more!)
|
(Upcoming Features include: Multiple Server Instances, and much more!)
|
||||||
|
|
||||||
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
|
||||||
|
|
||||||
@@ -32,72 +40,73 @@ 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
|
### 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
|
||||||
|
|
||||||
### Launching Jellyseerr manually:
|
### Building from source (ADVANCED):
|
||||||
|
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
||||||
Pre-requisites:
|
Pre-requisites:
|
||||||
|
|
||||||
- Nodejs (atleast LTS version)
|
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
||||||
- Yarn
|
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||||
- Download 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)
|
||||||
|
|
||||||
```bash
|
```cmd
|
||||||
npm i -g win-node-env
|
npm i -g win-node-env
|
||||||
yarn install
|
set CYPRESS_INSTALL_BINARY=0
|
||||||
|
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_
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
Pre-requisites:
|
**Pre-requisites:**
|
||||||
|
|
||||||
- Nodejs (atleast LTS version)
|
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
|
||||||
- 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:**
|
||||||
|
|
||||||
|
1. Assuming you want the root folder for the jellyseerr source code to be cloned to `/opt`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
git checkout main #if you want to run stable instead of develop
|
git checkout main
|
||||||
yarn install
|
|
||||||
yarn run build
|
|
||||||
yarn start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
_Systemd-service:_
|
3. Then install the dependencies and build the dist
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
|
yarn run build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Now you can start jellyseerr using `yarn start` and opening http://localhost:5055 in your browser.
|
||||||
|
|
||||||
|
5. If you want to run jellyseerr as a _Systemd-service:_
|
||||||
|
|
||||||
- assuming jellyseerr was cloned to `/opt/`
|
- assuming jellyseerr was cloned to `/opt/`
|
||||||
and the environmentfile is located at `/etc/jellyseerr`
|
- first create the environment file at `/etc/jellyseerr/jellyseerr.conf`
|
||||||
|
|
||||||
service:
|
Environment file:
|
||||||
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=Jellyseerr Service
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
EnvironmentFile=/etc/jellyseerr/jellyseerr.conf
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Type=exec
|
|
||||||
Restart=on-failure
|
|
||||||
WorkingDirectory=/opt/jellyseerr
|
|
||||||
ExecStart=/root/.nvm/versions/node/v18.7.0/bin/node dist/index.js
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Environmentfile:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# 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.
|
||||||
@@ -111,9 +120,34 @@ PORT=5055
|
|||||||
# JELLYFIN_TYPE=emby
|
# JELLYFIN_TYPE=emby
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Then run the command `which node` to find your node path (assuming it's at `/usr/bin/node`)
|
||||||
|
- Then create the service file using `sudo systemctl edit jellyseerr.service` or creating and editing a file at `/etc/systemd/system/jellyseerr.service`
|
||||||
|
|
||||||
|
Service file contents:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Jellyseerr Service
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/jellyseerr/jellyseerr.conf
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Type=exec
|
||||||
|
Restart=on-failure
|
||||||
|
WorkingDirectory=/opt/jellyseerr
|
||||||
|
ExecStart=/usr/bin/node dist/index.js
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
### Packages:
|
### Packages:
|
||||||
|
|
||||||
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
||||||
|
Nixpkg: [Nixpkg](https://search.nixos.org/packages?channel=unstable&show=jellyseerr)
|
||||||
|
Snap: [Snap](https://snapcraft.io/jellyseerr)
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|
||||||
@@ -143,4 +177,199 @@ You can help improve Jellyseerr too! Check out our [Contribution Guide](https://
|
|||||||
|
|
||||||
## Contributors ✨
|
## Contributors ✨
|
||||||
|
|
||||||
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.
|
Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcontributors.org/docs/en/emoji-key)) and all those that contributed directly to Jellyseerr:
|
||||||
|
|
||||||
|
### Jellyseerr Contributors ✨
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<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://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>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|
||||||
|
### Overseerr Contributors ✨
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt="sct"/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt="Alex Zoitos"/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt="Brandon Cohen"/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt="Ahreluth"/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt="KovalevArtem"/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt="GiyomuWeb"/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt="Angry Cuban"/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt="jvennik"/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt="darknessgp"/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt="salty"/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt="Shutruk"/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt="Krystian Charubin"/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt="Kieron Boswell"/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt="samwiseg0"/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt="ecelebi29"/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt="Mārtiņš Možeiko"/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt="mazzetta86"/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt="Paul Hagedorn"/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt="Shagon94"/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt="sebstrgg"/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt="Danshil Mungur"/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt="doob187"/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt="johnpyp"/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt="Jakob Ankarhem"/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt="Jayesh"/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt="flying-sausages"/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt="hirenshah"/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt="TheCatLady"/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt="Chris Pritchard"/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt="Tamberlox"/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt="Douglas Parker"/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt="Daniel Carter"/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt="nuro"/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt="ᗪєνιη ᗷυнʟ"/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt="JonnyWong16"/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt="Roxedus"/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt="WoisWoi"/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt="HubDuck"/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt="costaht"/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt="Shjosan"/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt="kobaubarr"/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt="Ricardo González"/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt="Torkil"/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt="Jagandeep Brar"/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt="dtalens"/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt="Alex Cortelyou"/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt="Jono Cairns"/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt="DJScias"/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt="Dabu-dot"/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt="Jabster28"/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt="littlerooster"/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt="Dustin Hildebrandt"/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt="Bruno Guerreiro"/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt="Alexander Neuhäuser"/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt="Livio"/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt="tangentThought"/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt="Nicolás Espinoza"/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt="sootylunatic"/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt="JoKerIsCraZy"/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt="Daddie0"/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt="Simone"/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt="Seohyun Joo"/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt="Sergey"/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt="Shaaft"/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt="sr093906"/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt="Nackophilz"/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt="Sean Chambers"/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt="deniscerri"/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt="tomgacz"/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt="Andersborrits"/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt="Maxent"/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt="Chun Yeung Wong"/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt="TheMeanCanEHdian"/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt="Gylesie"/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt="Fhd-pro"/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt="PovilasID"/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Jellyseerr">🪼⌨️</a> <a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|||||||
@@ -3,147 +3,147 @@
|
|||||||
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
|
||||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
"main": {
|
"main": {
|
||||||
"apiKey": "testkey",
|
"apiKey": "testkey",
|
||||||
"applicationTitle": "Overseerr",
|
"applicationTitle": "Overseerr",
|
||||||
"applicationUrl": "",
|
"applicationUrl": "",
|
||||||
"csrfProtection": false,
|
"csrfProtection": false,
|
||||||
"cacheImages": false,
|
"cacheImages": false,
|
||||||
"defaultPermissions": 32,
|
"defaultPermissions": 32,
|
||||||
"defaultQuotas": {
|
"defaultQuotas": {
|
||||||
"movie": {},
|
"movie": {},
|
||||||
"tv": {}
|
"tv": {}
|
||||||
},
|
},
|
||||||
"hideAvailable": false,
|
"hideAvailable": false,
|
||||||
"localLogin": true,
|
"localLogin": true,
|
||||||
"newPlexLogin": true,
|
"newPlexLogin": true,
|
||||||
"region": "",
|
"region": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
"name": "Seerr",
|
"name": "Seerr",
|
||||||
"ip": "192.168.1.1",
|
"ip": "192.168.1.1",
|
||||||
"port": 32400,
|
"port": 32400,
|
||||||
"useSsl": false,
|
"useSsl": false,
|
||||||
"libraries": [
|
"libraries": [
|
||||||
{
|
{
|
||||||
"id": "1",
|
"id": "1",
|
||||||
"name": "Movies",
|
"name": "Movies",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"type": "movie"
|
"type": "movie"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"machineId": "test"
|
"machineId": "test"
|
||||||
},
|
},
|
||||||
"tautulli": {},
|
"tautulli": {},
|
||||||
"radarr": [],
|
"radarr": [],
|
||||||
"sonarr": [],
|
"sonarr": [],
|
||||||
"public": {
|
"public": {
|
||||||
"initialized": true
|
"initialized": true
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"agents": {
|
"agents": {
|
||||||
"email": {
|
"email": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"options": {
|
"options": {
|
||||||
"emailFrom": "",
|
"emailFrom": "",
|
||||||
"smtpHost": "",
|
"smtpHost": "",
|
||||||
"smtpPort": 587,
|
"smtpPort": 587,
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"ignoreTls": false,
|
"ignoreTls": false,
|
||||||
"requireTls": false,
|
"requireTls": false,
|
||||||
"allowSelfSigned": false,
|
"allowSelfSigned": false,
|
||||||
"senderName": "Overseerr"
|
"senderName": "Overseerr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lunasea": {
|
"lunasea": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": ""
|
"webhookUrl": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": ""
|
"webhookUrl": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"botAPI": "",
|
"botAPI": "",
|
||||||
"chatId": "",
|
"chatId": "",
|
||||||
"sendSilently": false
|
"sendSilently": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pushbullet": {
|
"pushbullet": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"accessToken": ""
|
"accessToken": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pushover": {
|
"pushover": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"accessToken": "",
|
"accessToken": "",
|
||||||
"userToken": ""
|
"userToken": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"webhook": {
|
"webhook": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"webpush": {
|
"webpush": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"gotify": {
|
"gotify": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": ""
|
"token": ""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"plex-recently-added-scan": {
|
"plex-recently-added-scan": {
|
||||||
"schedule": "0 */5 * * * *"
|
"schedule": "0 */5 * * * *"
|
||||||
},
|
},
|
||||||
"plex-full-scan": {
|
"plex-full-scan": {
|
||||||
"schedule": "0 0 3 * * *"
|
"schedule": "0 0 3 * * *"
|
||||||
},
|
},
|
||||||
"radarr-scan": {
|
"radarr-scan": {
|
||||||
"schedule": "0 0 4 * * *"
|
"schedule": "0 0 4 * * *"
|
||||||
},
|
},
|
||||||
"sonarr-scan": {
|
"sonarr-scan": {
|
||||||
"schedule": "0 30 4 * * *"
|
"schedule": "0 30 4 * * *"
|
||||||
},
|
},
|
||||||
"download-sync": {
|
"download-sync": {
|
||||||
"schedule": "0 * * * * *"
|
"schedule": "0 * * * * *"
|
||||||
},
|
},
|
||||||
"download-sync-reset": {
|
"download-sync-reset": {
|
||||||
"schedule": "0 0 1 * * *"
|
"schedule": "0 0 1 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ describe('Discover', () => {
|
|||||||
|
|
||||||
cy.wait('@getWatchlist');
|
cy.wait('@getWatchlist');
|
||||||
|
|
||||||
const sliderHeader = cy.contains('.slider-header', 'Your Watchlist');
|
const sliderHeader = cy.contains('.slider-header', 'Watchlist');
|
||||||
|
|
||||||
sliderHeader.scrollIntoView();
|
sliderHeader.scrollIntoView();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
|
|||||||
url: '/api/v1/*',
|
url: '/api/v1/*',
|
||||||
}).as('apiCall');
|
}).as('apiCall');
|
||||||
|
|
||||||
cy.get('.searchbar').swipe('bottom', [190, 400]);
|
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||||
|
|
||||||
cy.wait('@apiCall').then((interception) => {
|
cy.wait('@apiCall').then((interception) => {
|
||||||
assert.isNotNull(
|
assert.isNotNull(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
overseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.local
|
dockerfile: Dockerfile.local
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -1351,6 +1354,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
userToken:
|
userToken:
|
||||||
type: string
|
type: string
|
||||||
|
sound:
|
||||||
|
type: string
|
||||||
GotifySettings:
|
GotifySettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1786,6 +1791,9 @@ components:
|
|||||||
pushoverUserKey:
|
pushoverUserKey:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
pushoverSound:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
telegramEnabled:
|
telegramEnabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
telegramBotUsername:
|
telegramBotUsername:
|
||||||
@@ -3083,6 +3091,33 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/notifications/pushover/sounds:
|
||||||
|
get:
|
||||||
|
summary: Get Pushover sounds
|
||||||
|
description: Returns valid Pushover sound options in a JSON array.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
nullable: false
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned Pushover settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
/settings/notifications/gotify:
|
/settings/notifications/gotify:
|
||||||
get:
|
get:
|
||||||
summary: Get Gotify notification settings
|
summary: Get Gotify notification settings
|
||||||
@@ -4505,6 +4540,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 10
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: voteCountGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteCountLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
- in: query
|
- in: query
|
||||||
name: watchRegion
|
name: watchRegion
|
||||||
schema:
|
schema:
|
||||||
@@ -4784,6 +4829,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 10
|
example: 10
|
||||||
|
- in: query
|
||||||
|
name: voteCountGte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 7
|
||||||
|
- in: query
|
||||||
|
name: voteCountLte
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 10
|
||||||
- in: query
|
- in: query
|
||||||
name: watchRegion
|
name: watchRegion
|
||||||
schema:
|
schema:
|
||||||
@@ -5637,6 +5692,63 @@ paths:
|
|||||||
audienceRating:
|
audienceRating:
|
||||||
type: string
|
type: string
|
||||||
enum: ['Spilled', 'Upright']
|
enum: ['Spilled', 'Upright']
|
||||||
|
/movie/{movieId}/ratingscombined:
|
||||||
|
get:
|
||||||
|
summary: Get RT and IMDB movie ratings combined
|
||||||
|
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
|
||||||
|
tags:
|
||||||
|
- movies
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: movieId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 337401
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ratings returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rt:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: Mulan
|
||||||
|
year:
|
||||||
|
type: number
|
||||||
|
example: 2020
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
|
||||||
|
criticsScore:
|
||||||
|
type: number
|
||||||
|
example: 85
|
||||||
|
criticsRating:
|
||||||
|
type: string
|
||||||
|
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||||
|
audienceScore:
|
||||||
|
type: number
|
||||||
|
example: 65
|
||||||
|
audienceRating:
|
||||||
|
type: string
|
||||||
|
enum: ['Spilled', 'Upright']
|
||||||
|
imdb:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
example: I am Legend
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: 'https://www.imdb.com/title/tt0480249'
|
||||||
|
criticsScore:
|
||||||
|
type: number
|
||||||
|
example: 6.5
|
||||||
/tv/{tvId}:
|
/tv/{tvId}:
|
||||||
get:
|
get:
|
||||||
summary: Get TV details
|
summary: Get TV details
|
||||||
|
|||||||
95
package.json
95
package.json
@@ -30,17 +30,17 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-displaynames": "6.2.3",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@formatjs/intl-locale": "3.0.11",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.8",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
"@formatjs/intl-utils": "3.8.4",
|
"@formatjs/intl-utils": "3.8.4",
|
||||||
"@headlessui/react": "1.7.7",
|
"@headlessui/react": "1.7.12",
|
||||||
"@heroicons/react": "2.0.13",
|
"@heroicons/react": "2.0.16",
|
||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.22",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
"ace-builds": "1.14.0",
|
"ace-builds": "1.15.2",
|
||||||
"axios": "1.2.2",
|
"axios": "1.3.4",
|
||||||
"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",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.6",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
"cronstrue": "2.21.0",
|
"cronstrue": "2.23.0",
|
||||||
"csurf": "1.11.0",
|
"csurf": "1.11.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.7",
|
||||||
@@ -65,23 +65,22 @@
|
|||||||
"next": "12.3.4",
|
"next": "12.3.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.0",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.8.0",
|
"nodemailer": "6.9.1",
|
||||||
"openpgp": "5.5.0",
|
"openpgp": "5.7.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"pulltorefreshjs": "0.1.22",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"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.22.0",
|
"react-aria": "3.23.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-intersection-observer": "9.4.1",
|
"react-intersection-observer": "9.4.3",
|
||||||
"react-intl": "6.2.5",
|
"react-intl": "6.2.10",
|
||||||
"react-markdown": "8.0.4",
|
"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",
|
||||||
"react-spring": "9.6.1",
|
"react-spring": "9.7.1",
|
||||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
@@ -90,42 +89,41 @@
|
|||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.3.8",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.6.0",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.0.0",
|
"swr": "2.0.4",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.12",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11",
|
"yup": "0.32.11",
|
||||||
"zod": "3.20.2"
|
"zod": "3.20.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "7.20.7",
|
"@babel/cli": "7.21.0",
|
||||||
"@commitlint/cli": "17.4.0",
|
"@commitlint/cli": "17.4.4",
|
||||||
"@commitlint/config-conventional": "17.4.0",
|
"@commitlint/config-conventional": "17.4.4",
|
||||||
"@semantic-release/changelog": "6.0.2",
|
"@semantic-release/changelog": "6.0.2",
|
||||||
"@semantic-release/commit-analyzer": "9.0.2",
|
"@semantic-release/commit-analyzer": "9.0.2",
|
||||||
"@semantic-release/exec": "6.0.3",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.3",
|
"@tailwindcss/forms": "0.5.3",
|
||||||
"@tailwindcss/typography": "0.5.8",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
"@types/csurf": "1.11.2",
|
"@types/csurf": "1.11.2",
|
||||||
"@types/email-templates": "8.0.4",
|
"@types/email-templates": "8.0.4",
|
||||||
"@types/express": "4.17.15",
|
"@types/express": "4.17.17",
|
||||||
"@types/express-session": "1.17.5",
|
"@types/express-session": "1.17.6",
|
||||||
"@types/lodash": "4.14.191",
|
"@types/lodash": "4.14.191",
|
||||||
"@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/pulltorefreshjs": "0.1.5",
|
"@types/react": "18.0.28",
|
||||||
"@types/react": "18.0.26",
|
"@types/react-dom": "18.0.11",
|
||||||
"@types/react-dom": "18.0.10",
|
|
||||||
"@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",
|
||||||
@@ -134,45 +132,46 @@
|
|||||||
"@types/xml2js": "0.4.11",
|
"@types/xml2js": "0.4.11",
|
||||||
"@types/yamljs": "0.2.31",
|
"@types/yamljs": "0.2.31",
|
||||||
"@types/yup": "0.29.14",
|
"@types/yup": "0.29.14",
|
||||||
"@typescript-eslint/eslint-plugin": "5.48.0",
|
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||||
"@typescript-eslint/parser": "5.48.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": "8.2.25",
|
||||||
"babel-plugin-react-intl-auto": "3.3.0",
|
"babel-plugin-react-intl-auto": "3.3.0",
|
||||||
"commitizen": "4.2.6",
|
"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.3.0",
|
"cypress": "12.7.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.31.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "12.3.4",
|
"eslint-config-next": "12.3.4",
|
||||||
"eslint-config-prettier": "8.6.0",
|
"eslint-config-prettier": "8.6.0",
|
||||||
"eslint-plugin-formatjs": "4.3.9",
|
"eslint-plugin-formatjs": "4.9.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
"eslint-plugin-prettier": "4.2.1",
|
||||||
"eslint-plugin-react": "7.31.11",
|
"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",
|
"extract-react-intl-messages": "4.1.1",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.0",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "2.0.20",
|
||||||
"postcss": "8.4.20",
|
"postcss": "8.4.21",
|
||||||
"prettier": "2.8.1",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.2.1",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.1",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
"semantic-release": "19.0.5",
|
"semantic-release": "19.0.5",
|
||||||
"semantic-release-docker-buildx": "1.0.1",
|
"semantic-release-docker-buildx": "1.0.1",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "3.2.7",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tsc-alias": "1.8.2",
|
"tsc-alias": "1.8.2",
|
||||||
"tsconfig-paths": "4.1.2",
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "4.9.4"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"sqlite3/node-gyp": "8.4.1",
|
"sqlite3/node-gyp": "8.4.1",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.28",
|
||||||
"@types/react-dom": "18.0.10"
|
"@types/react-dom": "18.0.11",
|
||||||
|
"@types/express-session": "1.17.6"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #6366F1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<!-- Inline the page's JavaScript file. -->
|
<!-- Inline the page's JavaScript file. -->
|
||||||
<script>
|
<script>
|
||||||
// Manual reload feature.
|
// Manual reload feature.
|
||||||
document.querySelector("button").addEventListener("click", () => {
|
document.querySelector('button').addEventListener('click', () => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
74
public/sw.js
74
public/sw.js
@@ -4,30 +4,30 @@
|
|||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 3;
|
||||||
const CACHE_NAME = "offline";
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = "/offline.html";
|
const OFFLINE_URL = '/offline.html';
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
// Setting {cache: 'reload'} in the new request will ensure that the
|
// Setting {cache: 'reload'} in the new request will ensure that the
|
||||||
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
||||||
// the network.
|
// the network.
|
||||||
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
|
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
// Force the waiting service worker to become the active service worker.
|
// Force the waiting service worker to become the active service worker.
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
// Enable navigation preload if it's supported.
|
// Enable navigation preload if it's supported.
|
||||||
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
||||||
if ("navigationPreload" in self.registration) {
|
if ('navigationPreload' in self.registration) {
|
||||||
await self.registration.navigationPreload.enable();
|
await self.registration.navigationPreload.enable();
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => {
|
|||||||
clients.claim();
|
clients.claim();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// We only want to call event.respondWith() if this is a navigation request
|
// We only want to call event.respondWith() if this is a navigation request
|
||||||
// for an HTML page.
|
// for an HTML page.
|
||||||
if (event.request.mode === "navigate") {
|
if (event.request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
// If fetch() returns a valid HTTP response with a response code in
|
// If fetch() returns a valid HTTP response with a response code in
|
||||||
// the 4xx or 5xx range, the catch() will NOT be called.
|
// the 4xx or 5xx range, the catch() will NOT be called.
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log("Fetch failed; returning offline page instead.", error);
|
console.log('Fetch failed; returning offline page instead.', error);
|
||||||
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
const cachedResponse = await cache.match(OFFLINE_URL);
|
const cachedResponse = await cache.match(OFFLINE_URL);
|
||||||
@@ -85,15 +85,13 @@ self.addEventListener('push', (event) => {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
},
|
},
|
||||||
actions: [],
|
actions: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
if (payload.actionUrl){
|
if (payload.actionUrl) {
|
||||||
options.actions.push(
|
options.actions.push({
|
||||||
{
|
action: 'view',
|
||||||
action: 'view',
|
title: payload.actionUrlTitle ?? 'View',
|
||||||
title: payload.actionUrlTitle ?? 'View',
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notificationType === 'MEDIA_PENDING') {
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
@@ -109,27 +107,29 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
self.registration.showNotification(payload.subject, options)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener(
|
||||||
const notificationData = event.notification.data;
|
'notificationclick',
|
||||||
|
(event) => {
|
||||||
|
const notificationData = event.notification.data;
|
||||||
|
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'approve') {
|
if (event.action === 'approve') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
} else if (event.action === 'decline') {
|
} else if (event.action === 'decline') {
|
||||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationData.actionUrl) {
|
if (notificationData.actionUrl) {
|
||||||
clients.openWindow(notificationData.actionUrl);
|
clients.openWindow(notificationData.actionUrl);
|
||||||
}
|
}
|
||||||
}, false);
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import type { AxiosInstance } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -8,6 +9,9 @@ export interface JellyfinUserResponse {
|
|||||||
ServerId: string;
|
ServerId: string;
|
||||||
ServerName: string;
|
ServerName: string;
|
||||||
Id: string;
|
Id: string;
|
||||||
|
Policy: {
|
||||||
|
IsAdministrator: boolean;
|
||||||
|
};
|
||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +175,9 @@ class JellyfinAPI {
|
|||||||
|
|
||||||
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
||||||
try {
|
try {
|
||||||
|
// TODO: Try to fix automatic grouping without fucking up LDAP users
|
||||||
|
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
|
||||||
|
|
||||||
const account = await this.axios.get<any>(
|
const account = await this.axios.get<any>(
|
||||||
`/Users/${this.userId ?? 'Me'}/Views`
|
`/Users/${this.userId ?? 'Me'}/Views`
|
||||||
);
|
);
|
||||||
@@ -238,7 +245,9 @@ class JellyfinAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 contents = await this.axios.get<any>(
|
||||||
`/Users/${this.userId}/Items/${id}`
|
`/Users/${this.userId}/Items/${id}`
|
||||||
@@ -246,6 +255,11 @@ class JellyfinAPI {
|
|||||||
|
|
||||||
return contents.data;
|
return contents.data;
|
||||||
} 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' }
|
||||||
@@ -258,9 +272,7 @@ class JellyfinAPI {
|
|||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
|
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
|
||||||
|
|
||||||
return contents.data.Items.filter(
|
return contents.data.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}`,
|
||||||
|
|||||||
@@ -82,21 +82,6 @@ interface ServerResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FriendResponse {
|
|
||||||
MediaContainer: {
|
|
||||||
User: {
|
|
||||||
$: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
thumb: string;
|
|
||||||
};
|
|
||||||
Server?: ServerResponse[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersResponse {
|
interface UsersResponse {
|
||||||
MediaContainer: {
|
MediaContainer: {
|
||||||
User: {
|
User: {
|
||||||
@@ -234,19 +219,6 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFriends(): Promise<FriendResponse> {
|
|
||||||
const response = await this.axios.get('/pms/friends/all', {
|
|
||||||
transformResponse: [],
|
|
||||||
responseType: 'text',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
|
||||||
response.data
|
|
||||||
)) as FriendResponse;
|
|
||||||
|
|
||||||
return parsedXml;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkUserAccess(userId: number): Promise<boolean> {
|
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
@@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
throw new Error('Plex is not configured!');
|
throw new Error('Plex is not configured!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const friends = await this.getFriends();
|
const usersResponse = await this.getUsers();
|
||||||
|
|
||||||
const users = friends.MediaContainer.User;
|
const users = usersResponse.MediaContainer.User;
|
||||||
|
|
||||||
const user = users.find((u) => parseInt(u.$.id) === userId);
|
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||||
|
|
||||||
|
|||||||
56
server/api/pushover.ts
Normal file
56
server/api/pushover.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
|
interface PushoverSoundsResponse {
|
||||||
|
sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
};
|
||||||
|
status: number;
|
||||||
|
request: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushoverSound {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapSounds = (sounds: {
|
||||||
|
[name: string]: string;
|
||||||
|
}): PushoverSound[] =>
|
||||||
|
Object.entries(sounds).map(
|
||||||
|
([name, description]) =>
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
} as PushoverSound)
|
||||||
|
);
|
||||||
|
|
||||||
|
class PushoverAPI extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'https://api.pushover.net/1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||||
|
params: {
|
||||||
|
token: appToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapSounds(data.sounds);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PushoverAPI;
|
||||||
195
server/api/rating/imdbRadarrProxy.ts
Normal file
195
server/api/rating/imdbRadarrProxy.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import cacheManager from '@server/lib/cache';
|
||||||
|
|
||||||
|
type IMDBRadarrProxyResponse = IMDBMovie[];
|
||||||
|
|
||||||
|
interface IMDBMovie {
|
||||||
|
ImdbId: string;
|
||||||
|
Overview: string;
|
||||||
|
Title: string;
|
||||||
|
OriginalTitle: string;
|
||||||
|
TitleSlug: string;
|
||||||
|
Ratings: Rating[];
|
||||||
|
MovieRatings: MovieRatings;
|
||||||
|
Runtime: number;
|
||||||
|
Images: Image[];
|
||||||
|
Genres: string[];
|
||||||
|
Popularity: number;
|
||||||
|
Premier: string;
|
||||||
|
InCinema: string;
|
||||||
|
PhysicalRelease: any;
|
||||||
|
DigitalRelease: string;
|
||||||
|
Year: number;
|
||||||
|
AlternativeTitles: AlternativeTitle[];
|
||||||
|
Translations: Translation[];
|
||||||
|
Recommendations: Recommendation[];
|
||||||
|
Credits: Credits;
|
||||||
|
Studio: string;
|
||||||
|
YoutubeTrailerId: string;
|
||||||
|
Certifications: Certification[];
|
||||||
|
Status: any;
|
||||||
|
Collection: Collection;
|
||||||
|
OriginalLanguage: string;
|
||||||
|
Homepage: string;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rating {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Origin: string;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieRatings {
|
||||||
|
Tmdb: Tmdb;
|
||||||
|
Imdb: Imdb;
|
||||||
|
Metacritic: Metacritic;
|
||||||
|
RottenTomatoes: RottenTomatoes;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tmdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Imdb {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Metacritic {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RottenTomatoes {
|
||||||
|
Count: number;
|
||||||
|
Value: number;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlternativeTitle {
|
||||||
|
Title: string;
|
||||||
|
Type: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Translation {
|
||||||
|
Title: string;
|
||||||
|
Overview: string;
|
||||||
|
Language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
TmdbId: number;
|
||||||
|
Title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credits {
|
||||||
|
Cast: Cast[];
|
||||||
|
Crew: Crew[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cast {
|
||||||
|
Name: string;
|
||||||
|
Order: number;
|
||||||
|
Character: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image2 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Crew {
|
||||||
|
Name: string;
|
||||||
|
Job: string;
|
||||||
|
Department: string;
|
||||||
|
TmdbId: number;
|
||||||
|
CreditId: string;
|
||||||
|
Images: Image3[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Image3 {
|
||||||
|
CoverType: string;
|
||||||
|
Url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Certification {
|
||||||
|
Country: string;
|
||||||
|
Certification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
Name: string;
|
||||||
|
Images: any;
|
||||||
|
Overview: any;
|
||||||
|
Translations: any;
|
||||||
|
Parts: any;
|
||||||
|
TmdbId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMDBRating {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
criticsScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a best-effort API. The IMDB API is technically
|
||||||
|
* private and getting access costs money/requires approval.
|
||||||
|
*
|
||||||
|
* Radarr hosts a public proxy that's in use by all Radarr instances.
|
||||||
|
*/
|
||||||
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super('https://api.radarr.video/v1', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the Radarr IMDB Proxy for the movie
|
||||||
|
*
|
||||||
|
* @param IMDBid Id of IMDB movie
|
||||||
|
*/
|
||||||
|
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<IMDBRadarrProxyResponse>(
|
||||||
|
`/movie/imdb/${IMDBid}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data[0].Title,
|
||||||
|
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||||
|
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IMDBRadarrProxy;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import ExternalAPI from './externalapi';
|
|
||||||
|
|
||||||
interface RTAlgoliaSearchResponse {
|
interface RTAlgoliaSearchResponse {
|
||||||
results: {
|
results: {
|
||||||
@@ -17,7 +17,7 @@ interface RTAlgoliaHit {
|
|||||||
title: string;
|
title: string;
|
||||||
titles: string[];
|
titles: string[];
|
||||||
description: string;
|
description: string;
|
||||||
releaseYear: string;
|
releaseYear: number;
|
||||||
rating: string;
|
rating: string;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
updateDate: string;
|
updateDate: string;
|
||||||
@@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
|
|
||||||
// First, attempt to match exact name and year
|
// First, attempt to match exact name and year
|
||||||
let movie = contentResults.hits.find(
|
let movie = contentResults.hits.find(
|
||||||
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
(movie) => movie.releaseYear === year && movie.title === name
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we don't find a movie, try to match partial name and year
|
// If we don't find a movie, try to match partial name and year
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
movie = contentResults.hits.find(
|
movie = contentResults.hits.find(
|
||||||
(movie) =>
|
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we still dont find a movie, try to match just on year
|
// If we still dont find a movie, try to match just on year
|
||||||
if (!movie) {
|
if (!movie) {
|
||||||
movie = contentResults.hits.find(
|
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||||
(movie) => movie.releaseYear === year.toString()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One last try, try exact name match only
|
// One last try, try exact name match only
|
||||||
@@ -147,6 +144,9 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
? 'Fresh'
|
? 'Fresh'
|
||||||
: 'Rotten',
|
: 'Rotten',
|
||||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: movie.rottenTomatoes.audienceScore,
|
||||||
year: Number(movie.releaseYear),
|
year: Number(movie.releaseYear),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -181,7 +181,7 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
|
|
||||||
if (year) {
|
if (year) {
|
||||||
tvshow = contentResults.hits.find(
|
tvshow = contentResults.hits.find(
|
||||||
(series) => series.releaseYear === year.toString()
|
(series) => series.releaseYear === year
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +195,9 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
criticsRating:
|
criticsRating:
|
||||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||||
|
audienceRating:
|
||||||
|
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||||
|
audienceScore: tvshow.rottenTomatoes.audienceScore,
|
||||||
year: Number(tvshow.releaseYear),
|
year: Number(tvshow.releaseYear),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
7
server/api/ratings.ts
Normal file
7
server/api/ratings.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
|
||||||
|
import { type RTRating } from '@server/api/rating/rottentomatoes';
|
||||||
|
|
||||||
|
export interface RatingResponse {
|
||||||
|
rt?: RTRating;
|
||||||
|
imdb?: IMDBRating;
|
||||||
|
}
|
||||||
@@ -76,6 +76,15 @@ export interface SonarrSeries {
|
|||||||
ignoreEpisodesWithoutFiles?: boolean;
|
ignoreEpisodesWithoutFiles?: boolean;
|
||||||
searchForMissingEpisodes?: boolean;
|
searchForMissingEpisodes?: boolean;
|
||||||
};
|
};
|
||||||
|
statistics: {
|
||||||
|
seasonCount: number;
|
||||||
|
episodeFileCount: number;
|
||||||
|
episodeCount: number;
|
||||||
|
totalEpisodeCount: number;
|
||||||
|
sizeOnDisk: number;
|
||||||
|
releaseGroups: string[];
|
||||||
|
percentOfEpisodes: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddSeriesOptions {
|
export interface AddSeriesOptions {
|
||||||
@@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
|
|||||||
withRuntimeLte?: string;
|
withRuntimeLte?: string;
|
||||||
voteAverageGte?: string;
|
voteAverageGte?: string;
|
||||||
voteAverageLte?: string;
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
studio?: string;
|
studio?: string;
|
||||||
@@ -83,6 +85,8 @@ interface DiscoverTvOptions {
|
|||||||
withRuntimeLte?: string;
|
withRuntimeLte?: string;
|
||||||
voteAverageGte?: string;
|
voteAverageGte?: string;
|
||||||
voteAverageLte?: string;
|
voteAverageLte?: string;
|
||||||
|
voteCountGte?: string;
|
||||||
|
voteCountLte?: string;
|
||||||
includeEmptyReleaseDate?: boolean;
|
includeEmptyReleaseDate?: boolean;
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
@@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
withRuntimeLte,
|
withRuntimeLte,
|
||||||
voteAverageGte,
|
voteAverageGte,
|
||||||
voteAverageLte,
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
@@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'with_runtime.lte': withRuntimeLte,
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.gte': voteAverageGte,
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.lte': voteAverageLte,
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
watch_region: watchRegion,
|
watch_region: watchRegion,
|
||||||
with_watch_providers: watchProviders,
|
with_watch_providers: watchProviders,
|
||||||
},
|
},
|
||||||
@@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
withRuntimeLte,
|
withRuntimeLte,
|
||||||
voteAverageGte,
|
voteAverageGte,
|
||||||
voteAverageLte,
|
voteAverageLte,
|
||||||
|
voteCountGte,
|
||||||
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
@@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'with_runtime.lte': withRuntimeLte,
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.gte': voteAverageGte,
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.lte': voteAverageLte,
|
'vote_average.lte': voteAverageLte,
|
||||||
|
'vote_count.gte': voteCountGte,
|
||||||
|
'vote_count.lte': voteCountLte,
|
||||||
with_watch_providers: watchProviders,
|
with_watch_providers: watchProviders,
|
||||||
watch_region: watchRegion,
|
watch_region: watchRegion,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
|||||||
first_air_date: string;
|
first_air_date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbCollectionResult {
|
||||||
|
id: number;
|
||||||
|
media_type: 'collection';
|
||||||
|
title: string;
|
||||||
|
original_title: string;
|
||||||
|
adult: boolean;
|
||||||
|
poster_path?: string;
|
||||||
|
backdrop_path?: string;
|
||||||
|
overview: string;
|
||||||
|
original_language: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TmdbPersonResult {
|
export interface TmdbPersonResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
results: (
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export enum DiscoverSliderType {
|
|||||||
TMDB_SEARCH,
|
TMDB_SEARCH,
|
||||||
TMDB_STUDIO,
|
TMDB_STUDIO,
|
||||||
TMDB_NETWORK,
|
TMDB_NETWORK,
|
||||||
|
TMDB_MOVIE_STREAMING_SERVICES,
|
||||||
|
TMDB_TV_STREAMING_SERVICES,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||||
|
|||||||
@@ -151,11 +151,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;
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ export class MediaRequest {
|
|||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
let tags = radarrSettings.tags;
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -764,6 +764,38 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (radarrSettings.tagRequests) {
|
||||||
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await radarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
@@ -952,7 +984,7 @@ export class MediaRequest {
|
|||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
seriesType = 'anime';
|
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder =
|
let rootFolder =
|
||||||
@@ -970,7 +1002,11 @@ export class MediaRequest {
|
|||||||
let tags =
|
let tags =
|
||||||
seriesType === 'anime'
|
seriesType === 'anime'
|
||||||
? sonarrSettings.animeTags
|
? sonarrSettings.animeTags
|
||||||
: sonarrSettings.tags;
|
? [...sonarrSettings.animeTags]
|
||||||
|
: []
|
||||||
|
: sonarrSettings.tags
|
||||||
|
? [...sonarrSettings.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
@@ -1022,6 +1058,38 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sonarrSettings.tagRequests) {
|
||||||
|
let userTag = (await sonarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await sonarr.createTag({
|
||||||
|
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
userId: this.requestedBy.id,
|
||||||
|
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
profileId: qualityProfile,
|
profileId: qualityProfile,
|
||||||
languageProfileId: languageProfile,
|
languageProfileId: languageProfile,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public pushoverUserKey?: string;
|
public pushoverUserKey?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public pushoverSound?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramChatId?: string;
|
public telegramChatId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ app
|
|||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: true,
|
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||||
secure: 'auto',
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export interface SettingsAboutResponse {
|
|||||||
|
|
||||||
export interface PublicSettingsResponse {
|
export interface PublicSettingsResponse {
|
||||||
jellyfinHost?: string;
|
jellyfinHost?: string;
|
||||||
|
jellyfinExternalHost?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface UserSettingsNotificationsResponse {
|
|||||||
pushbulletAccessToken?: string;
|
pushbulletAccessToken?: string;
|
||||||
pushoverApplicationToken?: string;
|
pushoverApplicationToken?: string;
|
||||||
pushoverUserKey?: string;
|
pushoverUserKey?: string;
|
||||||
|
pushoverSound?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
telegramBotUsername?: string;
|
telegramBotUsername?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: 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';
|
||||||
@@ -8,8 +13,8 @@ import type { JobId } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import watchlistSync from '@server/lib/watchlistsync';
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
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;
|
||||||
@@ -71,57 +76,67 @@ 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-sync',
|
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-sync'].schedule,
|
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||||
job: schedule.scheduleJob(
|
job: schedule.scheduleJob(
|
||||||
jobs['jellyfin-recently-added-sync'].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-sync',
|
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-sync'].schedule,
|
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run watchlist sync every 5 minutes
|
// Watchlist Sync
|
||||||
scheduledJobs.push({
|
const watchlistSyncJob: ScheduledJob = {
|
||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'minutes',
|
interval: 'fixed',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
watchlistSync.syncWatchlist();
|
watchlistSync.syncWatchlist();
|
||||||
}),
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||||
|
// after each run
|
||||||
|
watchlistSyncJob.job.on('run', () => {
|
||||||
|
watchlistSyncJob.job.schedule(
|
||||||
|
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push(watchlistSyncJob);
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
@@ -153,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',
|
||||||
@@ -168,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({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ export type AvailableCacheIds =
|
|||||||
| 'radarr'
|
| 'radarr'
|
||||||
| 'sonarr'
|
| 'sonarr'
|
||||||
| 'rt'
|
| 'rt'
|
||||||
|
| 'imdb'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid'
|
| 'plexguid'
|
||||||
| 'plextv';
|
| 'plextv';
|
||||||
@@ -51,6 +52,10 @@ class CacheManager {
|
|||||||
stdTtl: 43200,
|
stdTtl: 43200,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
|
||||||
|
stdTtl: 43200,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
github: new Cache('github', 'GitHub API', {
|
github: new Cache('github', 'GitHub API', {
|
||||||
stdTtl: 21600,
|
stdTtl: 21600,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class PushoverAgent
|
|||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
token: settings.options.accessToken,
|
token: settings.options.accessToken,
|
||||||
user: settings.options.userToken,
|
user: settings.options.userToken,
|
||||||
|
sound: settings.options.sound,
|
||||||
} as PushoverPayload);
|
} as PushoverPayload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
@@ -198,6 +199,7 @@ class PushoverAgent
|
|||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||||
user: payload.notifyUser.settings.pushoverUserKey,
|
user: payload.notifyUser.settings.pushoverUserKey,
|
||||||
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
} as PushoverPayload);
|
} as PushoverPayload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
|
|||||||
@@ -26,7 +26,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 +62,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,
|
||||||
@@ -168,9 +168,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 +197,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 +283,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) {
|
||||||
@@ -311,13 +319,15 @@ class JobJellyfinSync {
|
|||||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||||
// and then not modifying the status if there are 0 items
|
// and then not modifying the status if there are 0 items
|
||||||
existingSeason.status =
|
existingSeason.status =
|
||||||
totalStandard >= season.episode_count
|
totalStandard >= season.episode_count ||
|
||||||
|
existingSeason.status === MediaStatus.AVAILABLE
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: totalStandard > 0
|
: totalStandard > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: existingSeason.status;
|
: existingSeason.status;
|
||||||
existingSeason.status4k =
|
existingSeason.status4k =
|
||||||
this.enable4kShow && total4k >= season.episode_count
|
(this.enable4kShow && total4k >= season.episode_count) ||
|
||||||
|
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && total4k > 0
|
: this.enable4kShow && total4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
@@ -451,8 +461,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(
|
||||||
@@ -673,7 +684,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,
|
||||||
});
|
});
|
||||||
@@ -40,6 +40,7 @@ export interface JellyfinSettings {
|
|||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
externalHostname?: string;
|
externalHostname?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ export interface DVRSettings {
|
|||||||
externalUrl?: string;
|
externalUrl?: string;
|
||||||
syncEnabled: boolean;
|
syncEnabled: boolean;
|
||||||
preventSearch: boolean;
|
preventSearch: boolean;
|
||||||
|
tagRequests: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadarrSettings extends DVRSettings {
|
export interface RadarrSettings extends DVRSettings {
|
||||||
@@ -76,6 +78,8 @@ export interface RadarrSettings extends DVRSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SonarrSettings extends DVRSettings {
|
export interface SonarrSettings extends DVRSettings {
|
||||||
|
seriesType: 'standard' | 'daily' | 'anime';
|
||||||
|
animeSeriesType: 'standard' | 'daily' | 'anime';
|
||||||
activeAnimeProfileId?: number;
|
activeAnimeProfileId?: number;
|
||||||
activeAnimeProfileName?: string;
|
activeAnimeProfileName?: string;
|
||||||
activeAnimeDirectory?: string;
|
activeAnimeDirectory?: string;
|
||||||
@@ -127,6 +131,8 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
jellyfinHost?: string;
|
jellyfinHost?: string;
|
||||||
|
jellyfinExternalHost?: string;
|
||||||
|
jellyfinForgotPasswordUrl?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
cacheImages: boolean;
|
cacheImages: boolean;
|
||||||
@@ -203,6 +209,7 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
|
|||||||
options: {
|
options: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
userToken: string;
|
userToken: string;
|
||||||
|
sound: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +269,8 @@ export type JobId =
|
|||||||
| 'sonarr-scan'
|
| 'sonarr-scan'
|
||||||
| 'download-sync'
|
| 'download-sync'
|
||||||
| 'download-sync-reset'
|
| 'download-sync-reset'
|
||||||
| 'jellyfin-recently-added-sync'
|
| 'jellyfin-recently-added-scan'
|
||||||
| 'jellyfin-full-sync'
|
| 'jellyfin-full-scan'
|
||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync';
|
||||||
|
|
||||||
@@ -326,6 +333,7 @@ class Settings {
|
|||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
},
|
},
|
||||||
@@ -395,6 +403,7 @@ class Settings {
|
|||||||
options: {
|
options: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
userToken: '',
|
userToken: '',
|
||||||
|
sound: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
webhook: {
|
webhook: {
|
||||||
@@ -403,7 +412,7 @@ class Settings {
|
|||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
jsonPayload:
|
jsonPayload:
|
||||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
webpush: {
|
webpush: {
|
||||||
@@ -445,10 +454,10 @@ class Settings {
|
|||||||
'download-sync-reset': {
|
'download-sync-reset': {
|
||||||
schedule: '0 0 1 * * *',
|
schedule: '0 0 1 * * *',
|
||||||
},
|
},
|
||||||
'jellyfin-recently-added-sync': {
|
'jellyfin-recently-added-scan': {
|
||||||
schedule: '0 */5 * * * *',
|
schedule: '0 */5 * * * *',
|
||||||
},
|
},
|
||||||
'jellyfin-full-sync': {
|
'jellyfin-full-scan': {
|
||||||
schedule: '0 0 3 * * *',
|
schedule: '0 0 3 * * *',
|
||||||
},
|
},
|
||||||
'image-cache-cleanup': {
|
'image-cache-cleanup': {
|
||||||
@@ -528,6 +537,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
|
||||||
),
|
),
|
||||||
@@ -538,6 +548,7 @@ class Settings {
|
|||||||
originalLanguage: this.data.main.originalLanguage,
|
originalLanguage: this.data.main.originalLanguage,
|
||||||
mediaServerType: this.main.mediaServerType,
|
mediaServerType: this.main.mediaServerType,
|
||||||
jellyfinHost: this.jellyfin.hostname,
|
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,
|
||||||
|
|||||||
@@ -80,82 +80,80 @@ class WatchlistSync {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
for (const mediaItem of unavailableItems) {
|
||||||
unavailableItems.map(async (mediaItem) => {
|
try {
|
||||||
try {
|
logger.info("Creating media request from user's Plex Watchlist", {
|
||||||
logger.info("Creating media request from user's Plex Watchlist", {
|
label: 'Watchlist Sync',
|
||||||
label: 'Watchlist Sync',
|
userId: user.id,
|
||||||
userId: user.id,
|
mediaTitle: mediaItem.title,
|
||||||
mediaTitle: mediaItem.title,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
||||||
throw new Error('Missing TVDB ID from Plex Metadata');
|
throw new Error('Missing TVDB ID from Plex Metadata');
|
||||||
}
|
|
||||||
|
|
||||||
// Check if they have auto-request permissons and watchlist sync
|
|
||||||
// enabled for the media type
|
|
||||||
if (
|
|
||||||
((!user.hasPermission(
|
|
||||||
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
|
|
||||||
{ type: 'or' }
|
|
||||||
) ||
|
|
||||||
!user.settings?.watchlistSyncMovies) &&
|
|
||||||
mediaItem.type === 'movie') ||
|
|
||||||
((!user.hasPermission(
|
|
||||||
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
|
|
||||||
{ type: 'or' }
|
|
||||||
) ||
|
|
||||||
!user.settings?.watchlistSyncTv) &&
|
|
||||||
mediaItem.type === 'show')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await MediaRequest.request(
|
|
||||||
{
|
|
||||||
mediaId: mediaItem.tmdbId,
|
|
||||||
mediaType:
|
|
||||||
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
|
|
||||||
seasons: mediaItem.type === 'show' ? 'all' : undefined,
|
|
||||||
tvdbId: mediaItem.tvdbId,
|
|
||||||
is4k: false,
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
{ isAutoRequest: true }
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof Error)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.constructor) {
|
|
||||||
// During watchlist sync, these errors aren't necessarily
|
|
||||||
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
|
||||||
// possible they are unexpectedly at their quota limit, for example. So we'll
|
|
||||||
// instead log these as debug messages.
|
|
||||||
case RequestPermissionError:
|
|
||||||
case DuplicateMediaRequestError:
|
|
||||||
case QuotaRestrictedError:
|
|
||||||
case NoSeasonsAvailableError:
|
|
||||||
logger.debug('Failed to create media request from watchlist', {
|
|
||||||
label: 'Watchlist Sync',
|
|
||||||
userId: user.id,
|
|
||||||
mediaTitle: mediaItem.title,
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.error('Failed to create media request from watchlist', {
|
|
||||||
label: 'Watchlist Sync',
|
|
||||||
userId: user.id,
|
|
||||||
mediaTitle: mediaItem.title,
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
);
|
// Check if they have auto-request permissons and watchlist sync
|
||||||
|
// enabled for the media type
|
||||||
|
if (
|
||||||
|
((!user.hasPermission(
|
||||||
|
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
|
||||||
|
{ type: 'or' }
|
||||||
|
) ||
|
||||||
|
!user.settings?.watchlistSyncMovies) &&
|
||||||
|
mediaItem.type === 'movie') ||
|
||||||
|
((!user.hasPermission(
|
||||||
|
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
|
||||||
|
{ type: 'or' }
|
||||||
|
) ||
|
||||||
|
!user.settings?.watchlistSyncTv) &&
|
||||||
|
mediaItem.type === 'show')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MediaRequest.request(
|
||||||
|
{
|
||||||
|
mediaId: mediaItem.tmdbId,
|
||||||
|
mediaType:
|
||||||
|
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
|
||||||
|
seasons: mediaItem.type === 'show' ? 'all' : undefined,
|
||||||
|
tvdbId: mediaItem.tvdbId,
|
||||||
|
is4k: false,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
{ isAutoRequest: true }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.constructor) {
|
||||||
|
// During watchlist sync, these errors aren't necessarily
|
||||||
|
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
||||||
|
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||||
|
// instead log these as debug messages.
|
||||||
|
case RequestPermissionError:
|
||||||
|
case DuplicateMediaRequestError:
|
||||||
|
case QuotaRestrictedError:
|
||||||
|
case NoSeasonsAvailableError:
|
||||||
|
logger.debug('Failed to create media request from watchlist', {
|
||||||
|
label: 'Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
mediaTitle: mediaItem.title,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.error('Failed to create media request from watchlist', {
|
||||||
|
label: 'Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
mediaTitle: mediaItem.title,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
server/migration/1697393491630-AddUserPushoverSound.ts
Normal file
31
server/migration/1697393491630-AddUserPushoverSound.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserPushoverSound1697393491630 implements MigrationInterface {
|
||||||
|
name = 'AddUserPushoverSound1697393491630';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
@@ -9,7 +10,7 @@ import type {
|
|||||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||||
import type Media from '@server/entity/Media';
|
import type Media from '@server/entity/Media';
|
||||||
|
|
||||||
export type MediaType = 'tv' | 'movie' | 'person';
|
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
|
|||||||
firstAirDate: string;
|
firstAirDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CollectionResult {
|
||||||
|
id: number;
|
||||||
|
mediaType: 'collection';
|
||||||
|
title: string;
|
||||||
|
originalTitle: string;
|
||||||
|
adult: boolean;
|
||||||
|
posterPath?: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
overview: string;
|
||||||
|
originalLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonResult {
|
export interface PersonResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -53,7 +66,7 @@ export interface PersonResult {
|
|||||||
knownFor: (MovieResult | TvResult)[];
|
knownFor: (MovieResult | TvResult)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Results = MovieResult | TvResult | PersonResult;
|
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
||||||
|
|
||||||
export const mapMovieResult = (
|
export const mapMovieResult = (
|
||||||
movieResult: TmdbMovieResult,
|
movieResult: TmdbMovieResult,
|
||||||
@@ -99,6 +112,20 @@ export const mapTvResult = (
|
|||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mapCollectionResult = (
|
||||||
|
collectionResult: TmdbCollectionResult
|
||||||
|
): CollectionResult => ({
|
||||||
|
id: collectionResult.id,
|
||||||
|
mediaType: collectionResult.media_type || 'collection',
|
||||||
|
adult: collectionResult.adult,
|
||||||
|
originalLanguage: collectionResult.original_language,
|
||||||
|
originalTitle: collectionResult.original_title,
|
||||||
|
title: collectionResult.title,
|
||||||
|
overview: collectionResult.overview,
|
||||||
|
backdropPath: collectionResult.backdrop_path,
|
||||||
|
posterPath: collectionResult.poster_path,
|
||||||
|
});
|
||||||
|
|
||||||
export const mapPersonResult = (
|
export const mapPersonResult = (
|
||||||
personResult: TmdbPersonResult
|
personResult: TmdbPersonResult
|
||||||
): PersonResult => ({
|
): PersonResult => ({
|
||||||
@@ -118,7 +145,12 @@ export const mapPersonResult = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const mapSearchResults = (
|
export const mapSearchResults = (
|
||||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
|
results: (
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
)[],
|
||||||
media?: Media[]
|
media?: Media[]
|
||||||
): Results[] =>
|
): Results[] =>
|
||||||
results.map((result) => {
|
results.map((result) => {
|
||||||
@@ -139,6 +171,8 @@ export const mapSearchResults = (
|
|||||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
case 'collection':
|
||||||
|
return mapCollectionResult(result);
|
||||||
default:
|
default:
|
||||||
return mapPersonResult(result);
|
return mapPersonResult(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import logger from '@server/logger';
|
|||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
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';
|
||||||
|
|
||||||
const authRoutes = Router();
|
const authRoutes = Router();
|
||||||
|
|
||||||
@@ -274,24 +275,87 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
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 Error('not_admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
settings.jellyfin.hostname = body.hostname ?? '';
|
||||||
|
settings.jellyfin.serverId = account.User.ServerId;
|
||||||
|
settings.save();
|
||||||
|
startJobs();
|
||||||
|
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
|
// User already exists, let's update their information
|
||||||
|
else if (body.username === user?.jellyfinUsername) {
|
||||||
|
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 +371,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
|
||||||
@@ -380,7 +413,7 @@ 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') {
|
if (e.message === 'Unauthorized') {
|
||||||
logger.info(
|
logger.warn(
|
||||||
'Failed login attempt from user with incorrect Jellyfin credentials',
|
'Failed login attempt from user with incorrect Jellyfin credentials',
|
||||||
{
|
{
|
||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
@@ -395,11 +428,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
status: 401,
|
status: 401,
|
||||||
message: 'Unauthorized',
|
message: 'Unauthorized',
|
||||||
});
|
});
|
||||||
|
} else if (e.message === 'not_admin') {
|
||||||
|
return next({
|
||||||
|
status: 403,
|
||||||
|
message: 'CREDENTIAL_ERROR_NOT_ADMIN',
|
||||||
|
});
|
||||||
} else if (e.message === 'add_email') {
|
} else if (e.message === 'add_email') {
|
||||||
return next({
|
return next({
|
||||||
status: 406,
|
status: 406,
|
||||||
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
|
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
|
||||||
});
|
});
|
||||||
|
} else if (e.message === 'select_server_type') {
|
||||||
|
return next({
|
||||||
|
status: 406,
|
||||||
|
message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error(e.message, { label: 'Auth' });
|
logger.error(e.message, { label: 'Auth' });
|
||||||
return next({
|
return next({
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapProductionCompany } from '@server/models/Movie';
|
import { mapProductionCompany } from '@server/models/Movie';
|
||||||
import {
|
import {
|
||||||
|
mapCollectionResult,
|
||||||
mapMovieResult,
|
mapMovieResult,
|
||||||
mapPersonResult,
|
mapPersonResult,
|
||||||
mapTvResult,
|
mapTvResult,
|
||||||
} from '@server/models/Search';
|
} from '@server/models/Search';
|
||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -65,6 +66,8 @@ const QueryFilterOptions = z.object({
|
|||||||
withRuntimeLte: z.coerce.string().optional(),
|
withRuntimeLte: z.coerce.string().optional(),
|
||||||
voteAverageGte: z.coerce.string().optional(),
|
voteAverageGte: z.coerce.string().optional(),
|
||||||
voteAverageLte: z.coerce.string().optional(),
|
voteAverageLte: z.coerce.string().optional(),
|
||||||
|
voteCountGte: z.coerce.string().optional(),
|
||||||
|
voteCountLte: z.coerce.string().optional(),
|
||||||
network: z.coerce.string().optional(),
|
network: z.coerce.string().optional(),
|
||||||
watchProviders: z.coerce.string().optional(),
|
watchProviders: z.coerce.string().optional(),
|
||||||
watchRegion: z.coerce.string().optional(),
|
watchRegion: z.coerce.string().optional(),
|
||||||
@@ -96,6 +99,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
|||||||
withRuntimeLte: query.withRuntimeLte,
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
voteAverageGte: query.voteAverageGte,
|
voteAverageGte: query.voteAverageGte,
|
||||||
voteAverageLte: query.voteAverageLte,
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
voteCountGte: query.voteCountGte,
|
||||||
|
voteCountLte: query.voteCountLte,
|
||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
@@ -376,6 +381,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
withRuntimeLte: query.withRuntimeLte,
|
withRuntimeLte: query.withRuntimeLte,
|
||||||
voteAverageGte: query.voteAverageGte,
|
voteAverageGte: query.voteAverageGte,
|
||||||
voteAverageLte: query.voteAverageLte,
|
voteAverageLte: query.voteAverageLte,
|
||||||
|
voteCountGte: query.voteCountGte,
|
||||||
|
voteCountLte: query.voteCountLte,
|
||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
});
|
});
|
||||||
@@ -659,6 +666,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
: isPerson(result)
|
: isPerson(result)
|
||||||
? mapPersonResult(result)
|
? mapPersonResult(result)
|
||||||
|
: isCollection(result)
|
||||||
|
? mapCollectionResult(result)
|
||||||
: mapTvResult(
|
: mapTvResult(
|
||||||
result,
|
result,
|
||||||
media.find(
|
media.find(
|
||||||
@@ -839,7 +848,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
|||||||
if (total) {
|
if (total) {
|
||||||
return res.json({
|
return res.json({
|
||||||
page: page,
|
page: page,
|
||||||
totalPages: total / itemsPerPage,
|
totalPages: Math.ceil(total / itemsPerPage),
|
||||||
totalResults: total,
|
totalResults: total,
|
||||||
results: result,
|
results: result,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import GithubAPI from '@server/api/github';
|
import GithubAPI from '@server/api/github';
|
||||||
|
import PushoverAPI from '@server/api/pushover';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import type {
|
import type {
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
@@ -113,6 +114,31 @@ router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
|
|||||||
|
|
||||||
return res.json(sliders);
|
return res.json(sliders);
|
||||||
});
|
});
|
||||||
|
router.get(
|
||||||
|
'/settings/notifications/pushover/sounds',
|
||||||
|
isAuthenticated(),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const pushoverApi = new PushoverAPI();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!req.query.token) {
|
||||||
|
throw new Error('Pushover application token missing from request');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sounds = await pushoverApi.getSounds(req.query.token as string);
|
||||||
|
res.status(200).json(sounds);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving Pushover sounds', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve Pushover sounds.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
||||||
router.use('/search', isAuthenticated(), searchRoutes);
|
router.use('/search', isAuthenticated(), searchRoutes);
|
||||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
|
||||||
|
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||||
|
import { type RatingResponse } from '@server/api/ratings';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -118,6 +120,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint backed by RottenTomatoes
|
||||||
|
*/
|
||||||
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const rtapi = new RottenTomatoes();
|
const rtapi = new RottenTomatoes();
|
||||||
@@ -153,4 +158,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint combining RottenTomatoes and IMDB
|
||||||
|
*/
|
||||||
|
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const rtapi = new RottenTomatoes();
|
||||||
|
const imdbApi = new IMDBRadarrProxy();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const movie = await tmdb.getMovie({
|
||||||
|
movieId: Number(req.params.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rtratings = await rtapi.getMovieRatings(
|
||||||
|
movie.title,
|
||||||
|
Number(movie.release_date.slice(0, 4))
|
||||||
|
);
|
||||||
|
|
||||||
|
let imdbRatings;
|
||||||
|
if (movie.imdb_id) {
|
||||||
|
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rtratings && !imdbRatings) {
|
||||||
|
return next({
|
||||||
|
status: 404,
|
||||||
|
message: 'No ratings found.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratings: RatingResponse = {
|
||||||
|
...(rtratings ? { rt: rtratings } : {}),
|
||||||
|
...(imdbRatings ? { imdb: imdbRatings } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).json(ratings);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving movie ratings', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
movieId: req.params.id,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve movie ratings.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default movieRoutes;
|
export default movieRoutes;
|
||||||
|
|||||||
@@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
|
|||||||
|
|
||||||
const sonarr = new SonarrAPI({
|
const sonarr = new SonarrAPI({
|
||||||
apiKey: sonarrSettings.apiKey,
|
apiKey: sonarrSettings.apiKey,
|
||||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||||
sonarrSettings.hostname
|
|
||||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ 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';
|
||||||
@@ -29,6 +29,7 @@ import { getAppVersion } from '@server/utils/appVersion';
|
|||||||
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';
|
||||||
@@ -337,7 +338,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 +346,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();
|
||||||
@@ -367,25 +368,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
|||||||
|
|
||||||
Object.assign(settings.tautulli, req.body);
|
Object.assign(settings.tautulli, req.body);
|
||||||
|
|
||||||
try {
|
if (settings.tautulli.hostname) {
|
||||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
try {
|
||||||
|
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||||
|
|
||||||
const result = await tautulliClient.getInfo();
|
const result = await tautulliClient.getInfo();
|
||||||
|
|
||||||
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
||||||
throw new Error('Tautulli version not supported');
|
throw new Error('Tautulli version not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.save();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong testing Tautulli connection', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to connect to Tautulli.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.save();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong testing Tautulli connection', {
|
|
||||||
label: 'API',
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
return next({
|
|
||||||
status: 500,
|
|
||||||
message: 'Unable to connect to Tautulli.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json(settings.tautulli);
|
return res.status(200).json(settings.tautulli);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
|||||||
@@ -383,7 +383,14 @@ router.delete<{ id: string }>(
|
|||||||
* we manually remove all requests from the user here so the parent media's
|
* we manually remove all requests from the user here so the parent media's
|
||||||
* properly reflect the change.
|
* properly reflect the change.
|
||||||
*/
|
*/
|
||||||
await requestRepository.remove(user.requests);
|
await requestRepository.remove(user.requests, {
|
||||||
|
/**
|
||||||
|
* Break-up into groups of 1000 requests to be removed at a time.
|
||||||
|
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
|
||||||
|
* https://typeorm.io/repository-api#additional-options
|
||||||
|
*/
|
||||||
|
chunk: user.requests.length / 1000,
|
||||||
|
});
|
||||||
|
|
||||||
await userRepository.delete(user.id);
|
await userRepository.delete(user.id);
|
||||||
return res.status(200).json(user.filter());
|
return res.status(200).json(user.filter());
|
||||||
@@ -530,7 +537,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -710,29 +720,31 @@ router.get<{ id: string }, WatchlistResponse>(
|
|||||||
|
|
||||||
const user = await getRepository(User).findOneOrFail({
|
const user = await getRepository(User).findOneOrFail({
|
||||||
where: { id: Number(req.params.id) },
|
where: { id: Number(req.params.id) },
|
||||||
select: { id: true, plexToken: true },
|
select: ['id', 'plexToken'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user?.plexToken) {
|
if (user) {
|
||||||
if (user) {
|
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
where: { requestedBy: { id: user?.id } },
|
||||||
where: { requestedBy: { id: user?.id } },
|
relations: {
|
||||||
relations: { requestedBy: true },
|
/*requestedBy: true,media:true*/
|
||||||
// loadRelationIds: true,
|
},
|
||||||
take: itemsPerPage,
|
// loadRelationIds: true,
|
||||||
skip: offset,
|
take: itemsPerPage,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
if (total) {
|
||||||
|
return res.json({
|
||||||
|
page: page,
|
||||||
|
totalPages: Math.ceil(total / itemsPerPage),
|
||||||
|
totalResults: total,
|
||||||
|
results: result,
|
||||||
});
|
});
|
||||||
if (total) {
|
|
||||||
return res.json({
|
|
||||||
page: page,
|
|
||||||
totalPages: total / itemsPerPage,
|
|
||||||
totalResults: total,
|
|
||||||
results: result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We will just return an empty array if the user has no Plex token
|
// We will just return an empty array if the user has no Plex token
|
||||||
|
if (!user.plexToken) {
|
||||||
return res.json({
|
return res.json({
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
emailEnabled: settings?.email.enabled,
|
emailEnabled: settings.email.enabled,
|
||||||
pgpKey: user.settings?.pgpKey,
|
pgpKey: user.settings?.pgpKey,
|
||||||
discordEnabled:
|
discordEnabled:
|
||||||
settings?.discord.enabled && settings.discord.options.enableMentions,
|
settings?.discord.enabled && settings.discord.options.enableMentions,
|
||||||
@@ -277,11 +277,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
|||||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||||
telegramEnabled: settings?.telegram.enabled,
|
pushoverSound: user.settings?.pushoverSound,
|
||||||
telegramBotUsername: settings?.telegram.options.botUsername,
|
telegramEnabled: settings.telegram.enabled,
|
||||||
|
telegramBotUsername: settings.telegram.options.botUsername,
|
||||||
telegramChatId: user.settings?.telegramChatId,
|
telegramChatId: user.settings?.telegramChatId,
|
||||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
telegramSendSilently: user.settings?.telegramSendSilently,
|
||||||
webPushEnabled: settings?.webpush.enabled,
|
webPushEnabled: settings.webpush.enabled,
|
||||||
notificationTypes: user.settings?.notificationTypes ?? {},
|
notificationTypes: user.settings?.notificationTypes ?? {},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -332,6 +333,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
|||||||
user.settings.pushoverApplicationToken =
|
user.settings.pushoverApplicationToken =
|
||||||
req.body.pushoverApplicationToken;
|
req.body.pushoverApplicationToken;
|
||||||
user.settings.pushoverUserKey = req.body.pushoverUserKey;
|
user.settings.pushoverUserKey = req.body.pushoverUserKey;
|
||||||
|
user.settings.pushoverSound = req.body.pushoverSound;
|
||||||
user.settings.telegramChatId = req.body.telegramChatId;
|
user.settings.telegramChatId = req.body.telegramChatId;
|
||||||
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
||||||
user.settings.notificationTypes = Object.assign(
|
user.settings.notificationTypes = Object.assign(
|
||||||
@@ -344,13 +346,14 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
|||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pgpKey: user.settings?.pgpKey,
|
pgpKey: user.settings.pgpKey,
|
||||||
discordId: user.settings?.discordId,
|
discordId: user.settings.discordId,
|
||||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
pushbulletAccessToken: user.settings.pushbulletAccessToken,
|
||||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
pushoverApplicationToken: user.settings.pushoverApplicationToken,
|
||||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
pushoverUserKey: user.settings.pushoverUserKey,
|
||||||
telegramChatId: user.settings?.telegramChatId,
|
pushoverSound: user.settings.pushoverSound,
|
||||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
telegramChatId: user.settings.telegramChatId,
|
||||||
|
telegramSendSilently: user.settings.telegramSendSilently,
|
||||||
notificationTypes: user.settings.notificationTypes,
|
notificationTypes: user.settings.notificationTypes,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
TmdbCollectionResult,
|
||||||
TmdbMovieDetails,
|
TmdbMovieDetails,
|
||||||
TmdbMovieResult,
|
TmdbMovieResult,
|
||||||
TmdbPersonDetails,
|
TmdbPersonDetails,
|
||||||
@@ -8,17 +9,35 @@ import type {
|
|||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
|
||||||
export const isMovie = (
|
export const isMovie = (
|
||||||
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
movie:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
): movie is TmdbMovieResult => {
|
): movie is TmdbMovieResult => {
|
||||||
return (movie as TmdbMovieResult).title !== undefined;
|
return (movie as TmdbMovieResult).title !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPerson = (
|
export const isPerson = (
|
||||||
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
person:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
): person is TmdbPersonResult => {
|
): person is TmdbPersonResult => {
|
||||||
return (person as TmdbPersonResult).known_for !== undefined;
|
return (person as TmdbPersonResult).known_for !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isCollection = (
|
||||||
|
collection:
|
||||||
|
| TmdbMovieResult
|
||||||
|
| TmdbTvResult
|
||||||
|
| TmdbPersonResult
|
||||||
|
| TmdbCollectionResult
|
||||||
|
): collection is TmdbCollectionResult => {
|
||||||
|
return (collection as TmdbCollectionResult).media_type === 'collection';
|
||||||
|
};
|
||||||
|
|
||||||
export const isMovieDetails = (
|
export const isMovieDetails = (
|
||||||
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
||||||
): movie is TmdbMovieDetails => {
|
): movie is TmdbMovieDetails => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
Jellyseerr is a free and open source software application for managing requests for your media library.
|
Jellyseerr is a free and open source software application for managing requests for your media library.
|
||||||
It is a a fork of Overseerr built to bring support for & focusing mainly on Jellyfin & Emby media servers!
|
It is a a fork of Overseerr built to bring support for & focusing mainly on Jellyfin & Emby media servers!
|
||||||
It integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex.
|
It integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex.
|
||||||
base: core18
|
base: core20
|
||||||
confinement: strict
|
confinement: strict
|
||||||
|
|
||||||
architectures:
|
architectures:
|
||||||
@@ -16,12 +16,12 @@ architectures:
|
|||||||
|
|
||||||
parts:
|
parts:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
plugin: nodejs
|
plugin: nil
|
||||||
nodejs-version: '16.17.0'
|
|
||||||
nodejs-package-manager: 'yarn'
|
|
||||||
nodejs-yarn-version: v1.22.17
|
|
||||||
build-packages:
|
build-packages:
|
||||||
- git
|
- git
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- gnupg
|
||||||
- on arm64:
|
- on arm64:
|
||||||
- build-essential
|
- build-essential
|
||||||
- automake
|
- automake
|
||||||
@@ -37,7 +37,7 @@ parts:
|
|||||||
override-pull: |
|
override-pull: |
|
||||||
snapcraftctl pull
|
snapcraftctl pull
|
||||||
# Get information to determine snap grade and version
|
# Get information to determine snap grade and version
|
||||||
git config --global --add safe.directory /data/parts/jellyyseerr/src
|
git config --global --add safe.directory /data/parts/jellyseerr/src
|
||||||
#setup yarn.rc
|
#setup yarn.rc
|
||||||
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
|
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
|
||||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
@@ -65,13 +65,30 @@ parts:
|
|||||||
snapcraftctl set-version "$SNAP_VERSION"
|
snapcraftctl set-version "$SNAP_VERSION"
|
||||||
snapcraftctl set-grade "$GRADE"
|
snapcraftctl set-grade "$GRADE"
|
||||||
build-environment:
|
build-environment:
|
||||||
- PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH'
|
- PATH: '$SNAPCRAFT_PART_BUILD/node_modules/.bin:$PATH'
|
||||||
- CYPRESS_INSTALL_BINARY: '0'
|
- CYPRESS_INSTALL_BINARY: '0'
|
||||||
override-build: |
|
override-build: |
|
||||||
set -e
|
set -e
|
||||||
|
# Install necessary packages
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
# Add Node.js repository key
|
||||||
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
|
||||||
|
# Set Node.js version
|
||||||
|
NODE_MAJOR=18
|
||||||
|
# Add Node.js repository to sources list
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
|
# Update package sources and install Node.js
|
||||||
|
apt-get update
|
||||||
|
apt-get install nodejs -y
|
||||||
|
|
||||||
|
# Install Yarn
|
||||||
|
npm install -g yarn
|
||||||
# Set COMMIT_TAG before the build begins
|
# Set COMMIT_TAG before the build begins
|
||||||
export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt)
|
export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt)
|
||||||
snapcraftctl build
|
snapcraftctl build
|
||||||
|
yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
yarn build
|
yarn build
|
||||||
# Copy files needed for staging
|
# Copy files needed for staging
|
||||||
cp $SNAPCRAFT_PART_BUILD/committag.json $SNAPCRAFT_PART_INSTALL/
|
cp $SNAPCRAFT_PART_BUILD/committag.json $SNAPCRAFT_PART_INSTALL/
|
||||||
@@ -79,7 +96,7 @@ parts:
|
|||||||
cp -R $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/
|
cp -R $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/
|
||||||
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
|
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
|
||||||
# Remove .github and gitbook as it will fail snap lint
|
# Remove .github and gitbook as it will fail snap lint
|
||||||
rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml
|
rm -rf $SNAPCRAFT_PART_INSTALL/.github
|
||||||
stage-packages:
|
stage-packages:
|
||||||
- on armhf:
|
- on armhf:
|
||||||
- libatomic1
|
- libatomic1
|
||||||
|
|||||||
46
src/assets/services/emby.svg
Normal file
46
src/assets/services/emby.svg
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg2"
|
||||||
|
viewBox="0 0 712.60077 712.5481"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs4" />
|
||||||
|
<metadata
|
||||||
|
id="metadata7">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<rect
|
||||||
|
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||||
|
id="rect249"
|
||||||
|
width="712.60077"
|
||||||
|
height="712.5481"
|
||||||
|
x="-0.00071160076"
|
||||||
|
y="2.0223413e-11" />
|
||||||
|
<rect
|
||||||
|
style="fill:#ffffff"
|
||||||
|
id="rect289"
|
||||||
|
width="230.18982"
|
||||||
|
height="229.82355"
|
||||||
|
x="241.20476"
|
||||||
|
y="241.36227" />
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||||
|
<path
|
||||||
|
id="path3427"
|
||||||
|
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
|
||||||
|
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
20
src/assets/services/letterboxd.svg
Normal file
20
src/assets/services/letterboxd.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
@@ -349,7 +349,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<div className="pb-8" />
|
<div className="extra-bottom-space relative" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const Badge = (
|
|||||||
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
|
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
|
||||||
);
|
);
|
||||||
if (href) {
|
if (href) {
|
||||||
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
|
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type {
|
import type {
|
||||||
|
CollectionResult,
|
||||||
MovieResult,
|
MovieResult,
|
||||||
PersonResult,
|
PersonResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
@@ -12,12 +13,13 @@ import type {
|
|||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
type ListViewProps = {
|
type ListViewProps = {
|
||||||
items?: (TvResult | MovieResult | PersonResult)[];
|
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||||
plexItems?: WatchlistItem[];
|
plexItems?: WatchlistItem[];
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isReachingEnd?: boolean;
|
isReachingEnd?: boolean;
|
||||||
onScrollBottom: () => void;
|
onScrollBottom: () => void;
|
||||||
|
mutateParent?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListView = ({
|
const ListView = ({
|
||||||
@@ -27,6 +29,7 @@ const ListView = ({
|
|||||||
onScrollBottom,
|
onScrollBottom,
|
||||||
isReachingEnd,
|
isReachingEnd,
|
||||||
plexItems,
|
plexItems,
|
||||||
|
mutateParent,
|
||||||
}: ListViewProps) => {
|
}: ListViewProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||||
@@ -45,7 +48,9 @@ const ListView = ({
|
|||||||
id={title.tmdbId}
|
id={title.tmdbId}
|
||||||
tmdbId={title.tmdbId}
|
tmdbId={title.tmdbId}
|
||||||
type={title.mediaType}
|
type={title.mediaType}
|
||||||
|
isAddedToWatchlist={true}
|
||||||
canExpand
|
canExpand
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -94,6 +99,18 @@ const ListView = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'collection':
|
||||||
|
titleCard = (
|
||||||
|
<TitleCard
|
||||||
|
id={title.id}
|
||||||
|
image={title.posterPath}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.title}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
canExpand
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case 'person':
|
case 'person':
|
||||||
titleCard = (
|
titleCard = (
|
||||||
<PersonCard
|
<PersonCard
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
|||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import { sliderTitles } from '@app/components/Discover/constants';
|
import { sliderTitles } from '@app/components/Discover/constants';
|
||||||
import MediaSlider from '@app/components/MediaSlider';
|
import MediaSlider from '@app/components/MediaSlider';
|
||||||
|
import { WatchProviderSelector } from '@app/components/Selector';
|
||||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
import type {
|
import type {
|
||||||
TmdbCompanySearchResponse,
|
TmdbCompanySearchResponse,
|
||||||
@@ -55,7 +56,7 @@ type CreateOption = {
|
|||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
params?: string;
|
params?: string;
|
||||||
titlePlaceholderText: string;
|
titlePlaceholderText: string;
|
||||||
dataPlaceholderText: string;
|
dataPlaceholderText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||||
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
|
||||||
|
dataUrl: '/api/v1/discover/movies',
|
||||||
|
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
|
||||||
|
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
|
||||||
|
dataUrl: '/api/v1/discover/tv',
|
||||||
|
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||||
|
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||||
|
dataInput = (
|
||||||
|
<WatchProviderSelector
|
||||||
|
type={'movie'}
|
||||||
|
region={slider?.data?.split(',')[0]}
|
||||||
|
activeProviders={
|
||||||
|
slider?.data
|
||||||
|
?.split(',')[1]
|
||||||
|
.split('|')
|
||||||
|
.map((v) => Number(v)) ?? []
|
||||||
|
}
|
||||||
|
onChange={(region, providers) => {
|
||||||
|
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||||
|
dataInput = (
|
||||||
|
<WatchProviderSelector
|
||||||
|
type={'tv'}
|
||||||
|
region={slider?.data?.split(',')[0]}
|
||||||
|
activeProviders={
|
||||||
|
slider?.data
|
||||||
|
?.split(',')[1]
|
||||||
|
.split('|')
|
||||||
|
.map((v) => Number(v)) ?? []
|
||||||
|
}
|
||||||
|
onChange={(region, providers) => {
|
||||||
|
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
dataInput = (
|
dataInput = (
|
||||||
<Field
|
<Field
|
||||||
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
'$value',
|
'$value',
|
||||||
encodeURIExtraParams(values.data)
|
encodeURIExtraParams(values.data)
|
||||||
)}
|
)}
|
||||||
extraParams={activeOption.params?.replace(
|
extraParams={
|
||||||
'$value',
|
activeOption.type ===
|
||||||
encodeURIExtraParams(values.data)
|
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
|
||||||
)}
|
activeOption.type ===
|
||||||
|
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
|
||||||
|
? activeOption.params
|
||||||
|
?.replace(
|
||||||
|
'$regionValue',
|
||||||
|
encodeURIExtraParams(values?.data.split(',')[0])
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'$providersValue',
|
||||||
|
encodeURIExtraParams(values?.data.split(',')[1])
|
||||||
|
)
|
||||||
|
: activeOption.params?.replace(
|
||||||
|
'$value',
|
||||||
|
encodeURIExtraParams(values.data)
|
||||||
|
)
|
||||||
|
}
|
||||||
onNewTitles={updateResultCount}
|
onNewTitles={updateResultCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
|
|||||||
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||||
case DiscoverSliderType.TMDB_SEARCH:
|
case DiscoverSliderType.TMDB_SEARCH:
|
||||||
return intl.formatMessage(sliderTitles.tmdbsearch);
|
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
||||||
|
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||||
|
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
||||||
default:
|
default:
|
||||||
return 'Unknown Slider';
|
return 'Unknown Slider';
|
||||||
}
|
}
|
||||||
@@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
|
|||||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||||
>
|
>
|
||||||
<Bars3Icon className="h-6 w-6" />
|
<Bars3Icon className="h-6 w-6" />
|
||||||
<div>{getSliderTitle(slider)}</div>
|
<div className="w-7/12 truncate md:w-full">
|
||||||
|
{getSliderTitle(slider)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none ${
|
className={`pointer-events-none ${
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const DiscoverWatchlist = () => {
|
|||||||
titles,
|
titles,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
error,
|
error,
|
||||||
|
mutate,
|
||||||
} = useDiscover<WatchlistItem>(
|
} = useDiscover<WatchlistItem>(
|
||||||
`/api/v1/${
|
`/api/v1/${
|
||||||
router.pathname.startsWith('/profile')
|
router.pathname.startsWith('/profile')
|
||||||
@@ -76,6 +77,7 @@ const DiscoverWatchlist = () => {
|
|||||||
}
|
}
|
||||||
isReachingEnd={isReachingEnd}
|
isReachingEnd={isReachingEnd}
|
||||||
onScrollBottom={fetchMore}
|
onScrollBottom={fetchMore}
|
||||||
|
mutateParent={mutate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ const messages = defineMessages({
|
|||||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||||
clearfilters: 'Clear Active Filters',
|
clearfilters: 'Clear Active Filters',
|
||||||
tmdbuserscore: 'TMDB User Score',
|
tmdbuserscore: 'TMDB User Score',
|
||||||
|
tmdbuservotecount: 'TMDB User Vote Count',
|
||||||
runtime: 'Runtime',
|
runtime: 'Runtime',
|
||||||
streamingservices: 'Streaming Services',
|
streamingservices: 'Streaming Services',
|
||||||
|
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||||
});
|
});
|
||||||
|
|
||||||
type FilterSlideoverProps = {
|
type FilterSlideoverProps = {
|
||||||
@@ -246,6 +248,45 @@ const FilterSlideover = ({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{intl.formatMessage(messages.tmdbuservotecount)}
|
||||||
|
</span>
|
||||||
|
<div className="relative z-0">
|
||||||
|
<MultiRangeSlider
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
defaultMaxValue={
|
||||||
|
currentFilters.voteCountLte
|
||||||
|
? Number(currentFilters.voteCountLte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultMinValue={
|
||||||
|
currentFilters.voteCountGte
|
||||||
|
? Number(currentFilters.voteCountGte)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUpdateMin={(min) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'voteCountGte',
|
||||||
|
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
|
||||||
|
? min.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onUpdateMax={(max) => {
|
||||||
|
updateQueryParams(
|
||||||
|
'voteCountLte',
|
||||||
|
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
|
||||||
|
? max.toString()
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
subText={intl.formatMessage(messages.voteCount, {
|
||||||
|
minValue: currentFilters.voteCountGte ?? 0,
|
||||||
|
maxValue: currentFilters.voteCountLte ?? 1000,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className="text-lg font-semibold">
|
<span className="text-lg font-semibold">
|
||||||
{intl.formatMessage(messages.streamingservices)}
|
{intl.formatMessage(messages.streamingservices)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ const networks: Network[] = [
|
|||||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||||
url: '/discover/tv/network/13',
|
url: '/discover/tv/network/13',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Peacock',
|
||||||
|
image:
|
||||||
|
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gIAcGTjKKr0KOHL5s4O36roJ8p7.png',
|
||||||
|
url: '/discover/tv/network/3353',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const NetworkSlider = () => {
|
const NetworkSlider = () => {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
|
|||||||
tmdbnetwork: 'TMDB Network',
|
tmdbnetwork: 'TMDB Network',
|
||||||
tmdbstudio: 'TMDB Studio',
|
tmdbstudio: 'TMDB Studio',
|
||||||
tmdbsearch: 'TMDB Search',
|
tmdbsearch: 'TMDB Search',
|
||||||
|
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||||
|
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const QueryFilterOptions = z.object({
|
export const QueryFilterOptions = z.object({
|
||||||
@@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
|
|||||||
withRuntimeLte: z.string().optional(),
|
withRuntimeLte: z.string().optional(),
|
||||||
voteAverageGte: z.string().optional(),
|
voteAverageGte: z.string().optional(),
|
||||||
voteAverageLte: z.string().optional(),
|
voteAverageLte: z.string().optional(),
|
||||||
|
voteCountLte: z.string().optional(),
|
||||||
|
voteCountGte: z.string().optional(),
|
||||||
watchRegion: z.string().optional(),
|
watchRegion: z.string().optional(),
|
||||||
watchProviders: z.string().optional(),
|
watchProviders: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -167,6 +171,14 @@ export const prepareFilterValues = (
|
|||||||
filterValues.voteAverageLte = values.voteAverageLte;
|
filterValues.voteAverageLte = values.voteAverageLte;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.voteCountGte) {
|
||||||
|
filterValues.voteCountGte = values.voteCountGte;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.voteCountLte) {
|
||||||
|
filterValues.voteCountLte = values.voteCountLte;
|
||||||
|
}
|
||||||
|
|
||||||
if (values.watchProviders) {
|
if (values.watchProviders) {
|
||||||
filterValues.watchProviders = values.watchProviders;
|
filterValues.watchProviders = values.watchProviders;
|
||||||
}
|
}
|
||||||
@@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
|||||||
delete clonedFilters.voteAverageLte;
|
delete clonedFilters.voteAverageLte;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
|
||||||
|
totalCount += 1;
|
||||||
|
delete clonedFilters.voteCountGte;
|
||||||
|
delete clonedFilters.voteCountLte;
|
||||||
|
}
|
||||||
|
|
||||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||||
totalCount += 1;
|
totalCount += 1;
|
||||||
delete clonedFilters.withRuntimeGte;
|
delete clonedFilters.withRuntimeGte;
|
||||||
|
|||||||
@@ -365,6 +365,36 @@ const Discover = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url="/api/v1/discover/movies"
|
||||||
|
extraParams={`watchRegion=${
|
||||||
|
slider.data?.split(',')[0]
|
||||||
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||||
|
linkUrl={`/discover/movies?watchRegion=${
|
||||||
|
slider.data?.split(',')[0]
|
||||||
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||||
|
sliderComponent = (
|
||||||
|
<MediaSlider
|
||||||
|
sliderKey={`custom-slider-${slider.id}`}
|
||||||
|
title={slider.title ?? ''}
|
||||||
|
url="/api/v1/discover/tv"
|
||||||
|
extraParams={`watchRegion=${
|
||||||
|
slider.data?.split(',')[0]
|
||||||
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||||
|
linkUrl={`/discover/tv?watchRegion=${
|
||||||
|
slider.data?.split(',')[0]
|
||||||
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import EmbyLogo from '@app/assets/services/emby.svg';
|
||||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
||||||
|
import LetterboxdLogo from '@app/assets/services/letterboxd.svg';
|
||||||
import PlexLogo from '@app/assets/services/plex.svg';
|
import PlexLogo from '@app/assets/services/plex.svg';
|
||||||
import RTLogo from '@app/assets/services/rt.svg';
|
import RTLogo from '@app/assets/services/rt.svg';
|
||||||
import TmdbLogo from '@app/assets/services/tmdb.svg';
|
import TmdbLogo from '@app/assets/services/tmdb.svg';
|
||||||
@@ -9,6 +11,7 @@ import useLocale from '@app/hooks/useLocale';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import getConfig from 'next/config';
|
||||||
|
|
||||||
interface ExternalLinkBlockProps {
|
interface ExternalLinkBlockProps {
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
@@ -28,6 +31,7 @@ const ExternalLinkBlock = ({
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
}: ExternalLinkBlockProps) => {
|
}: ExternalLinkBlockProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const { publicRuntimeConfig } = getConfig();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +45,8 @@ const ExternalLinkBlock = ({
|
|||||||
>
|
>
|
||||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||||
<PlexLogo />
|
<PlexLogo />
|
||||||
|
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||||
|
<EmbyLogo />
|
||||||
) : (
|
) : (
|
||||||
<JellyfinLogo />
|
<JellyfinLogo />
|
||||||
)}
|
)}
|
||||||
@@ -98,6 +104,16 @@ const ExternalLinkBlock = ({
|
|||||||
<TraktLogo />
|
<TraktLogo />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{tmdbId && mediaType === MediaType.MOVIE && (
|
||||||
|
<a
|
||||||
|
href={`https://letterboxd.com/tmdb/${tmdbId}`}
|
||||||
|
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<LetterboxdLogo />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const PullToRefresh = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [pullStartPoint, setPullStartPoint] = useState(0);
|
||||||
|
const [pullChange, setPullChange] = useState(0);
|
||||||
|
const refreshDiv = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Various pull down thresholds that determine icon location
|
||||||
|
const pullDownInitThreshold = pullChange > 20;
|
||||||
|
const pullDownStopThreshold = 120;
|
||||||
|
const pullDownReloadThreshold = pullChange > 340;
|
||||||
|
const pullDownIconLocation = pullChange / 3;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reload function that is called when reload threshold has been hit
|
||||||
|
// Add loading class to determine when to add spin animation
|
||||||
|
const forceReload = () => {
|
||||||
|
refreshDiv.current?.classList.add('loading');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.reload();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = document.querySelector('html');
|
||||||
|
|
||||||
|
// Determines if we are at the top of the page
|
||||||
|
// Locks or unlocks page when pulling down to refresh
|
||||||
|
const pullStart = (e: TouchEvent) => {
|
||||||
|
setPullStartPoint(e.targetTouches[0].screenY);
|
||||||
|
|
||||||
|
if (window.scrollY === 0 && window.scrollX === 0) {
|
||||||
|
refreshDiv.current?.classList.add('block');
|
||||||
|
refreshDiv.current?.classList.remove('hidden');
|
||||||
|
document.body.style.touchAction = 'none';
|
||||||
|
document.body.style.overscrollBehavior = 'none';
|
||||||
|
if (html) {
|
||||||
|
html.style.overscrollBehaviorY = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refreshDiv.current?.classList.remove('block');
|
||||||
|
refreshDiv.current?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tracks how far we have pulled down the refresh icon
|
||||||
|
const pullDown = async (e: TouchEvent) => {
|
||||||
|
const screenY = e.targetTouches[0].screenY;
|
||||||
|
|
||||||
|
const pullLength =
|
||||||
|
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
|
||||||
|
|
||||||
|
setPullChange(pullLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Will reload the page if we are past the threshold
|
||||||
|
// Otherwise, we reset the pull
|
||||||
|
const pullFinish = () => {
|
||||||
|
setPullStartPoint(0);
|
||||||
|
|
||||||
|
if (pullDownReloadThreshold) {
|
||||||
|
forceReload();
|
||||||
|
} else {
|
||||||
|
setPullChange(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.style.touchAction = 'auto';
|
||||||
|
document.body.style.overscrollBehaviorY = 'auto';
|
||||||
|
if (html) {
|
||||||
|
html.style.overscrollBehaviorY = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('touchstart', pullStart, { passive: false });
|
||||||
|
window.addEventListener('touchmove', pullDown, { passive: false });
|
||||||
|
window.addEventListener('touchend', pullFinish, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('touchstart', pullStart);
|
||||||
|
window.removeEventListener('touchmove', pullDown);
|
||||||
|
window.removeEventListener('touchend', pullFinish);
|
||||||
|
};
|
||||||
|
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={refreshDiv}
|
||||||
|
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
|
||||||
|
id="refreshIcon"
|
||||||
|
style={{
|
||||||
|
top:
|
||||||
|
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
|
||||||
|
? pullDownIconLocation
|
||||||
|
: pullDownInitThreshold
|
||||||
|
? pullDownStopThreshold
|
||||||
|
: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
|
||||||
|
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
|
||||||
|
style={{ animationDirection: 'reverse' }}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon
|
||||||
|
className={`rounded-full ${
|
||||||
|
pullDownReloadThreshold && 'rotate-180'
|
||||||
|
} text-indigo-500 transition-all duration-300`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PullToRefresh;
|
||||||
@@ -72,9 +72,7 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
{
|
{
|
||||||
href: '/issues',
|
href: '/issues',
|
||||||
messagesKey: 'issues',
|
messagesKey: 'issues',
|
||||||
svgIcon: (
|
svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
|
||||||
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
|
|
||||||
),
|
|
||||||
activeRegExp: /^\/issues/,
|
activeRegExp: /^\/issues/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
Permission.MANAGE_ISSUES,
|
Permission.MANAGE_ISSUES,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
streamdevelop: 'Overseerr Develop',
|
streamdevelop: 'Jellyseerr Develop',
|
||||||
streamstable: 'Overseerr Stable',
|
streamstable: 'Jellyseerr Stable',
|
||||||
outofdate: 'Out of Date',
|
outofdate: 'Out of Date',
|
||||||
commitsbehind:
|
commitsbehind:
|
||||||
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
|
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import MobileMenu from '@app/components/Layout/MobileMenu';
|
import MobileMenu from '@app/components/Layout/MobileMenu';
|
||||||
|
import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||||
import SearchInput from '@app/components/Layout/SearchInput';
|
import SearchInput from '@app/components/Layout/SearchInput';
|
||||||
import Sidebar from '@app/components/Layout/Sidebar';
|
import Sidebar from '@app/components/Layout/Sidebar';
|
||||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||||
import PullToRefresh from '@app/components/PullToRefresh';
|
|
||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import getConfig from 'next/config';
|
import getConfig from 'next/config';
|
||||||
@@ -13,6 +15,8 @@ const messages = defineMessages({
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
host: '{mediaServerName} URL',
|
host: '{mediaServerName} URL',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
|
emailtooltip:
|
||||||
|
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||||
validationhostrequired: '{mediaServerName} URL required',
|
validationhostrequired: '{mediaServerName} URL required',
|
||||||
validationhostformat: 'Valid URL required',
|
validationhostformat: 'Valid URL required',
|
||||||
validationemailrequired: 'Email required',
|
validationemailrequired: 'Email required',
|
||||||
@@ -20,6 +24,7 @@ const messages = defineMessages({
|
|||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
credentialerror: 'The username or password is incorrect.',
|
credentialerror: 'The username or password is incorrect.',
|
||||||
signingin: 'Signing in…',
|
signingin: 'Signing in…',
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
@@ -63,6 +68,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
),
|
),
|
||||||
password: Yup.string(),
|
password: Yup.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mediaServerFormatValues = {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@@ -85,6 +95,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
e.message == 'Request failed with status code 401'
|
e.message == 'Request failed with status code 401'
|
||||||
? messages.credentialerror
|
? messages.credentialerror
|
||||||
|
: e.message == 'Request failed with status code 403'
|
||||||
|
? messages.adminerror
|
||||||
: messages.loginerror
|
: messages.loginerror
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@@ -101,12 +113,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
<Form>
|
<Form>
|
||||||
<div className="sm:border-t sm:border-gray-800">
|
<div className="sm:border-t sm:border-gray-800">
|
||||||
<label htmlFor="host" className="text-label">
|
<label htmlFor="host" className="text-label">
|
||||||
{intl.formatMessage(messages.host, {
|
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
||||||
mediaServerName:
|
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
||||||
? 'Emby'
|
|
||||||
: 'Jellyfin',
|
|
||||||
})}
|
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
@@ -114,20 +121,34 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
id="host"
|
id="host"
|
||||||
name="host"
|
name="host"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(messages.host, {
|
placeholder={intl.formatMessage(
|
||||||
mediaServerName:
|
messages.host,
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
mediaServerFormatValues
|
||||||
? 'Emby'
|
)}
|
||||||
: 'Jellyfin',
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.host && touched.host && (
|
{errors.host && touched.host && (
|
||||||
<div className="error">{errors.host}</div>
|
<div className="error">{errors.host}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label htmlFor="email" className="text-label">
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="text-label"
|
||||||
|
style={{ display: 'inline-flex' }}
|
||||||
|
>
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
|
<span className="label-tip">
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(
|
||||||
|
messages.emailtooltip,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="tooltip-trigger">
|
||||||
|
<InformationCircleIcon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
@@ -201,6 +222,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
),
|
),
|
||||||
password: Yup.string(),
|
password: Yup.string(),
|
||||||
});
|
});
|
||||||
|
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||||
|
? settings.currentSettings.jellyfinExternalHost
|
||||||
|
: settings.currentSettings.jellyfinHost;
|
||||||
|
const jellyfinForgotPasswordUrl =
|
||||||
|
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Formik
|
<Formik
|
||||||
@@ -278,11 +304,13 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
as="a"
|
as="a"
|
||||||
buttonType="ghost"
|
buttonType="ghost"
|
||||||
href={
|
href={
|
||||||
process.env.JELLYFIN_TYPE == 'emby'
|
jellyfinForgotPasswordUrl
|
||||||
? settings.currentSettings.jellyfinHost +
|
? `${jellyfinForgotPasswordUrl}`
|
||||||
'/web/index.html#!/startup/forgotpassword.html'
|
: `${baseUrl}/web/index.html#!/${
|
||||||
: settings.currentSettings.jellyfinHost +
|
process.env.JELLYFIN_TYPE === 'emby'
|
||||||
'/web/index.html#!/forgotpassword.html'
|
? 'startup/'
|
||||||
|
: ''
|
||||||
|
}forgotpassword.html`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.forgotpassword)}
|
{intl.formatMessage(messages.forgotpassword)}
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ const ManageSlideOver = ({
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const { data: radarrData } = useSWR<RadarrSettings[]>(
|
const { data: radarrData } = useSWR<RadarrSettings[]>(
|
||||||
'/api/v1/settings/radarr'
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null
|
||||||
);
|
);
|
||||||
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
||||||
'/api/v1/settings/sonarr'
|
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMedia = async () => {
|
const deleteMedia = async () => {
|
||||||
@@ -330,11 +330,16 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
>
|
>
|
||||||
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
||||||
<img
|
<Tooltip
|
||||||
src={user.avatar}
|
key={`watch-user-${user.id}`}
|
||||||
alt={user.displayName}
|
content={user.displayName}
|
||||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
>
|
||||||
/>
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -485,11 +490,16 @@ const ManageSlideOver = ({
|
|||||||
key={`watch-user-${user.id}`}
|
key={`watch-user-${user.id}`}
|
||||||
>
|
>
|
||||||
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
||||||
<img
|
<Tooltip
|
||||||
src={user.avatar}
|
key={`watch-user-${user.id}`}
|
||||||
alt={user.displayName}
|
content={user.displayName}
|
||||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
>
|
||||||
/>
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
|
|||||||
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||||
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
@@ -40,7 +41,7 @@ import {
|
|||||||
ChevronDoubleDownIcon,
|
ChevronDoubleDownIcon,
|
||||||
ChevronDoubleUpIcon,
|
ChevronDoubleUpIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type { RTRating } from '@server/api/rottentomatoes';
|
import { type RatingResponse } from '@server/api/ratings';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
@@ -91,6 +92,7 @@ const messages = defineMessages({
|
|||||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||||
tmdbuserscore: 'TMDB User Score',
|
tmdbuserscore: 'TMDB User Score',
|
||||||
|
imdbuserscore: 'IMDB User Score',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MovieDetailsProps {
|
interface MovieDetailsProps {
|
||||||
@@ -126,8 +128,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ratingData } = useSWR<RTRating>(
|
const { data: ratingData } = useSWR<RatingResponse>(
|
||||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
`/api/v1/movie/${router.query.movieId}/ratingscombined`
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedCrew = useMemo(
|
const sortedCrew = useMemo(
|
||||||
@@ -541,44 +543,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
)}
|
)}
|
||||||
<div className="media-facts">
|
<div className="media-facts">
|
||||||
{(!!data.voteCount ||
|
{(!!data.voteCount ||
|
||||||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
(ratingData?.rt?.criticsRating &&
|
||||||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
!!ratingData?.rt?.criticsScore) ||
|
||||||
|
(ratingData?.rt?.audienceRating &&
|
||||||
|
!!ratingData?.rt?.audienceScore) ||
|
||||||
|
ratingData?.imdb?.criticsScore) && (
|
||||||
<div className="media-ratings">
|
<div className="media-ratings">
|
||||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
{ratingData?.rt?.criticsRating &&
|
||||||
<Tooltip
|
!!ratingData?.rt?.criticsScore && (
|
||||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
<Tooltip
|
||||||
>
|
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={ratingData.rt.url}
|
||||||
|
className="media-rating"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{ratingData.rt.criticsRating === 'Rotten' ? (
|
||||||
|
<RTRotten className="w-6" />
|
||||||
|
) : (
|
||||||
|
<RTFresh className="w-6" />
|
||||||
|
)}
|
||||||
|
<span>{ratingData.rt.criticsScore}%</span>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{ratingData?.rt?.audienceRating &&
|
||||||
|
!!ratingData?.rt?.audienceScore && (
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.rtaudiencescore)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={ratingData.rt.url}
|
||||||
|
className="media-rating"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{ratingData.rt.audienceRating === 'Spilled' ? (
|
||||||
|
<RTAudRotten className="w-6" />
|
||||||
|
) : (
|
||||||
|
<RTAudFresh className="w-6" />
|
||||||
|
)}
|
||||||
|
<span>{ratingData.rt.audienceScore}%</span>
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{ratingData?.imdb?.criticsScore && (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
|
||||||
<a
|
<a
|
||||||
href={ratingData.url}
|
href={ratingData.imdb.url}
|
||||||
className="media-rating"
|
className="media-rating"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{ratingData.criticsRating === 'Rotten' ? (
|
<ImdbLogo className="mr-1 w-6" />
|
||||||
<RTRotten className="w-6" />
|
<span>{ratingData.imdb.criticsScore}</span>
|
||||||
) : (
|
|
||||||
<RTFresh className="w-6" />
|
|
||||||
)}
|
|
||||||
<span>{ratingData.criticsScore}%</span>
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
|
||||||
<Tooltip
|
|
||||||
content={intl.formatMessage(messages.rtaudiencescore)}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={ratingData.url}
|
|
||||||
className="media-rating"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{ratingData.audienceRating === 'Spilled' ? (
|
|
||||||
<RTAudRotten className="w-6" />
|
|
||||||
) : (
|
|
||||||
<RTAudFresh className="w-6" />
|
|
||||||
)}
|
|
||||||
<span>{ratingData.audienceScore}%</span>
|
|
||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -827,7 +847,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
tvdbId={data.externalIds.tvdbId}
|
tvdbId={data.externalIds.tvdbId}
|
||||||
imdbId={data.externalIds.imdbId}
|
imdbId={data.externalIds.imdbId}
|
||||||
rtUrl={ratingData?.url}
|
rtUrl={ratingData?.rt?.url}
|
||||||
mediaUrl={
|
mediaUrl={
|
||||||
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
|
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { PermissionItem } from '@app/components/PermissionOption';
|
import type { PermissionItem } from '@app/components/PermissionOption';
|
||||||
import PermissionOption from '@app/components/PermissionOption';
|
import PermissionOption from '@app/components/PermissionOption';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import type { User } from '@app/hooks/useUser';
|
import type { User } from '@app/hooks/useUser';
|
||||||
import { Permission } from '@app/hooks/useUser';
|
import { Permission } from '@app/hooks/useUser';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
@@ -72,9 +74,9 @@ export const messages = defineMessages({
|
|||||||
viewrecent: 'View Recently Added',
|
viewrecent: 'View Recently Added',
|
||||||
viewrecentDescription:
|
viewrecentDescription:
|
||||||
'Grant permission to view the list of recently added media.',
|
'Grant permission to view the list of recently added media.',
|
||||||
viewwatchlists: 'View Plex Watchlists',
|
viewwatchlists: 'View {mediaServerName} Watchlists',
|
||||||
viewwatchlistsDescription:
|
viewwatchlistsDescription:
|
||||||
"Grant permission to view other users' Plex Watchlists.",
|
"Grant permission to view other users' {mediaServerName} Watchlists.",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface PermissionEditProps {
|
interface PermissionEditProps {
|
||||||
@@ -91,6 +93,7 @@ export const PermissionEdit = ({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
}: PermissionEditProps) => {
|
}: PermissionEditProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
const permissionList: PermissionItem[] = [
|
const permissionList: PermissionItem[] = [
|
||||||
{
|
{
|
||||||
@@ -131,8 +134,24 @@ export const PermissionEdit = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'viewwatchlists',
|
id: 'viewwatchlists',
|
||||||
name: intl.formatMessage(messages.viewwatchlists),
|
name: intl.formatMessage(messages.viewwatchlists, {
|
||||||
description: intl.formatMessage(messages.viewwatchlistsDescription),
|
mediaServerName:
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby',
|
||||||
|
}),
|
||||||
|
description: intl.formatMessage(messages.viewwatchlistsDescription, {
|
||||||
|
mediaServerName:
|
||||||
|
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby',
|
||||||
|
}),
|
||||||
permission: Permission.WATCHLIST_VIEW,
|
permission: Permission.WATCHLIST_VIEW,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import PR from 'pulltorefreshjs';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import ReactDOMServer from 'react-dom/server';
|
|
||||||
|
|
||||||
const PullToRefresh = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
PR.init({
|
|
||||||
mainElement: '#pull-to-refresh',
|
|
||||||
onRefresh() {
|
|
||||||
router.reload();
|
|
||||||
},
|
|
||||||
iconArrow: ReactDOMServer.renderToString(
|
|
||||||
<div className="p-2">
|
|
||||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
iconRefreshing: ReactDOMServer.renderToString(
|
|
||||||
<div
|
|
||||||
className="animate-spin p-2"
|
|
||||||
style={{ animationDirection: 'reverse' }}
|
|
||||||
>
|
|
||||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
|
|
||||||
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
|
|
||||||
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
|
|
||||||
distReload: 60,
|
|
||||||
distIgnore: 15,
|
|
||||||
shouldPullToRefresh: () =>
|
|
||||||
!window.scrollY && document.body.style.overflow !== 'hidden',
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
PR.destroyAll();
|
|
||||||
};
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return <div id="pull-to-refresh"></div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PullToRefresh;
|
|
||||||
@@ -76,8 +76,12 @@ const RegionSelector = ({
|
|||||||
}, [value, regions, allRegion]);
|
}, [value, regions, allRegion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange && regions && selectedRegion) {
|
if (onChange && regions) {
|
||||||
onChange(name, selectedRegion.iso_3166_1);
|
if (selectedRegion) {
|
||||||
|
onChange(name, selectedRegion.iso_3166_1);
|
||||||
|
} else {
|
||||||
|
onChange(name, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [onChange, selectedRegion, name, regions]);
|
}, [onChange, selectedRegion, name, regions]);
|
||||||
|
|
||||||
|
|||||||
@@ -169,15 +169,19 @@ export const GenreSelector = ({
|
|||||||
loadDefaultGenre();
|
loadDefaultGenre();
|
||||||
}, [defaultValue, type]);
|
}, [defaultValue, type]);
|
||||||
|
|
||||||
const loadGenreOptions = async () => {
|
const loadGenreOptions = async (inputValue: string) => {
|
||||||
const results = await axios.get<GenreSliderItem[]>(
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
`/api/v1/discover/genreslider/${type}`
|
`/api/v1/discover/genreslider/${type}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return results.data.map((result) => ({
|
return results.data
|
||||||
label: result.name,
|
.map((result) => ({
|
||||||
value: result.id,
|
label: result.name,
|
||||||
}));
|
value: result.id,
|
||||||
|
}))
|
||||||
|
.filter(({ label }) =>
|
||||||
|
label.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange(watchRegion, activeProvider);
|
onChange(watchRegion, activeProvider);
|
||||||
}, [activeProvider, watchRegion, onChange]);
|
// removed onChange as a dependency as we only need to call it when the value(s) change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeProvider, watchRegion]);
|
||||||
|
|
||||||
const orderedData = useMemo(() => {
|
const orderedData = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
|
|||||||
<SmallLoadingSpinner />
|
<SmallLoadingSpinner />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
<div className="grid grid-cols-6 gap-2">
|
<div className="provider-icons grid gap-2">
|
||||||
{initialProviders.map((provider) => {
|
{initialProviders.map((provider) => {
|
||||||
const isActive = activeProvider.includes(provider.id);
|
const isActive = activeProvider.includes(provider.id);
|
||||||
return (
|
return (
|
||||||
@@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
|
|||||||
key={`prodiver-${provider.id}`}
|
key={`prodiver-${provider.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||||
@@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{showMore && otherProviders.length > 0 && (
|
{showMore && otherProviders.length > 0 && (
|
||||||
<div className="relative top-2 grid grid-cols-6 gap-2">
|
<div className="provider-icons relative top-2 grid gap-2">
|
||||||
{otherProviders.map((provider) => {
|
{otherProviders.map((provider) => {
|
||||||
const isActive = activeProvider.includes(provider.id);
|
const isActive = activeProvider.includes(provider.id);
|
||||||
return (
|
return (
|
||||||
@@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
|
|||||||
key={`prodiver-${provider.id}`}
|
key={`prodiver-${provider.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||||
@@ -431,6 +437,7 @@ export const WatchProviderSelector = ({
|
|||||||
{otherProviders.length > 0 && (
|
{otherProviders.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
|
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
|
||||||
|
type="button"
|
||||||
onClick={() => setShowMore(!showMore)}
|
onClick={() => setShowMore(!showMore)}
|
||||||
>
|
>
|
||||||
<div className="h-0.5 flex-1 bg-gray-600" />
|
<div className="h-0.5 flex-1 bg-gray-600" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
||||||
|
import type { PushoverSound } from '@server/api/pushover';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -15,10 +16,12 @@ const messages = defineMessages({
|
|||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
accessToken: 'Application API Token',
|
accessToken: 'Application API Token',
|
||||||
accessTokenTip:
|
accessTokenTip:
|
||||||
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Overseerr',
|
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
|
||||||
userToken: 'User or Group Key',
|
userToken: 'User or Group Key',
|
||||||
userTokenTip:
|
userTokenTip:
|
||||||
'Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>',
|
'Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>',
|
||||||
|
sound: 'Notification Sound',
|
||||||
|
deviceDefault: 'Device Default',
|
||||||
validationAccessTokenRequired: 'You must provide a valid application token',
|
validationAccessTokenRequired: 'You must provide a valid application token',
|
||||||
validationUserTokenRequired: 'You must provide a valid user or group key',
|
validationUserTokenRequired: 'You must provide a valid user or group key',
|
||||||
pushoversettingssaved: 'Pushover notification settings saved successfully!',
|
pushoversettingssaved: 'Pushover notification settings saved successfully!',
|
||||||
@@ -38,6 +41,11 @@ const NotificationsPushover = () => {
|
|||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR('/api/v1/settings/notifications/pushover');
|
} = useSWR('/api/v1/settings/notifications/pushover');
|
||||||
|
const { data: soundsData } = useSWR<PushoverSound[]>(
|
||||||
|
data?.options.accessToken
|
||||||
|
? `/api/v1/settings/notifications/pushover/sounds?token=${data.options.accessToken}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
const NotificationsPushoverSchema = Yup.object().shape({
|
const NotificationsPushoverSchema = Yup.object().shape({
|
||||||
accessToken: Yup.string()
|
accessToken: Yup.string()
|
||||||
@@ -77,6 +85,7 @@ const NotificationsPushover = () => {
|
|||||||
types: data?.types,
|
types: data?.types,
|
||||||
accessToken: data?.options.accessToken,
|
accessToken: data?.options.accessToken,
|
||||||
userToken: data?.options.userToken,
|
userToken: data?.options.userToken,
|
||||||
|
sound: data?.options.sound,
|
||||||
}}
|
}}
|
||||||
validationSchema={NotificationsPushoverSchema}
|
validationSchema={NotificationsPushoverSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -132,6 +141,7 @@ const NotificationsPushover = () => {
|
|||||||
options: {
|
options: {
|
||||||
accessToken: values.accessToken,
|
accessToken: values.accessToken,
|
||||||
userToken: values.userToken,
|
userToken: values.userToken,
|
||||||
|
sound: values.sound,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,6 +236,30 @@ const NotificationsPushover = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="sound" className="text-label">
|
||||||
|
{intl.formatMessage(messages.sound)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="sound"
|
||||||
|
name="sound"
|
||||||
|
disabled={!soundsData?.length}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{intl.formatMessage(messages.deviceDefault)}
|
||||||
|
</option>
|
||||||
|
{soundsData?.map((sound, index) => (
|
||||||
|
<option key={`sound-${index}`} value={sound.name}>
|
||||||
|
{sound.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<NotificationTypeSelector
|
<NotificationTypeSelector
|
||||||
currentTypes={values.enabled ? values.types : 0}
|
currentTypes={values.enabled ? values.types : 0}
|
||||||
onUpdate={(newTypes) => {
|
onUpdate={(newTypes) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const messages = defineMessages({
|
|||||||
'Allow users to also start a chat with your bot and configure their own notifications',
|
'Allow users to also start a chat with your bot and configure their own notifications',
|
||||||
botAPI: 'Bot Authorization Token',
|
botAPI: 'Bot Authorization Token',
|
||||||
botApiTip:
|
botApiTip:
|
||||||
'<CreateBotLink>Create a bot</CreateBotLink> for use with Overseerr',
|
'<CreateBotLink>Create a bot</CreateBotLink> for use with Jellyseerr',
|
||||||
chatId: 'Chat ID',
|
chatId: 'Chat ID',
|
||||||
chatIdTip:
|
chatIdTip:
|
||||||
'Start a chat with your bot, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command',
|
'Start a chat with your bot, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const messages = defineMessages({
|
|||||||
toastWebPushTestSuccess: 'Web push test notification sent!',
|
toastWebPushTestSuccess: 'Web push test notification sent!',
|
||||||
toastWebPushTestFailed: 'Web push test notification failed to send.',
|
toastWebPushTestFailed: 'Web push test notification failed to send.',
|
||||||
httpsRequirement:
|
httpsRequirement:
|
||||||
'In order to receive web push notifications, Overseerr must be served over HTTPS.',
|
'In order to receive web push notifications, Jellyseerr must be served over HTTPS.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const NotificationsWebPush = () => {
|
const NotificationsWebPush = () => {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const defaultPayload = {
|
|||||||
requestedBy_email: '{{requestedBy_email}}',
|
requestedBy_email: '{{requestedBy_email}}',
|
||||||
requestedBy_username: '{{requestedBy_username}}',
|
requestedBy_username: '{{requestedBy_username}}',
|
||||||
requestedBy_avatar: '{{requestedBy_avatar}}',
|
requestedBy_avatar: '{{requestedBy_avatar}}',
|
||||||
|
requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}',
|
||||||
|
requestedBy_settings_telegramChatId:
|
||||||
|
'{{requestedBy_settings_telegramChatId}}',
|
||||||
},
|
},
|
||||||
'{{issue}}': {
|
'{{issue}}': {
|
||||||
issue_id: '{{issue_id}}',
|
issue_id: '{{issue_id}}',
|
||||||
@@ -47,12 +50,18 @@ const defaultPayload = {
|
|||||||
reportedBy_email: '{{reportedBy_email}}',
|
reportedBy_email: '{{reportedBy_email}}',
|
||||||
reportedBy_username: '{{reportedBy_username}}',
|
reportedBy_username: '{{reportedBy_username}}',
|
||||||
reportedBy_avatar: '{{reportedBy_avatar}}',
|
reportedBy_avatar: '{{reportedBy_avatar}}',
|
||||||
|
reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}',
|
||||||
|
reportedBy_settings_telegramChatId:
|
||||||
|
'{{reportedBy_settings_telegramChatId}}',
|
||||||
},
|
},
|
||||||
'{{comment}}': {
|
'{{comment}}': {
|
||||||
comment_message: '{{comment_message}}',
|
comment_message: '{{comment_message}}',
|
||||||
commentedBy_email: '{{commentedBy_email}}',
|
commentedBy_email: '{{commentedBy_email}}',
|
||||||
commentedBy_username: '{{commentedBy_username}}',
|
commentedBy_username: '{{commentedBy_username}}',
|
||||||
commentedBy_avatar: '{{commentedBy_avatar}}',
|
commentedBy_avatar: '{{commentedBy_avatar}}',
|
||||||
|
commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}',
|
||||||
|
commentedBy_settings_telegramChatId:
|
||||||
|
'{{commentedBy_settings_telegramChatId}}',
|
||||||
},
|
},
|
||||||
'{{extra}}': [],
|
'{{extra}}': [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const messages = defineMessages({
|
|||||||
testFirstTags: 'Test connection to load tags',
|
testFirstTags: 'Test connection to load tags',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
enableSearch: 'Enable Automatic Search',
|
enableSearch: 'Enable Automatic Search',
|
||||||
|
tagRequests: 'Tag Requests',
|
||||||
|
tagRequestsInfo:
|
||||||
|
"Automatically add an additional tag with the requester's user ID & display name",
|
||||||
validationApplicationUrl: 'You must provide a valid URL',
|
validationApplicationUrl: 'You must provide a valid URL',
|
||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
||||||
@@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
externalUrl: radarr?.externalUrl,
|
externalUrl: radarr?.externalUrl,
|
||||||
syncEnabled: radarr?.syncEnabled ?? false,
|
syncEnabled: radarr?.syncEnabled ?? false,
|
||||||
enableSearch: !radarr?.preventSearch,
|
enableSearch: !radarr?.preventSearch,
|
||||||
|
tagRequests: radarr?.tagRequests ?? false,
|
||||||
}}
|
}}
|
||||||
validationSchema={RadarrSettingsSchema}
|
validationSchema={RadarrSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
externalUrl: values.externalUrl,
|
externalUrl: values.externalUrl,
|
||||||
syncEnabled: values.syncEnabled,
|
syncEnabled: values.syncEnabled,
|
||||||
preventSearch: !values.enableSearch,
|
preventSearch: !values.enableSearch,
|
||||||
|
tagRequests: values.tagRequests,
|
||||||
};
|
};
|
||||||
if (!radarr) {
|
if (!radarr) {
|
||||||
await axios.post('/api/v1/settings/radarr', submission);
|
await axios.post('/api/v1/settings/radarr', submission);
|
||||||
@@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="tagRequests" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.tagRequests)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="tagRequests"
|
||||||
|
name="tagRequests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import useSWR from 'swr';
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
about: 'About',
|
about: 'About',
|
||||||
overseerrinformation: 'About Overseerr',
|
overseerrinformation: 'About Jellyseerr',
|
||||||
version: 'Version',
|
version: 'Version',
|
||||||
totalmedia: 'Total Media',
|
totalmedia: 'Total Media',
|
||||||
totalrequests: 'Total Requests',
|
totalrequests: 'Total Requests',
|
||||||
@@ -25,6 +25,7 @@ const messages = defineMessages({
|
|||||||
timezone: 'Time Zone',
|
timezone: 'Time Zone',
|
||||||
appDataPath: 'Data Directory',
|
appDataPath: 'Data Directory',
|
||||||
supportoverseerr: 'Support Overseerr',
|
supportoverseerr: 'Support Overseerr',
|
||||||
|
supportjellyseerr: 'Support Jellyseerr',
|
||||||
helppaycoffee: 'Help Pay for Coffee',
|
helppaycoffee: 'Help Pay for Coffee',
|
||||||
documentation: 'Documentation',
|
documentation: 'Documentation',
|
||||||
preferredmethod: 'Preferred',
|
preferredmethod: 'Preferred',
|
||||||
@@ -33,7 +34,7 @@ const messages = defineMessages({
|
|||||||
betawarning:
|
betawarning:
|
||||||
'This is BETA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
|
'This is BETA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
|
||||||
runningDevelop:
|
runningDevelop:
|
||||||
'You are running the <code>develop</code> branch of Overseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.',
|
'You are running the <code>develop</code> branch of Jellyseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsAbout = () => {
|
const SettingsAbout = () => {
|
||||||
@@ -187,6 +188,54 @@ const SettingsAbout = () => {
|
|||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="section">
|
||||||
|
<List title={intl.formatMessage(messages.supportoverseerr)}>
|
||||||
|
<List.Item
|
||||||
|
title={`${intl.formatMessage(messages.helppaycoffee)} ☕️`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://github.com/sponsors/sct"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-indigo-500 transition duration-300 hover:underline"
|
||||||
|
>
|
||||||
|
https://github.com/sponsors/sct
|
||||||
|
</a>
|
||||||
|
<Badge className="ml-2">
|
||||||
|
{intl.formatMessage(messages.preferredmethod)}
|
||||||
|
</Badge>
|
||||||
|
</List.Item>
|
||||||
|
<List.Item title="">
|
||||||
|
<a
|
||||||
|
href="https://patreon.com/overseerr"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-indigo-500 transition duration-300 hover:underline"
|
||||||
|
>
|
||||||
|
https://patreon.com/overseerr
|
||||||
|
</a>
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
<div className="section">
|
||||||
|
<List title={intl.formatMessage(messages.supportjellyseerr)}>
|
||||||
|
<List.Item
|
||||||
|
title={`${intl.formatMessage(messages.helppaycoffee)} ☕️`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://www.buymeacoffee.com/fallen.bagel"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-indigo-500 transition duration-300 hover:underline"
|
||||||
|
>
|
||||||
|
https://www.buymeacoffee.com/fallen.bagel
|
||||||
|
</a>
|
||||||
|
<Badge className="ml-2">
|
||||||
|
{intl.formatMessage(messages.preferredmethod)}
|
||||||
|
</Badge>
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<Releases currentVersion={data.version} />
|
<Releases currentVersion={data.version} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const messages = defineMessages({
|
|||||||
experimentalTooltip:
|
experimentalTooltip:
|
||||||
'Enabling this setting may result in unexpected application behavior',
|
'Enabling this setting may result in unexpected application behavior',
|
||||||
restartrequiredTooltip:
|
restartrequiredTooltip:
|
||||||
'Overseerr must be restarted for changes to this setting to take effect',
|
'Jellyseerr must be restarted for changes to this setting to take effect',
|
||||||
});
|
});
|
||||||
|
|
||||||
const SettingsBadge = ({
|
const SettingsBadge = ({
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ const messages = defineMessages({
|
|||||||
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
||||||
jellyfinSettings: '{mediaServerName} Settings',
|
jellyfinSettings: '{mediaServerName} Settings',
|
||||||
jellyfinSettingsDescription:
|
jellyfinSettingsDescription:
|
||||||
'Optionally configure an external player endpoint for your {mediaServerName} server that is different to the internal URL used during setup',
|
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
|
internalUrl: 'Internal URL',
|
||||||
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||||
validationUrl: 'You must provide a valid URL',
|
validationUrl: 'You must provide a valid URL',
|
||||||
syncing: 'Syncing',
|
syncing: 'Syncing',
|
||||||
syncJellyfin: 'Sync Libraries',
|
syncJellyfin: 'Sync Libraries',
|
||||||
@@ -86,7 +88,15 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
|
|
||||||
const JellyfinSettingsSchema = Yup.object().shape({
|
const JellyfinSettingsSchema = Yup.object().shape({
|
||||||
jellyfinExternalUrl: Yup.string().matches(
|
jellyfinExternalUrl: Yup.string().matches(
|
||||||
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/,
|
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
),
|
||||||
|
jellyfinInternalUrl: Yup.string().matches(
|
||||||
|
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
),
|
||||||
|
jellyfinForgotPasswordUrl: Yup.string().matches(
|
||||||
|
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
||||||
intl.formatMessage(messages.validationUrl)
|
intl.formatMessage(messages.validationUrl)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -346,13 +356,17 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
|
jellyfinInternalUrl: data?.hostname || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
}}
|
}}
|
||||||
validationSchema={JellyfinSettingsSchema}
|
validationSchema={JellyfinSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/jellyfin', {
|
await axios.post('/api/v1/settings/jellyfin', {
|
||||||
|
hostname: values.jellyfinInternalUrl,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
} as JellyfinSettings);
|
} as JellyfinSettings);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
@@ -388,6 +402,27 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||||
return (
|
return (
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="jellyfinInternalUrl" className="text-label">
|
||||||
|
{intl.formatMessage(messages.internalUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="jellyfinInternalUrl"
|
||||||
|
name="jellyfinInternalUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.jellyfinInternalUrl &&
|
||||||
|
touched.jellyfinInternalUrl && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.jellyfinInternalUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
||||||
{intl.formatMessage(messages.externalUrl)}
|
{intl.formatMessage(messages.externalUrl)}
|
||||||
@@ -409,6 +444,30 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="jellyfinForgotPasswordUrl"
|
||||||
|
className="text-label"
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="jellyfinForgotPasswordUrl"
|
||||||
|
name="jellyfinForgotPasswordUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.jellyfinForgotPasswordUrl &&
|
||||||
|
touched.jellyfinForgotPasswordUrl && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.jellyfinForgotPasswordUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
jobsandcache: 'Jobs & Cache',
|
jobsandcache: 'Jobs & Cache',
|
||||||
jobs: 'Jobs',
|
jobs: 'Jobs',
|
||||||
jobsDescription:
|
jobsDescription:
|
||||||
'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.',
|
'Jellyseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.',
|
||||||
jobname: 'Job Name',
|
jobname: 'Job Name',
|
||||||
jobtype: 'Type',
|
jobtype: 'Type',
|
||||||
nextexecution: 'Next Execution',
|
nextexecution: 'Next Execution',
|
||||||
@@ -42,7 +42,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
command: 'Command',
|
command: 'Command',
|
||||||
cache: 'Cache',
|
cache: 'Cache',
|
||||||
cacheDescription:
|
cacheDescription:
|
||||||
'Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.',
|
'Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.',
|
||||||
cacheflushed: '{cachename} cache flushed.',
|
cacheflushed: '{cachename} cache flushed.',
|
||||||
cachename: 'Cache Name',
|
cachename: 'Cache Name',
|
||||||
cachehits: 'Hits',
|
cachehits: 'Hits',
|
||||||
@@ -55,8 +55,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||||
'plex-full-scan': 'Plex Full Library Scan',
|
'plex-full-scan': 'Plex Full Library Scan',
|
||||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||||
'jellyfin-recently-added-sync': 'Jellyfin Recently Added Scan',
|
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
|
||||||
'jellyfin-full-sync': 'Jellyfin Full Library Scan',
|
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
|
||||||
'availability-sync': 'Media Availability Sync',
|
'availability-sync': 'Media Availability Sync',
|
||||||
'radarr-scan': 'Radarr Scan',
|
'radarr-scan': 'Radarr Scan',
|
||||||
'sonarr-scan': 'Sonarr Scan',
|
'sonarr-scan': 'Sonarr Scan',
|
||||||
@@ -76,7 +76,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
|||||||
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
||||||
imagecache: 'Image Cache',
|
imagecache: 'Image Cache',
|
||||||
imagecacheDescription:
|
imagecacheDescription:
|
||||||
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||||
imagecachecount: 'Images Cached',
|
imagecachecount: 'Images Cached',
|
||||||
imagecachesize: 'Total Cache Size',
|
imagecachesize: 'Total Cache Size',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const messages = defineMessages({
|
|||||||
general: 'General',
|
general: 'General',
|
||||||
generalsettings: 'General Settings',
|
generalsettings: 'General Settings',
|
||||||
generalsettingsDescription:
|
generalsettingsDescription:
|
||||||
'Configure global and default settings for Overseerr.',
|
'Configure global and default settings for Jellyseerr.',
|
||||||
apikey: 'API Key',
|
apikey: 'API Key',
|
||||||
applicationTitle: 'Application Title',
|
applicationTitle: 'Application Title',
|
||||||
applicationurl: 'Application URL',
|
applicationurl: 'Application URL',
|
||||||
@@ -49,7 +49,7 @@ const messages = defineMessages({
|
|||||||
'Cache externally sourced images (requires a significant amount of disk space)',
|
'Cache externally sourced images (requires a significant amount of disk space)',
|
||||||
trustProxy: 'Enable Proxy Support',
|
trustProxy: 'Enable Proxy Support',
|
||||||
trustProxyTip:
|
trustProxyTip:
|
||||||
'Allow Overseerr to correctly register client IP addresses behind a proxy',
|
'Allow Jellyseerr to correctly register client IP addresses behind a proxy',
|
||||||
validationApplicationTitle: 'You must provide an application title',
|
validationApplicationTitle: 'You must provide an application title',
|
||||||
validationApplicationUrl: 'You must provide a valid URL',
|
validationApplicationUrl: 'You must provide a valid URL',
|
||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ const messages = defineMessages({
|
|||||||
enablessl: 'Use SSL',
|
enablessl: 'Use SSL',
|
||||||
plexlibraries: 'Plex Libraries',
|
plexlibraries: 'Plex Libraries',
|
||||||
plexlibrariesDescription:
|
plexlibrariesDescription:
|
||||||
'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.',
|
'The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.',
|
||||||
scanning: 'Syncing…',
|
scanning: 'Syncing…',
|
||||||
scan: 'Sync Libraries',
|
scan: 'Sync Libraries',
|
||||||
manualscan: 'Manual Library Scan',
|
manualscan: 'Manual Library Scan',
|
||||||
manualscanDescription:
|
manualscanDescription:
|
||||||
"Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
|
"Normally, this will only be run once every 24 hours. Jellyseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
|
||||||
notrunning: 'Not Running',
|
notrunning: 'Not Running',
|
||||||
currentlibrary: 'Current Library: {name}',
|
currentlibrary: 'Current Library: {name}',
|
||||||
librariesRemaining: 'Libraries Remaining: {count}',
|
librariesRemaining: 'Libraries Remaining: {count}',
|
||||||
@@ -67,7 +67,7 @@ const messages = defineMessages({
|
|||||||
'Optionally direct users to the web app on your server instead of the "hosted" web app',
|
'Optionally direct users to the web app on your server instead of the "hosted" web app',
|
||||||
tautulliSettings: 'Tautulli Settings',
|
tautulliSettings: 'Tautulli Settings',
|
||||||
tautulliSettingsDescription:
|
tautulliSettingsDescription:
|
||||||
'Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.',
|
'Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.',
|
||||||
urlBase: 'URL Base',
|
urlBase: 'URL Base',
|
||||||
tautulliApiKey: 'API Key',
|
tautulliApiKey: 'API Key',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const messages = defineMessages({
|
|||||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
localLogin: 'Enable Local Sign-In',
|
localLogin: 'Enable Local Sign-In',
|
||||||
localLoginTip:
|
localLoginTip:
|
||||||
'Allow users to sign in using their email address and password, instead of Plex OAuth',
|
'Allow users to sign in using their email address and password, instead of {mediaServerName} OAuth',
|
||||||
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
|
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
|
||||||
newPlexLoginTip:
|
newPlexLoginTip:
|
||||||
'Allow {mediaServerName} users to sign in without first being imported',
|
'Allow {mediaServerName} users to sign in without first being imported',
|
||||||
@@ -114,7 +114,16 @@ const SettingsUsers = () => {
|
|||||||
<label htmlFor="localLogin" className="checkbox-label">
|
<label htmlFor="localLogin" className="checkbox-label">
|
||||||
{intl.formatMessage(messages.localLogin)}
|
{intl.formatMessage(messages.localLogin)}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
{intl.formatMessage(messages.localLoginTip)}
|
{intl.formatMessage(messages.localLoginTip, {
|
||||||
|
mediaServerName:
|
||||||
|
settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.PLEX
|
||||||
|
? 'Plex'
|
||||||
|
: settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.JELLYFIN
|
||||||
|
? 'Jellyfin'
|
||||||
|
: 'Emby',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { SonarrSettings } from '@server/lib/settings';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { type SonarrSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -43,6 +45,8 @@ const messages = defineMessages({
|
|||||||
qualityprofile: 'Quality Profile',
|
qualityprofile: 'Quality Profile',
|
||||||
languageprofile: 'Language Profile',
|
languageprofile: 'Language Profile',
|
||||||
rootfolder: 'Root Folder',
|
rootfolder: 'Root Folder',
|
||||||
|
seriesType: 'Series Type',
|
||||||
|
animeSeriesType: 'Anime Series Type',
|
||||||
animequalityprofile: 'Anime Quality Profile',
|
animequalityprofile: 'Anime Quality Profile',
|
||||||
animelanguageprofile: 'Anime Language Profile',
|
animelanguageprofile: 'Anime Language Profile',
|
||||||
animerootfolder: 'Anime Root Folder',
|
animerootfolder: 'Anime Root Folder',
|
||||||
@@ -62,6 +66,9 @@ const messages = defineMessages({
|
|||||||
syncEnabled: 'Enable Scan',
|
syncEnabled: 'Enable Scan',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
enableSearch: 'Enable Automatic Search',
|
enableSearch: 'Enable Automatic Search',
|
||||||
|
tagRequests: 'Tag Requests',
|
||||||
|
tagRequestsInfo:
|
||||||
|
"Automatically add an additional tag with the requester's user ID & display name",
|
||||||
validationApplicationUrl: 'You must provide a valid URL',
|
validationApplicationUrl: 'You must provide a valid URL',
|
||||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||||
@@ -104,6 +111,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const settings = useSettings();
|
||||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||||
profiles: [],
|
profiles: [],
|
||||||
rootFolders: [],
|
rootFolders: [],
|
||||||
@@ -241,6 +249,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
activeProfileId: sonarr?.activeProfileId,
|
activeProfileId: sonarr?.activeProfileId,
|
||||||
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
|
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
|
||||||
rootFolder: sonarr?.activeDirectory,
|
rootFolder: sonarr?.activeDirectory,
|
||||||
|
seriesType: sonarr?.seriesType,
|
||||||
|
animeSeriesType: sonarr?.animeSeriesType,
|
||||||
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
|
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
|
||||||
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
|
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
|
||||||
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
|
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
|
||||||
@@ -248,10 +258,13 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
animeTags: sonarr?.animeTags ?? [],
|
animeTags: sonarr?.animeTags ?? [],
|
||||||
isDefault: sonarr?.isDefault ?? false,
|
isDefault: sonarr?.isDefault ?? false,
|
||||||
is4k: sonarr?.is4k ?? false,
|
is4k: sonarr?.is4k ?? false,
|
||||||
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
|
enableSeasonFolders:
|
||||||
|
sonarr?.enableSeasonFolders ??
|
||||||
|
settings.currentSettings.mediaServerType !== MediaServerType.PLEX,
|
||||||
externalUrl: sonarr?.externalUrl,
|
externalUrl: sonarr?.externalUrl,
|
||||||
syncEnabled: sonarr?.syncEnabled ?? false,
|
syncEnabled: sonarr?.syncEnabled ?? false,
|
||||||
enableSearch: !sonarr?.preventSearch,
|
enableSearch: !sonarr?.preventSearch,
|
||||||
|
tagRequests: sonarr?.tagRequests ?? false,
|
||||||
}}
|
}}
|
||||||
validationSchema={SonarrSettingsSchema}
|
validationSchema={SonarrSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -276,6 +289,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
activeProfileName: profileName,
|
activeProfileName: profileName,
|
||||||
activeDirectory: values.rootFolder,
|
activeDirectory: values.rootFolder,
|
||||||
|
seriesType: values.seriesType,
|
||||||
|
animeSeriesType: values.animeSeriesType,
|
||||||
activeAnimeProfileId: values.activeAnimeProfileId
|
activeAnimeProfileId: values.activeAnimeProfileId
|
||||||
? Number(values.activeAnimeProfileId)
|
? Number(values.activeAnimeProfileId)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -292,6 +307,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
externalUrl: values.externalUrl,
|
externalUrl: values.externalUrl,
|
||||||
syncEnabled: values.syncEnabled,
|
syncEnabled: values.syncEnabled,
|
||||||
preventSearch: !values.enableSearch,
|
preventSearch: !values.enableSearch,
|
||||||
|
tagRequests: values.tagRequests,
|
||||||
};
|
};
|
||||||
if (!sonarr) {
|
if (!sonarr) {
|
||||||
await axios.post('/api/v1/settings/sonarr', submission);
|
await axios.post('/api/v1/settings/sonarr', submission);
|
||||||
@@ -532,6 +548,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="seriesType" className="text-label">
|
||||||
|
{intl.formatMessage(messages.seriesType)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="seriesType"
|
||||||
|
name="seriesType"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors.seriesType && touched.seriesType && (
|
||||||
|
<div className="error">{errors.seriesType}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="activeProfileId" className="text-label">
|
<label htmlFor="activeProfileId" className="text-label">
|
||||||
{intl.formatMessage(messages.qualityprofile)}
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
@@ -718,6 +755,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="animeSeriesType" className="text-label">
|
||||||
|
{intl.formatMessage(messages.animeSeriesType)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field
|
||||||
|
as="select"
|
||||||
|
id="animeSeriesType"
|
||||||
|
name="animeSeriesType"
|
||||||
|
disabled={!isValidated || isTesting}
|
||||||
|
>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="anime">Anime</option>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors.animeSeriesType && touched.animeSeriesType && (
|
||||||
|
<div className="error">{errors.animeSeriesType}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="activeAnimeProfileId" className="text-label">
|
<label htmlFor="activeAnimeProfileId" className="text-label">
|
||||||
{intl.formatMessage(messages.animequalityprofile)}
|
{intl.formatMessage(messages.animequalityprofile)}
|
||||||
@@ -908,11 +966,24 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(messages.seasonfolders)}
|
{intl.formatMessage(messages.seasonfolders)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div
|
||||||
|
className={`form-input-area ${
|
||||||
|
settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.JELLYFIN ||
|
||||||
|
settings.currentSettings.mediaServerType ===
|
||||||
|
MediaServerType.EMBY
|
||||||
|
? 'opacity-50'
|
||||||
|
: 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Field
|
<Field
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="enableSeasonFolders"
|
id="enableSeasonFolders"
|
||||||
name="enableSeasonFolders"
|
name="enableSeasonFolders"
|
||||||
|
disabled={
|
||||||
|
settings.currentSettings.mediaServerType !==
|
||||||
|
MediaServerType.PLEX
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -960,6 +1031,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="tagRequests" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.tagRequests)}
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="tagRequests"
|
||||||
|
name="tagRequests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ const Slider = ({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => slide(Direction.LEFT)}
|
onClick={() => slide(Direction.LEFT)}
|
||||||
disabled={scrollPos.isStart}
|
disabled={scrollPos.isStart}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-6 w-6" />
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -165,6 +166,7 @@ const Slider = ({
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => slide(Direction.RIGHT)}
|
onClick={() => slide(Direction.RIGHT)}
|
||||||
disabled={scrollPos.isEnd}
|
disabled={scrollPos.isEnd}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-6 w-6" />
|
<ChevronRightIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -176,11 +176,11 @@ const StatusBadge = ({
|
|||||||
</span>
|
</span>
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<>
|
<>
|
||||||
{mediaType === 'tv' && (
|
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -229,11 +229,11 @@ const StatusBadge = ({
|
|||||||
</span>
|
</span>
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<>
|
<>
|
||||||
{mediaType === 'tv' && (
|
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -282,11 +282,11 @@ const StatusBadge = ({
|
|||||||
</span>
|
</span>
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<>
|
<>
|
||||||
{mediaType === 'tv' && (
|
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TmdbTitleCardProps {
|
|||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
isAddedToWatchlist?: boolean;
|
isAddedToWatchlist?: boolean;
|
||||||
|
mutateParent?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -25,6 +26,7 @@ const TmdbTitleCard = ({
|
|||||||
type,
|
type,
|
||||||
canExpand,
|
canExpand,
|
||||||
isAddedToWatchlist = false,
|
isAddedToWatchlist = false,
|
||||||
|
mutateParent,
|
||||||
}: TmdbTitleCardProps) => {
|
}: TmdbTitleCardProps) => {
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ const TmdbTitleCard = ({
|
|||||||
year={title.releaseDate}
|
year={title.releaseDate}
|
||||||
mediaType={'movie'}
|
mediaType={'movie'}
|
||||||
canExpand={canExpand}
|
canExpand={canExpand}
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TitleCard
|
<TitleCard
|
||||||
@@ -87,6 +90,7 @@ const TmdbTitleCard = ({
|
|||||||
year={title.firstAirDate}
|
year={title.firstAirDate}
|
||||||
mediaType={'tv'}
|
mediaType={'tv'}
|
||||||
canExpand={canExpand}
|
canExpand={canExpand}
|
||||||
|
mutateParent={mutateParent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ interface TitleCardProps {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
title: string;
|
title: string;
|
||||||
userScore: number;
|
userScore?: number;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
status?: MediaStatus;
|
status?: MediaStatus;
|
||||||
canExpand?: boolean;
|
canExpand?: boolean;
|
||||||
inProgress?: boolean;
|
inProgress?: boolean;
|
||||||
isAddedToWatchlist?: number | boolean;
|
isAddedToWatchlist?: number | boolean;
|
||||||
|
mutateParent?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -61,6 +62,7 @@ const TitleCard = ({
|
|||||||
isAddedToWatchlist = false,
|
isAddedToWatchlist = false,
|
||||||
inProgress = false,
|
inProgress = false,
|
||||||
canExpand = false,
|
canExpand = false,
|
||||||
|
mutateParent,
|
||||||
}: TitleCardProps) => {
|
}: TitleCardProps) => {
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -148,6 +150,9 @@ const TitleCard = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
mutate('/api/v1/discover/watchlist');
|
mutate('/api/v1/discover/watchlist');
|
||||||
|
if (mutateParent) {
|
||||||
|
mutateParent();
|
||||||
|
}
|
||||||
setToggleWatchlist((prevState) => !prevState);
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -157,7 +162,9 @@ const TitleCard = ({
|
|||||||
const showRequestButton = hasPermission(
|
const showRequestButton = hasPermission(
|
||||||
[
|
[
|
||||||
Permission.REQUEST,
|
Permission.REQUEST,
|
||||||
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
|
mediaType === 'movie' || mediaType === 'collection'
|
||||||
|
? Permission.REQUEST_MOVIE
|
||||||
|
: Permission.REQUEST_TV,
|
||||||
],
|
],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
@@ -170,7 +177,13 @@ const TitleCard = ({
|
|||||||
<RequestModal
|
<RequestModal
|
||||||
tmdbId={id}
|
tmdbId={id}
|
||||||
show={showRequestModal}
|
show={showRequestModal}
|
||||||
type={mediaType === 'movie' ? 'movie' : 'tv'}
|
type={
|
||||||
|
mediaType === 'movie'
|
||||||
|
? 'movie'
|
||||||
|
: mediaType === 'collection'
|
||||||
|
? 'collection'
|
||||||
|
: 'tv'
|
||||||
|
}
|
||||||
onComplete={requestComplete}
|
onComplete={requestComplete}
|
||||||
onUpdating={requestUpdating}
|
onUpdating={requestUpdating}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
@@ -214,7 +227,7 @@ const TitleCard = ({
|
|||||||
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
|
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
|
||||||
mediaType === 'movie'
|
mediaType === 'movie' || mediaType === 'collection'
|
||||||
? 'border-blue-500 bg-blue-600'
|
? 'border-blue-500 bg-blue-600'
|
||||||
: 'border-purple-600 bg-purple-600'
|
: 'border-purple-600 bg-purple-600'
|
||||||
}`}
|
}`}
|
||||||
@@ -222,6 +235,8 @@ const TitleCard = ({
|
|||||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||||
{mediaType === 'movie'
|
{mediaType === 'movie'
|
||||||
? intl.formatMessage(globalMessages.movie)
|
? intl.formatMessage(globalMessages.movie)
|
||||||
|
: mediaType === 'collection'
|
||||||
|
? intl.formatMessage(globalMessages.collection)
|
||||||
: intl.formatMessage(globalMessages.tvshow)}
|
: intl.formatMessage(globalMessages.tvshow)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +298,15 @@ const TitleCard = ({
|
|||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-xl">
|
<div className="absolute inset-0 overflow-hidden rounded-xl">
|
||||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
<Link
|
||||||
|
href={
|
||||||
|
mediaType === 'movie'
|
||||||
|
? `/movie/${id}`
|
||||||
|
: mediaType === 'collection'
|
||||||
|
? `/collection/${id}`
|
||||||
|
: `/tv/${id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
|
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user