mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
341 Commits
v1.4.1
...
fix-librar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
044f9e667e | ||
|
|
a276c51ce7 | ||
|
|
7ab40cbbf3 | ||
|
|
6293dc2b70 | ||
|
|
016c50680c | ||
|
|
525537b4ad | ||
|
|
fa3e99a931 | ||
|
|
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 | ||
|
|
03316c642d | ||
|
|
b8e3c07c47 | ||
|
|
aa84977680 | ||
|
|
e051b1dfea | ||
|
|
c27f96096a | ||
|
|
4bd87647d0 | ||
|
|
c1e10338c1 | ||
|
|
cd1cacad55 | ||
|
|
ac77b037d5 | ||
|
|
10eb69a7dc | ||
|
|
70b1540ae2 | ||
|
|
7522aa3174 | ||
|
|
77a33cb74d | ||
|
|
c08897bdc1 | ||
|
|
469f64d484 | ||
|
|
b7e3d285ed | ||
|
|
5f1c10d50a | ||
|
|
9637c3f4ab | ||
|
|
a15c85cbd1 | ||
|
|
53f6a890b9 | ||
|
|
7dbe6f61d0 | ||
|
|
fd460df243 | ||
|
|
2e5cf22626 | ||
|
|
092d639dd9 | ||
|
|
fc1f3202e8 | ||
|
|
3bf04f2abd | ||
|
|
38fb66d31e | ||
|
|
8b3801539e | ||
|
|
101ffae641 | ||
|
|
bc9017f54d | ||
|
|
b90dedfafc | ||
|
|
a4d07f5afa | ||
|
|
f5191aded6 | ||
|
|
2520d8f739 | ||
|
|
ee23de6d2f | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
ddbc377d79 | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
7bd4c4d1d4 | ||
|
|
3005e577d7 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
572a7db4aa | ||
|
|
862cd2d6ac | ||
|
|
6f23abaa6d | ||
|
|
81518df89a | ||
|
|
604335a16d | ||
|
|
c93467b3ac | ||
|
|
812fb2f087 | ||
|
|
c60667ba63 | ||
|
|
7d6831483a | ||
|
|
58c5c27929 | ||
|
|
bcd2bb7c96 | ||
|
|
5a72f5f86e | ||
|
|
7d4455ba6b | ||
|
|
2e7458457e |
File diff suppressed because it is too large
Load Diff
21
.gitattributes
vendored
21
.gitattributes
vendored
@@ -24,3 +24,24 @@
|
||||
*.woff binary
|
||||
*.pyc 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
|
||||
|
||||
- @Fallenbagel
|
||||
|
||||
# i18n locale files
|
||||
|
||||
src/i18n/locale/ @Fallenbagel
|
||||
* @Fallenbagel
|
||||
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
||||
github: [sct]
|
||||
patreon: overseerr
|
||||
github: [Fallenbagel]
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -19,7 +19,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -87,5 +87,5 @@ body:
|
||||
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)
|
||||
options:
|
||||
- label: I agree to follow Overseerr's Code of Conduct
|
||||
- label: I agree to follow Jellyseerr's Code of Conduct
|
||||
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:
|
||||
- name: 💬 Support via Discord
|
||||
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
|
||||
url: https://github.com/fallenbagel/jellyseerr/discussions
|
||||
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
|
||||
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:
|
||||
- label: I agree to follow Overseerr's Code of Conduct
|
||||
- label: I agree to follow Jellyseerr's Code of Conduct
|
||||
required: true
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:16.17-alpine
|
||||
runs-on: ubuntu-22.04
|
||||
container: node:18.18-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
build_and_push:
|
||||
name: Build & Publish Docker Images
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -39,13 +39,6 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -57,6 +50,11 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
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
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
@@ -68,21 +66,13 @@ jobs:
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
tags: |
|
||||
fallenbagel/jellyseerr:develop
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
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
|
||||
ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build_and_push
|
||||
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:
|
||||
- name: Get Build Job Status
|
||||
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:
|
||||
build_and_push:
|
||||
name: Build & Publish Docker Preview Images
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -5,7 +5,7 @@ on: workflow_dispatch
|
||||
jobs:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: semantic-release
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
if: always()
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
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:
|
||||
name: Job Check
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: jobs
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,6 +41,8 @@ jobs:
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Configure Git
|
||||
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
@@ -67,7 +69,7 @@ jobs:
|
||||
name: Send Discord Notification
|
||||
needs: build-snap
|
||||
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -16,5 +16,8 @@
|
||||
}
|
||||
],
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"files.associations": {
|
||||
"globals.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -1,68 +1,3 @@
|
||||
## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
|
||||
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
|
||||
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
|
||||
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
|
||||
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
|
||||
|
||||
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
|
||||
* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
|
||||
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
|
||||
* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
|
||||
* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
|
||||
* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
|
||||
* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
|
||||
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
|
||||
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
|
||||
* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
|
||||
* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
|
||||
* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
|
||||
* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
|
||||
* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
|
||||
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
|
||||
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
|
||||
* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
|
||||
* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
|
||||
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
|
||||
* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
|
||||
* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
|
||||
* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
|
||||
* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
|
||||
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
|
||||
* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
|
||||
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
|
||||
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
|
||||
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
|
||||
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
|
||||
* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
|
||||
* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
|
||||
* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
|
||||
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
|
||||
* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
|
||||
* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
|
||||
* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
|
||||
* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
|
||||
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
|
||||
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
|
||||
* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
|
||||
* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
|
||||
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
|
||||
* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
|
||||
|
||||
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||
git clone https://github.com/YOUR_USERNAME/jellyseerr.git
|
||||
cd overseerr/
|
||||
```
|
||||
|
||||
@@ -97,9 +97,9 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -7,10 +7,11 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
'linux/arm64' | 'linux/arm/v7') \
|
||||
apk add --no-cache python3 make g++ && \
|
||||
ln -s /usr/bin/python3 /usr/bin/python \
|
||||
;; \
|
||||
'linux/arm64' | 'linux/arm/v7') \
|
||||
apk update && \
|
||||
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
|
||||
yarn global add node-gyp \
|
||||
;; \
|
||||
esac
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
@@ -33,7 +34,10 @@ RUN touch config/DOCKER
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:16.17-alpine
|
||||
FROM node:18.18-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
326
README.md
326
README.md
@@ -2,21 +2,28 @@
|
||||
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" 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://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></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="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>
|
||||
<!-- 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!_
|
||||
|
||||
## Current Features
|
||||
|
||||
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
|
||||
- Supports Movies, Shows, Mixed Libraries!
|
||||
- Full Jellyfin/Emby/Plex integration including authentication with user import & management
|
||||
- Supports Movies, Shows and Mixed Libraries
|
||||
- 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!
|
||||
- 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.
|
||||
@@ -25,7 +32,7 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
|
||||
- Support for various notification agents.
|
||||
- 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.
|
||||
|
||||
@@ -33,72 +40,73 @@ With more features on the way! Check out our [issue tracker](https://github.com/
|
||||
|
||||
#### 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
|
||||
|
||||
### Launching Jellyseerr manually:
|
||||
### Building from source (ADVANCED):
|
||||
|
||||
#### Windows
|
||||
|
||||
Pre-requisites:
|
||||
|
||||
- Nodejs (atleast LTS version)
|
||||
- Yarn
|
||||
- Download the source code from the github (Either develop branch or main for stable)
|
||||
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
|
||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||
- Download/git clone the source code from the github (Either develop branch or main for stable)
|
||||
|
||||
```bash
|
||||
```cmd
|
||||
npm i -g win-node-env
|
||||
yarn install
|
||||
set CYPRESS_INSTALL_BINARY=0
|
||||
yarn install --frozen-lockfile --network-timeout 1000000
|
||||
yarn run build
|
||||
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
|
||||
|
||||
Pre-requisites:
|
||||
**Pre-requisites:**
|
||||
|
||||
- Nodejs (atleast LTS version)
|
||||
- Yarn
|
||||
- Nodejs [v18](https://nodejs.org/en/download/package-manager)
|
||||
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
|
||||
- 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
|
||||
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
|
||||
git checkout main #if you want to run stable instead of develop
|
||||
yarn install
|
||||
yarn run build
|
||||
yarn start
|
||||
git checkout main
|
||||
```
|
||||
|
||||
_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/`
|
||||
and the environmentfile is located at `/etc/jellyseerr`
|
||||
- first create the environment file at `/etc/jellyseerr/jellyseerr.conf`
|
||||
|
||||
service:
|
||||
|
||||
```
|
||||
[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:
|
||||
Environment file:
|
||||
|
||||
```
|
||||
# Jellyseerr's default port is 5055, if you want to use both, change this.
|
||||
@@ -112,9 +120,34 @@ PORT=5055
|
||||
# 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:
|
||||
|
||||
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
|
||||
|
||||
@@ -141,3 +174,202 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
|
||||
## Contributing
|
||||
|
||||
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
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",
|
||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||
"main": {
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Overseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
"movie": {},
|
||||
"tv": {}
|
||||
},
|
||||
"hideAvailable": false,
|
||||
"localLogin": true,
|
||||
"newPlexLogin": true,
|
||||
"region": "",
|
||||
"originalLanguage": "",
|
||||
"trustProxy": false,
|
||||
"partialRequestsEnabled": true,
|
||||
"locale": "en"
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Overseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
"movie": {},
|
||||
"tv": {}
|
||||
},
|
||||
"hideAvailable": false,
|
||||
"localLogin": true,
|
||||
"newPlexLogin": true,
|
||||
"region": "",
|
||||
"originalLanguage": "",
|
||||
"trustProxy": false,
|
||||
"partialRequestsEnabled": true,
|
||||
"locale": "en"
|
||||
},
|
||||
"plex": {
|
||||
"name": "Seerr",
|
||||
"ip": "192.168.1.1",
|
||||
"port": 32400,
|
||||
"useSsl": false,
|
||||
"libraries": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Movies",
|
||||
"enabled": true,
|
||||
"type": "movie"
|
||||
}
|
||||
],
|
||||
"machineId": "test"
|
||||
"name": "Seerr",
|
||||
"ip": "192.168.1.1",
|
||||
"port": 32400,
|
||||
"useSsl": false,
|
||||
"libraries": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Movies",
|
||||
"enabled": true,
|
||||
"type": "movie"
|
||||
}
|
||||
],
|
||||
"machineId": "test"
|
||||
},
|
||||
"tautulli": {},
|
||||
"radarr": [],
|
||||
"sonarr": [],
|
||||
"public": {
|
||||
"initialized": true
|
||||
"initialized": true
|
||||
},
|
||||
"notifications": {
|
||||
"agents": {
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"options": {
|
||||
"emailFrom": "",
|
||||
"smtpHost": "",
|
||||
"smtpPort": 587,
|
||||
"secure": false,
|
||||
"ignoreTls": false,
|
||||
"requireTls": false,
|
||||
"allowSelfSigned": false,
|
||||
"senderName": "Overseerr"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
"lunasea": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"botAPI": "",
|
||||
"chatId": "",
|
||||
"sendSilently": false
|
||||
}
|
||||
},
|
||||
"pushbullet": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": ""
|
||||
}
|
||||
},
|
||||
"pushover": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": "",
|
||||
"userToken": ""
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||
}
|
||||
},
|
||||
"webpush": {
|
||||
"enabled": false,
|
||||
"options": {}
|
||||
},
|
||||
"gotify": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"url": "",
|
||||
"token": ""
|
||||
}
|
||||
"agents": {
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"options": {
|
||||
"emailFrom": "",
|
||||
"smtpHost": "",
|
||||
"smtpPort": 587,
|
||||
"secure": false,
|
||||
"ignoreTls": false,
|
||||
"requireTls": false,
|
||||
"allowSelfSigned": false,
|
||||
"senderName": "Overseerr"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
"lunasea": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"botAPI": "",
|
||||
"chatId": "",
|
||||
"sendSilently": false
|
||||
}
|
||||
},
|
||||
"pushbullet": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": ""
|
||||
}
|
||||
},
|
||||
"pushover": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"accessToken": "",
|
||||
"userToken": ""
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": "",
|
||||
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
|
||||
}
|
||||
},
|
||||
"webpush": {
|
||||
"enabled": false,
|
||||
"options": {}
|
||||
},
|
||||
"gotify": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"url": "",
|
||||
"token": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jobs": {
|
||||
"plex-recently-added-scan": {
|
||||
"schedule": "0 */5 * * * *"
|
||||
},
|
||||
"plex-full-scan": {
|
||||
"schedule": "0 0 3 * * *"
|
||||
},
|
||||
"radarr-scan": {
|
||||
"schedule": "0 0 4 * * *"
|
||||
},
|
||||
"sonarr-scan": {
|
||||
"schedule": "0 30 4 * * *"
|
||||
},
|
||||
"download-sync": {
|
||||
"schedule": "0 * * * * *"
|
||||
},
|
||||
"download-sync-reset": {
|
||||
"schedule": "0 0 1 * * *"
|
||||
}
|
||||
"plex-recently-added-scan": {
|
||||
"schedule": "0 */5 * * * *"
|
||||
},
|
||||
"plex-full-scan": {
|
||||
"schedule": "0 0 3 * * *"
|
||||
},
|
||||
"radarr-scan": {
|
||||
"schedule": "0 0 4 * * *"
|
||||
},
|
||||
"sonarr-scan": {
|
||||
"schedule": "0 30 4 * * *"
|
||||
},
|
||||
"download-sync": {
|
||||
"schedule": "0 * * * * *"
|
||||
},
|
||||
"download-sync-reset": {
|
||||
"schedule": "0 0 1 * * *"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('Discover', () => {
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||
const sliderHeader = cy.contains('.slider-header', 'Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('Discover', () => {
|
||||
.find('[data-testid=title-card-title]')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
cy.contains('.slider-header', 'Plex Watchlist')
|
||||
cy.contains('.slider-header', 'Watchlist')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
|
||||
url: '/api/v1/*',
|
||||
}).as('apiCall');
|
||||
|
||||
cy.get('.searchbar').swipe('bottom', [190, 400]);
|
||||
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||
|
||||
cy.wait('@apiCall').then((interception) => {
|
||||
assert.isNotNull(
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
|
||||
.should('be.disabled');
|
||||
|
||||
cy.get('#data').clear();
|
||||
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
||||
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||
|
||||
// Confirming we have some results
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
version: '3'
|
||||
services:
|
||||
overseerr:
|
||||
jellyseerr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
|
||||
@@ -23,5 +23,6 @@ module.exports = {
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
largePageDataBytes: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ tags:
|
||||
description: Endpoints related to retrieving collection details.
|
||||
- name: service
|
||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||
- name: watchlist
|
||||
description: Collection of media to watch later
|
||||
servers:
|
||||
- url: '{server}/api/v1'
|
||||
variables:
|
||||
@@ -44,6 +46,34 @@ servers:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Watchlist:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
readOnly: true
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
ratingKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
media:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
createdAt:
|
||||
type: string
|
||||
example: '2020-09-12T10:00:27.000Z'
|
||||
readOnly: true
|
||||
updatedAt:
|
||||
type: string
|
||||
example: '2020-09-12T10:00:27.000Z'
|
||||
readOnly: true
|
||||
requestedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
@@ -338,6 +368,9 @@ components:
|
||||
externalHostname:
|
||||
type: string
|
||||
example: 'http://my.jellyfin.host'
|
||||
jellyfinForgotPasswordUrl:
|
||||
type: string
|
||||
example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html'
|
||||
adminUser:
|
||||
type: string
|
||||
example: 'admin'
|
||||
@@ -1321,6 +1354,8 @@ components:
|
||||
type: string
|
||||
userToken:
|
||||
type: string
|
||||
sound:
|
||||
type: string
|
||||
GotifySettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1756,6 +1791,9 @@ components:
|
||||
pushoverUserKey:
|
||||
type: string
|
||||
nullable: true
|
||||
pushoverSound:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramEnabled:
|
||||
type: boolean
|
||||
telegramBotUsername:
|
||||
@@ -3053,6 +3091,33 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
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:
|
||||
get:
|
||||
summary: Get Gotify notification settings
|
||||
@@ -3868,7 +3933,7 @@ paths:
|
||||
$ref: '#/components/schemas/User'
|
||||
/user/{userId}/requests:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
summary: Get requests for a specific user
|
||||
description: |
|
||||
Retrieves a user's requests in a JSON object.
|
||||
tags:
|
||||
@@ -3962,13 +4027,49 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
example: false
|
||||
/watchlist:
|
||||
post:
|
||||
summary: Add media to watchlist
|
||||
tags:
|
||||
- watchlist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Watchlist'
|
||||
responses:
|
||||
'200':
|
||||
description: Watchlist data returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Watchlist'
|
||||
/watchlist/{tmdbId}:
|
||||
delete:
|
||||
summary: Delete watchlist item
|
||||
description: Removes a watchlist item.
|
||||
tags:
|
||||
- watchlist
|
||||
parameters:
|
||||
- in: path
|
||||
name: tmdbId
|
||||
description: tmdbId ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed watchlist item
|
||||
/user/{userId}/watchlist:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
summary: Get the Plex watchlist for a specific user
|
||||
description: |
|
||||
Retrieves a user's Plex Watchlist in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
- watchlist
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
@@ -4439,6 +4540,16 @@ paths:
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: voteCountGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteCountLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
@@ -4718,6 +4829,16 @@ paths:
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: voteCountGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteCountLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
@@ -5571,6 +5692,63 @@ paths:
|
||||
audienceRating:
|
||||
type: string
|
||||
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}:
|
||||
get:
|
||||
summary: Get TV details
|
||||
@@ -5876,6 +6054,23 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/file:
|
||||
delete:
|
||||
summary: Delete media file
|
||||
description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action.
|
||||
tags:
|
||||
- media
|
||||
parameters:
|
||||
- in: path
|
||||
name: mediaId
|
||||
description: Media ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/{status}:
|
||||
post:
|
||||
summary: Update media status
|
||||
|
||||
98
package.json
98
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyseerr",
|
||||
"version": "1.4.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -8,6 +8,7 @@
|
||||
"build:next": "next build",
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
@@ -29,17 +30,17 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-displaynames": "6.2.3",
|
||||
"@formatjs/intl-locale": "3.0.11",
|
||||
"@formatjs/intl-pluralrules": "5.1.8",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "1.7.7",
|
||||
"@heroicons/react": "2.0.13",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.22",
|
||||
"ace-builds": "1.14.0",
|
||||
"axios": "1.2.2",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.3.4",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
@@ -47,7 +48,7 @@
|
||||
"cookie-parser": "1.4.6",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.21.0",
|
||||
"cronstrue": "2.23.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
@@ -64,23 +65,22 @@
|
||||
"next": "12.3.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.0",
|
||||
"nodemailer": "6.8.0",
|
||||
"openpgp": "5.5.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"openpgp": "5.7.0",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.2",
|
||||
"pulltorefreshjs": "0.1.22",
|
||||
"react": "18.2.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.22.0",
|
||||
"react-aria": "3.23.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-intersection-observer": "9.4.1",
|
||||
"react-intl": "6.2.5",
|
||||
"react-markdown": "8.0.4",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "6.2.10",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-spring": "9.6.1",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
@@ -89,42 +89,41 @@
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.8",
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.0",
|
||||
"swr": "2.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.0.4",
|
||||
"typeorm": "0.3.12",
|
||||
"web-push": "3.5.0",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.20.2"
|
||||
"zod": "3.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.20.7",
|
||||
"@commitlint/cli": "17.4.0",
|
||||
"@commitlint/config-conventional": "17.4.0",
|
||||
"@babel/cli": "7.21.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@semantic-release/changelog": "6.0.2",
|
||||
"@semantic-release/commit-analyzer": "9.0.2",
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.8",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/pulltorefreshjs": "0.1.5",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.13",
|
||||
@@ -133,45 +132,46 @@
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.48.0",
|
||||
"@typescript-eslint/parser": "5.48.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"babel-plugin-react-intl-auto": "3.3.0",
|
||||
"commitizen": "4.2.6",
|
||||
"commitizen": "4.3.0",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "12.3.0",
|
||||
"cypress": "12.7.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "12.3.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.3.9",
|
||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"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",
|
||||
"extract-react-intl-messages": "4.1.1",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.0",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "2.0.20",
|
||||
"postcss": "8.4.20",
|
||||
"prettier": "2.8.1",
|
||||
"prettier-plugin-organize-imports": "3.2.1",
|
||||
"prettier-plugin-tailwindcss": "0.2.1",
|
||||
"postcss": "8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.2.4",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "4.9.4"
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10"
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/express-session": "1.17.6"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #6366F1;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -37,7 +37,7 @@
|
||||
<!-- Inline the page's JavaScript file. -->
|
||||
<script>
|
||||
// Manual reload feature.
|
||||
document.querySelector("button").addEventListener("click", () => {
|
||||
document.querySelector('button').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
|
||||
74
public/sw.js
74
public/sw.js
@@ -4,30 +4,30 @@
|
||||
// This variable is intentionally declared and unused.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const OFFLINE_VERSION = 3;
|
||||
const CACHE_NAME = "offline";
|
||||
const CACHE_NAME = 'offline';
|
||||
// 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(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
// 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
|
||||
// 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.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Enable navigation preload if it's supported.
|
||||
// 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();
|
||||
}
|
||||
})()
|
||||
@@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => {
|
||||
clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// We only want to call event.respondWith() if this is a navigation request
|
||||
// for an HTML page.
|
||||
if (event.request.mode === "navigate") {
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
@@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => {
|
||||
// If fetch() returns a valid HTTP response with a response code in
|
||||
// the 4xx or 5xx range, the catch() will NOT be called.
|
||||
// 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 cachedResponse = await cache.match(OFFLINE_URL);
|
||||
@@ -85,15 +85,13 @@ self.addEventListener('push', (event) => {
|
||||
requestId: payload.requestId,
|
||||
},
|
||||
actions: [],
|
||||
}
|
||||
};
|
||||
|
||||
if (payload.actionUrl){
|
||||
options.actions.push(
|
||||
{
|
||||
action: 'view',
|
||||
title: payload.actionUrlTitle ?? 'View',
|
||||
}
|
||||
);
|
||||
if (payload.actionUrl) {
|
||||
options.actions.push({
|
||||
action: 'view',
|
||||
title: payload.actionUrlTitle ?? 'View',
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||
@@ -109,27 +107,29 @@ self.addEventListener('push', (event) => {
|
||||
);
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.subject, options)
|
||||
);
|
||||
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
const notificationData = event.notification.data;
|
||||
self.addEventListener(
|
||||
'notificationclick',
|
||||
(event) => {
|
||||
const notificationData = event.notification.data;
|
||||
|
||||
event.notification.close();
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'approve') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} else if (event.action === 'decline') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationData.actionUrl) {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
}
|
||||
}, false);
|
||||
if (event.action === 'approve') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} else if (event.action === 'decline') {
|
||||
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationData.actionUrl) {
|
||||
clients.openWindow(notificationData.actionUrl);
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import logger from '@server/logger';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
@@ -8,6 +9,12 @@ export interface JellyfinUserResponse {
|
||||
ServerId: string;
|
||||
ServerName: string;
|
||||
Id: string;
|
||||
Configuration: {
|
||||
GroupedFolders: string[];
|
||||
};
|
||||
Policy: {
|
||||
IsAdministrator: boolean;
|
||||
};
|
||||
PrimaryImageTag?: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +27,13 @@ export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
|
||||
interface JellyfinMediaFolder {
|
||||
Name: string;
|
||||
Id: string;
|
||||
Type: string;
|
||||
CollectionType: string;
|
||||
}
|
||||
|
||||
export interface JellyfinLibrary {
|
||||
type: 'show' | 'movie';
|
||||
key: string;
|
||||
@@ -171,21 +185,45 @@ class JellyfinAPI {
|
||||
|
||||
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
||||
try {
|
||||
const account = await this.axios.get<any>(
|
||||
`/Users/${this.userId ?? 'Me'}/Views`
|
||||
);
|
||||
const mediaFolders = await this.axios.get<any>(`/Library/MediaFolders`);
|
||||
|
||||
const response: JellyfinLibrary[] = account.data.Items.filter(
|
||||
(Item: any) => {
|
||||
return (
|
||||
Item.Type === 'CollectionFolder' &&
|
||||
Item.CollectionType !== 'music' &&
|
||||
Item.CollectionType !== 'books' &&
|
||||
Item.CollectionType !== 'musicvideos' &&
|
||||
Item.CollectionType !== 'homevideos'
|
||||
);
|
||||
}
|
||||
).map((Item: any) => {
|
||||
return this.mapLibraries(mediaFolders.data.Items);
|
||||
} catch (mediaFoldersError) {
|
||||
// fallback to user views to get libraries
|
||||
// this only affects LDAP users
|
||||
try {
|
||||
const mediaFolders = await this.axios.get<any>(
|
||||
`/Users/${this.userId ?? 'Me'}/Views`
|
||||
);
|
||||
|
||||
return this.mapLibraries(mediaFolders.data.Items);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
|
||||
const excludedTypes = [
|
||||
'music',
|
||||
'books',
|
||||
'musicvideos',
|
||||
'homevideos',
|
||||
'boxsets',
|
||||
];
|
||||
|
||||
return mediaFolders
|
||||
.filter((Item: JellyfinMediaFolder) => {
|
||||
return (
|
||||
Item.Type === 'CollectionFolder' &&
|
||||
!excludedTypes.includes(Item.CollectionType)
|
||||
);
|
||||
})
|
||||
.map((Item: JellyfinMediaFolder) => {
|
||||
return <JellyfinLibrary>{
|
||||
key: Item.Id,
|
||||
title: Item.Name,
|
||||
@@ -193,21 +231,12 @@ class JellyfinAPI {
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
);
|
||||
|
||||
return contents.data.Items.filter(
|
||||
@@ -238,7 +267,9 @@ class JellyfinAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
|
||||
public async getItemData(
|
||||
id: string
|
||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(
|
||||
`/Users/${this.userId}/Items/${id}`
|
||||
@@ -246,6 +277,11 @@ class JellyfinAPI {
|
||||
|
||||
return contents.data;
|
||||
} catch (e) {
|
||||
if (availabilitySync.running) {
|
||||
if (e.response && e.response.status === 500) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
logger.error(
|
||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
@@ -258,9 +294,7 @@ class JellyfinAPI {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
|
||||
|
||||
return contents.data.Items.filter(
|
||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||
);
|
||||
return contents.data.Items;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`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 {
|
||||
MediaContainer: {
|
||||
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> {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI {
|
||||
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);
|
||||
|
||||
|
||||
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 { getSettings } from '@server/lib/settings';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
@@ -17,7 +17,7 @@ interface RTAlgoliaHit {
|
||||
title: string;
|
||||
titles: string[];
|
||||
description: string;
|
||||
releaseYear: string;
|
||||
releaseYear: number;
|
||||
rating: string;
|
||||
genres: string[];
|
||||
updateDate: string;
|
||||
@@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
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 (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) =>
|
||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString()
|
||||
);
|
||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
@@ -147,6 +144,9 @@ class RottenTomatoes extends ExternalAPI {
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
audienceScore: movie.rottenTomatoes.audienceScore,
|
||||
year: Number(movie.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -181,7 +181,7 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
if (year) {
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year.toString()
|
||||
(series) => series.releaseYear === year
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,9 @@ class RottenTomatoes extends ExternalAPI {
|
||||
criticsRating:
|
||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
audienceScore: tvshow.rottenTomatoes.audienceScore,
|
||||
year: Number(tvshow.releaseYear),
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
@@ -213,6 +213,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
);
|
||||
}
|
||||
}
|
||||
public removeMovie = async (movieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||
await this.axios.delete(`/movie/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
});
|
||||
logger.info(`[Radarr] Removed movie ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default RadarrAPI;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface SonarrSeason {
|
||||
export interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: {
|
||||
@@ -76,6 +76,15 @@ export interface SonarrSeries {
|
||||
ignoreEpisodesWithoutFiles?: boolean;
|
||||
searchForMissingEpisodes?: boolean;
|
||||
};
|
||||
statistics: {
|
||||
seasonCount: number;
|
||||
episodeFileCount: number;
|
||||
episodeCount: number;
|
||||
totalEpisodeCount: number;
|
||||
sizeOnDisk: number;
|
||||
releaseGroups: string[];
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
|
||||
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[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
@@ -321,6 +340,20 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
public removeSerie = async (serieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||
await this.axios.delete(`/series/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
});
|
||||
logger.info(`[Radarr] Removed serie ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default SonarrAPI;
|
||||
|
||||
@@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
@@ -83,6 +85,8 @@ interface DiscoverTvOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: string;
|
||||
@@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
@@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
},
|
||||
@@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
@@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
|
||||
@@ -20,6 +20,8 @@ export enum DiscoverSliderType {
|
||||
TMDB_SEARCH,
|
||||
TMDB_STUDIO,
|
||||
TMDB_NETWORK,
|
||||
TMDB_MOVIE_STREAMING_SERVICES,
|
||||
TMDB_TV_STREAMING_SERVICES,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
|
||||
@@ -3,6 +3,8 @@ import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
@@ -12,7 +14,6 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
In,
|
||||
Index,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -25,6 +26,7 @@ import Season from './Season';
|
||||
@Entity()
|
||||
class Media {
|
||||
public static async getRelatedMedia(
|
||||
user: User | undefined,
|
||||
tmdbIds: number | number[]
|
||||
): Promise<Media[]> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
@@ -37,9 +39,16 @@ class Media {
|
||||
finalIds = tmdbIds;
|
||||
}
|
||||
|
||||
const media = await mediaRepository.find({
|
||||
where: { tmdbId: In(finalIds) },
|
||||
});
|
||||
const media = await mediaRepository
|
||||
.createQueryBuilder('media')
|
||||
.leftJoinAndSelect(
|
||||
'media.watchlists',
|
||||
'watchlist',
|
||||
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
||||
{ userId: user?.id }
|
||||
) //,
|
||||
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
||||
.getMany();
|
||||
|
||||
return media;
|
||||
} catch (e) {
|
||||
@@ -94,6 +103,9 @@ class Media {
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||
public watchlists: null | Watchlist[];
|
||||
|
||||
@OneToMany(() => Season, (season) => season.media, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
@@ -115,29 +127,29 @@ class Media {
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public serviceId4k?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId4k?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId4k?: number;
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId4k?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug4k?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug4k?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public ratingKey4k?: string;
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey4k?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinMediaId?: string;
|
||||
@@ -288,7 +300,9 @@ class Media {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||
this.serviceId,
|
||||
@@ -298,7 +312,9 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||
this.serviceId4k,
|
||||
@@ -310,7 +326,9 @@ class Media {
|
||||
if (this.mediaType === MediaType.TV) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.serviceId !== undefined
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||
this.serviceId,
|
||||
@@ -320,7 +338,9 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||
this.serviceId4k,
|
||||
|
||||
@@ -704,7 +704,7 @@ export class MediaRequest {
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags;
|
||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -764,6 +764,38 @@ export class MediaRequest {
|
||||
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 (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
@@ -952,7 +984,7 @@ export class MediaRequest {
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = 'anime';
|
||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
@@ -970,7 +1002,11 @@ export class MediaRequest {
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
: sonarrSettings.tags;
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
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 = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
@@ -1187,3 +1255,5 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaRequest;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
AfterRemove,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
||||
constructor(init?: Partial<SeasonRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
public async handleRemoveParent(): Promise<void> {
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||
where: { id: this.request.id },
|
||||
});
|
||||
|
||||
if (requestToBeDeleted.seasons.length === 0) {
|
||||
await mediaRequestRepository.delete({ id: this.request.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SeasonRequest;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
@@ -103,6 +104,9 @@ export class User {
|
||||
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy)
|
||||
public watchlists: Watchlist[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
public movieQuotaLimit?: number;
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public pushoverUserKey?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public pushoverSound?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramChatId?: string;
|
||||
|
||||
|
||||
157
server/entity/Watchlist.ts
Normal file
157
server/entity/Watchlist.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||
|
||||
export class DuplicateWatchlistRequestError extends Error {}
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message = 'Not found') {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
@Entity()
|
||||
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||
export class Watchlist implements WatchlistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public ratingKey = '';
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
title = '';
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Watchlist>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public static async createWatchlist({
|
||||
watchlistRequest,
|
||||
user,
|
||||
}: {
|
||||
watchlistRequest: {
|
||||
mediaType: MediaType;
|
||||
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
};
|
||||
user: User;
|
||||
}): Promise<Watchlist> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
const mediaRepository = getRepository(Media);
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
});
|
||||
}
|
||||
|
||||
const watchlist = new this({
|
||||
...watchlistRequest,
|
||||
requestedBy: user,
|
||||
media,
|
||||
});
|
||||
|
||||
await mediaRepository.save(media);
|
||||
await watchlistRepository.save(watchlist);
|
||||
return watchlist;
|
||||
}
|
||||
|
||||
public static async deleteWatchlist(
|
||||
tmdbId: Watchlist['tmdbId'],
|
||||
user: User
|
||||
): Promise<Watchlist | null> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
const watchlist = await watchlistRepository.findOneBy({
|
||||
tmdbId,
|
||||
requestedBy: { id: user.id },
|
||||
});
|
||||
if (!watchlist) {
|
||||
throw new NotFoundError('not Found');
|
||||
}
|
||||
|
||||
if (watchlist) {
|
||||
await watchlistRepository.delete(watchlist.id);
|
||||
}
|
||||
|
||||
return watchlist;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
@@ -161,7 +162,7 @@ app
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||
secure: 'auto',
|
||||
},
|
||||
store: new TypeormStore({
|
||||
@@ -192,7 +193,8 @@ app
|
||||
});
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
server.use('/imageproxy', imageproxy);
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -22,7 +22,9 @@ export interface SettingsAboutResponse {
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
jellyfinHost?: string;
|
||||
jellyfinExternalHost?: string;
|
||||
jellyfinServerName?: string;
|
||||
jellyfinForgotPasswordUrl?: string;
|
||||
initialized: boolean;
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UserSettingsNotificationsResponse {
|
||||
pushbulletAccessToken?: string;
|
||||
pushoverApplicationToken?: string;
|
||||
pushoverUserKey?: string;
|
||||
pushoverSound?: string;
|
||||
telegramEnabled?: boolean;
|
||||
telegramBotUsername?: string;
|
||||
telegramChatId?: string;
|
||||
|
||||
9
server/interfaces/api/watchlistCreate.ts
Normal file
9
server/interfaces/api/watchlistCreate.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const watchlistCreate = z.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import {
|
||||
jellyfinFullScanner,
|
||||
jellyfinRecentScanner,
|
||||
} from '@server/lib/scanners/jellyfin';
|
||||
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
@@ -8,15 +13,15 @@ import type { JobId } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import watchlistSync from '@server/lib/watchlistsync';
|
||||
import logger from '@server/logger';
|
||||
import random from 'lodash/random';
|
||||
import schedule from 'node-schedule';
|
||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: JobId;
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
@@ -34,7 +39,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
interval: 'short',
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||
job: schedule.scheduleJob(
|
||||
jobs['plex-recently-added-scan'].schedule,
|
||||
@@ -54,7 +59,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
@@ -71,63 +76,73 @@ export const startJobs = (): void => {
|
||||
) {
|
||||
// Run recently added jellyfin sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'jellyfin-recently-added-sync',
|
||||
name: 'Jellyfin Recently Added Sync',
|
||||
id: 'jellyfin-recently-added-scan',
|
||||
name: 'Jellyfin Recently Added Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||
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',
|
||||
});
|
||||
jobJellyfinRecentSync.run();
|
||||
jellyfinRecentScanner.run();
|
||||
}
|
||||
),
|
||||
running: () => jobJellyfinRecentSync.status().running,
|
||||
cancelFn: () => jobJellyfinRecentSync.cancel(),
|
||||
running: () => jellyfinRecentScanner.status().running,
|
||||
cancelFn: () => jellyfinRecentScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full jellyfin sync every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'jellyfin-full-sync',
|
||||
name: 'Jellyfin Full Library Sync',
|
||||
id: 'jellyfin-full-scan',
|
||||
name: 'Jellyfin Full Library Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Jellyfin Full Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
jobJellyfinFullSync.run();
|
||||
jellyfinFullScanner.run();
|
||||
}),
|
||||
running: () => jobJellyfinFullSync.status().running,
|
||||
cancelFn: () => jobJellyfinFullSync.cancel(),
|
||||
running: () => jellyfinFullScanner.status().running,
|
||||
cancelFn: () => jellyfinFullScanner.cancel(),
|
||||
});
|
||||
}
|
||||
|
||||
// Run watchlist sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
// Watchlist Sync
|
||||
const watchlistSyncJob: ScheduledJob = {
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'short',
|
||||
interval: 'fixed',
|
||||
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', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
};
|
||||
|
||||
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||
// after each run
|
||||
watchlistSyncJob.job.on('run', () => {
|
||||
watchlistSyncJob.job.schedule(
|
||||
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||
);
|
||||
});
|
||||
|
||||
scheduledJobs.push(watchlistSyncJob);
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['radarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
@@ -142,7 +157,7 @@ export const startJobs = (): void => {
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
@@ -152,12 +167,29 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Checks if media is still available in plex/sonarr/radarr libs
|
||||
scheduledJobs.push({
|
||||
id: 'availability-sync',
|
||||
name: 'Media Availability Sync',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['availability-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Media Availability Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
availabilitySync.run();
|
||||
}),
|
||||
running: () => availabilitySync.running,
|
||||
cancelFn: () => availabilitySync.cancel(),
|
||||
});
|
||||
|
||||
// Run download sync every minute
|
||||
scheduledJobs.push({
|
||||
id: 'download-sync',
|
||||
name: 'Download Sync',
|
||||
type: 'command',
|
||||
interval: 'fixed',
|
||||
interval: 'seconds',
|
||||
cronSchedule: jobs['download-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', {
|
||||
@@ -172,7 +204,7 @@ export const startJobs = (): void => {
|
||||
id: 'download-sync-reset',
|
||||
name: 'Download Sync Reset',
|
||||
type: 'command',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||
@@ -182,12 +214,12 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Run image cache cleanup every 5 minutes
|
||||
// Run image cache cleanup every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'image-cache-cleanup',
|
||||
name: 'Image Cache Cleanup',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||
|
||||
1091
server/lib/availabilitySync.ts
Normal file
1091
server/lib/availabilitySync.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ export type AvailableCacheIds =
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'imdb'
|
||||
| 'github'
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
@@ -51,6 +52,10 @@ class CacheManager {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
github: new Cache('github', 'GitHub API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
|
||||
@@ -18,14 +18,14 @@ type ImageResponse = {
|
||||
imageBuffer: Buffer;
|
||||
};
|
||||
|
||||
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
||||
: path.join(__dirname, '../../config/cache/images');
|
||||
|
||||
class ImageProxy {
|
||||
public static async clearCache(key: string) {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
@@ -57,11 +57,7 @@ class ImageProxy {
|
||||
public static async getImageStats(
|
||||
key: string
|
||||
): Promise<{ size: number; imageCount: number }> {
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||
@@ -263,7 +259,7 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private getCacheDirectory() {
|
||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
||||
return path.join(baseCacheDirectory, this.key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ class PushoverAgent
|
||||
...notificationPayload,
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
sound: settings.options.sound,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
@@ -198,6 +199,7 @@ class PushoverAgent
|
||||
...notificationPayload,
|
||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||
user: payload.notifyUser.settings.pushoverUserKey,
|
||||
sound: payload.notifyUser.settings.pushoverSound,
|
||||
} as PushoverPayload);
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushover notification', {
|
||||
|
||||
@@ -26,7 +26,7 @@ interface SyncStatus {
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class JobJellyfinSync {
|
||||
class JellyfinScanner {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
private jfClient: JellyfinAPI;
|
||||
@@ -62,7 +62,7 @@ class JobJellyfinSync {
|
||||
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata.Id) {
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: jellyfinitem.Id,
|
||||
@@ -197,6 +197,14 @@ class JobJellyfinSync {
|
||||
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.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) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||
@@ -275,14 +283,14 @@ class JobJellyfinSync {
|
||||
episode.Id
|
||||
);
|
||||
|
||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if (MediaStream.Width ?? 0 < 2000) {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
total4k += episodeCount;
|
||||
} else {
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
} else {
|
||||
total4k += episodeCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -311,13 +319,15 @@ class JobJellyfinSync {
|
||||
// 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
|
||||
existingSeason.status =
|
||||
totalStandard === season.episode_count
|
||||
totalStandard >= season.episode_count ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
this.enable4kShow && total4k === season.episode_count
|
||||
(this.enable4kShow && total4k >= season.episode_count) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
@@ -329,13 +339,13 @@ class JobJellyfinSync {
|
||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||
// if we dont have any items for the season
|
||||
status:
|
||||
totalStandard === season.episode_count
|
||||
totalStandard >= season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow && total4k === season.episode_count
|
||||
this.enable4kShow && total4k >= season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
@@ -451,8 +461,9 @@ class JobJellyfinSync {
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
|
||||
jellyfinMediaId: Id,
|
||||
jellyfinMediaId4k: Id,
|
||||
jellyfinMediaId: isAllStandardSeasons ? Id : undefined,
|
||||
jellyfinMediaId4k:
|
||||
isAll4kSeasons && this.enable4kShow ? Id : undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
@@ -673,7 +684,7 @@ class JobJellyfinSync {
|
||||
}
|
||||
}
|
||||
|
||||
export const jobJellyfinFullSync = new JobJellyfinSync();
|
||||
export const jobJellyfinRecentSync = new JobJellyfinSync({
|
||||
export const jellyfinFullScanner = new JellyfinScanner();
|
||||
export const jellyfinRecentScanner = new JellyfinScanner({
|
||||
isRecentOnly: true,
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export interface JellyfinSettings {
|
||||
name: string;
|
||||
hostname: string;
|
||||
externalHostname?: string;
|
||||
jellyfinForgotPasswordUrl?: string;
|
||||
libraries: Library[];
|
||||
serverId: string;
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export interface DVRSettings {
|
||||
externalUrl?: string;
|
||||
syncEnabled: boolean;
|
||||
preventSearch: boolean;
|
||||
tagRequests: boolean;
|
||||
}
|
||||
|
||||
export interface RadarrSettings extends DVRSettings {
|
||||
@@ -76,6 +78,8 @@ export interface RadarrSettings extends DVRSettings {
|
||||
}
|
||||
|
||||
export interface SonarrSettings extends DVRSettings {
|
||||
seriesType: 'standard' | 'daily' | 'anime';
|
||||
animeSeriesType: 'standard' | 'daily' | 'anime';
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeProfileName?: string;
|
||||
activeAnimeDirectory?: string;
|
||||
@@ -127,6 +131,8 @@ interface FullPublicSettings extends PublicSettings {
|
||||
originalLanguage: string;
|
||||
mediaServerType: number;
|
||||
jellyfinHost?: string;
|
||||
jellyfinExternalHost?: string;
|
||||
jellyfinForgotPasswordUrl?: string;
|
||||
jellyfinServerName?: string;
|
||||
partialRequestsEnabled: boolean;
|
||||
cacheImages: boolean;
|
||||
@@ -203,6 +209,7 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
|
||||
options: {
|
||||
accessToken: string;
|
||||
userToken: string;
|
||||
sound: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,9 +269,10 @@ export type JobId =
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-sync'
|
||||
| 'jellyfin-full-sync'
|
||||
| 'image-cache-cleanup';
|
||||
| 'jellyfin-recently-added-scan'
|
||||
| 'jellyfin-full-scan'
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -325,6 +333,7 @@ class Settings {
|
||||
name: '',
|
||||
hostname: '',
|
||||
externalHostname: '',
|
||||
jellyfinForgotPasswordUrl: '',
|
||||
libraries: [],
|
||||
serverId: '',
|
||||
},
|
||||
@@ -394,6 +403,7 @@ class Settings {
|
||||
options: {
|
||||
accessToken: '',
|
||||
userToken: '',
|
||||
sound: '',
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
@@ -402,7 +412,7 @@ class Settings {
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
webpush: {
|
||||
@@ -435,16 +445,19 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'download-sync': {
|
||||
schedule: '0 * * * * *',
|
||||
},
|
||||
'download-sync-reset': {
|
||||
schedule: '0 0 1 * * *',
|
||||
},
|
||||
'jellyfin-recently-added-sync': {
|
||||
'jellyfin-recently-added-scan': {
|
||||
schedule: '0 */5 * * * *',
|
||||
},
|
||||
'jellyfin-full-sync': {
|
||||
'jellyfin-full-scan': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'image-cache-cleanup': {
|
||||
@@ -524,6 +537,7 @@ class Settings {
|
||||
applicationUrl: this.data.main.applicationUrl,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
localLogin: this.data.main.localLogin,
|
||||
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
),
|
||||
@@ -534,6 +548,7 @@ class Settings {
|
||||
originalLanguage: this.data.main.originalLanguage,
|
||||
mediaServerType: this.main.mediaServerType,
|
||||
jellyfinHost: this.jellyfin.hostname,
|
||||
jellyfinExternalHost: this.jellyfin.externalHostname,
|
||||
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
||||
cacheImages: this.data.main.cacheImages,
|
||||
vapidPublic: this.vapidPublic,
|
||||
@@ -590,7 +605,7 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
}
|
||||
|
||||
private generateVapidKeys(force = false): void {
|
||||
|
||||
@@ -65,6 +65,7 @@ class WatchlistSync {
|
||||
const response = await plexTvApi.getWatchlist({ size: 200 });
|
||||
|
||||
const mediaItems = await Media.getRelatedMedia(
|
||||
user,
|
||||
response.items.map((i) => i.tmdbId)
|
||||
);
|
||||
|
||||
@@ -79,82 +80,80 @@ class WatchlistSync {
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
unavailableItems.map(async (mediaItem) => {
|
||||
try {
|
||||
logger.info("Creating media request from user's Plex Watchlist", {
|
||||
label: 'Watchlist Sync',
|
||||
userId: user.id,
|
||||
mediaTitle: mediaItem.title,
|
||||
});
|
||||
for (const mediaItem of unavailableItems) {
|
||||
try {
|
||||
logger.info("Creating media request from user's Plex Watchlist", {
|
||||
label: 'Watchlist Sync',
|
||||
userId: user.id,
|
||||
mediaTitle: mediaItem.title,
|
||||
});
|
||||
|
||||
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
||||
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')
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
server/middleware/clearcookies.ts
Normal file
6
server/middleware/clearcookies.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const clearCookies: Middleware = (_req, res, next) => {
|
||||
res.removeHeader('Set-Cookie');
|
||||
next();
|
||||
};
|
||||
|
||||
export default clearCookies;
|
||||
19
server/migration/1682608634546-AddWatchlists.ts
Normal file
19
server/migration/1682608634546-AddWatchlists.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWatchlists1682608634546 implements MigrationInterface {
|
||||
name = 'AddWatchlists1682608634546';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -9,7 +10,7 @@ import type {
|
||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person';
|
||||
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
@@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -53,7 +66,7 @@ export interface PersonResult {
|
||||
knownFor: (MovieResult | TvResult)[];
|
||||
}
|
||||
|
||||
export type Results = MovieResult | TvResult | PersonResult;
|
||||
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
||||
|
||||
export const mapMovieResult = (
|
||||
movieResult: TmdbMovieResult,
|
||||
@@ -99,6 +112,20 @@ export const mapTvResult = (
|
||||
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 = (
|
||||
personResult: TmdbPersonResult
|
||||
): PersonResult => ({
|
||||
@@ -118,7 +145,12 @@ export const mapPersonResult = (
|
||||
});
|
||||
|
||||
export const mapSearchResults = (
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[],
|
||||
media?: Media[]
|
||||
): Results[] =>
|
||||
results.map((result) => {
|
||||
@@ -139,6 +171,8 @@ export const mapSearchResults = (
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||
)
|
||||
);
|
||||
case 'collection':
|
||||
return mapCollectionResult(result);
|
||||
default:
|
||||
return mapPersonResult(result);
|
||||
}
|
||||
|
||||
11
server/repositories/watchlist.repository.ts
Normal file
11
server/repositories/watchlist.repository.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
|
||||
export const UserRepository = getRepository(Watchlist).extend({
|
||||
// findByName(firstName: string, lastName: string) {
|
||||
// return this.createQueryBuilder("user")
|
||||
// .where("user.firstName = :firstName", { firstName })
|
||||
// .andWhere("user.lastName = :lastName", { lastName })
|
||||
// .getMany()
|
||||
// },
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
@@ -274,24 +275,87 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
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
|
||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
}
|
||||
|
||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||
if (account.User.PrimaryImageTag) {
|
||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||
} else {
|
||||
user.avatar = '/os_logo_square.png';
|
||||
user.avatar = gravatarUrl(user.email, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
}
|
||||
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
|
||||
if (user.username === account.User.Name) {
|
||||
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);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
@@ -307,69 +371,38 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
// Here we check if it's the first user. If it is, we create the user with no check
|
||||
// and give them admin permissions
|
||||
const totalUsers = await userRepository.count();
|
||||
if (totalUsers === 0) {
|
||||
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 = new User({
|
||||
email: body.email,
|
||||
} else if (!user) {
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
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) {
|
||||
if (!body.email) {
|
||||
throw new Error('add_email');
|
||||
}
|
||||
|
||||
user = new User({
|
||||
email: body.email,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: '/os_logo_square.png',
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
//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);
|
||||
user = new User({
|
||||
email: body.email,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
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,
|
||||
});
|
||||
//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);
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
@@ -380,7 +413,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
if (e.message === 'Unauthorized') {
|
||||
logger.info(
|
||||
logger.warn(
|
||||
'Failed login attempt from user with incorrect Jellyfin credentials',
|
||||
{
|
||||
label: 'Auth',
|
||||
@@ -395,11 +428,21 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
status: 401,
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
} else if (e.message === 'not_admin') {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'CREDENTIAL_ERROR_NOT_ADMIN',
|
||||
});
|
||||
} else if (e.message === 'add_email') {
|
||||
return next({
|
||||
status: 406,
|
||||
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
|
||||
});
|
||||
} else if (e.message === 'select_server_type') {
|
||||
return next({
|
||||
status: 406,
|
||||
message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE',
|
||||
});
|
||||
} else {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
return next({
|
||||
|
||||
@@ -16,6 +16,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
collection.parts.map((part) => part.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type {
|
||||
GenreSliderItem,
|
||||
WatchlistResponse,
|
||||
@@ -14,12 +15,13 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import {
|
||||
mapCollectionResult,
|
||||
mapMovieResult,
|
||||
mapPersonResult,
|
||||
mapTvResult,
|
||||
} from '@server/models/Search';
|
||||
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 { sortBy } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
@@ -64,6 +66,8 @@ const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.coerce.string().optional(),
|
||||
voteAverageGte: z.coerce.string().optional(),
|
||||
voteAverageLte: z.coerce.string().optional(),
|
||||
voteCountGte: z.coerce.string().optional(),
|
||||
voteCountLte: z.coerce.string().optional(),
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
@@ -95,11 +99,14 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -164,6 +171,7 @@ discoverRoutes.get<{ language: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -221,6 +229,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -268,6 +277,7 @@ discoverRoutes.get<{ studioId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -317,6 +327,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -370,11 +381,14 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -438,6 +452,7 @@ discoverRoutes.get<{ language: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -495,6 +510,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -542,6 +558,7 @@ discoverRoutes.get<{ networkId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -591,6 +608,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -629,6 +647,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -647,6 +666,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
)
|
||||
: isPerson(result)
|
||||
? mapPersonResult(result)
|
||||
: isCollection(result)
|
||||
? mapCollectionResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
@@ -681,6 +702,7 @@ discoverRoutes.get<{ keywordId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -800,12 +822,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
'/watchlist',
|
||||
async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const page = Number(req.query.page) ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const activeUser = await userRepository.findOne({
|
||||
@@ -813,6 +835,25 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (activeUser) {
|
||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||
where: { requestedBy: { id: activeUser?.id } },
|
||||
relations: {
|
||||
/*requestedBy: true,media:true*/
|
||||
},
|
||||
// loadRelationIds: true,
|
||||
take: itemsPerPage,
|
||||
skip: offset,
|
||||
});
|
||||
if (total) {
|
||||
return res.json({
|
||||
page: page,
|
||||
totalPages: Math.ceil(total / itemsPerPage),
|
||||
totalResults: total,
|
||||
results: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!activeUser?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
@@ -829,8 +870,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||
totalResults: watchlist.totalSize,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GithubAPI from '@server/api/github';
|
||||
import PushoverAPI from '@server/api/pushover';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbMovieResult,
|
||||
@@ -15,6 +16,7 @@ import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import watchlistRoutes from '@server/routes/watchlist';
|
||||
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
@@ -112,10 +114,36 @@ router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
|
||||
|
||||
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('/search', isAuthenticated(), searchRoutes);
|
||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||
router.use('/request', isAuthenticated(), requestRoutes);
|
||||
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -168,6 +171,100 @@ mediaRoutes.delete(
|
||||
}
|
||||
);
|
||||
|
||||
mediaRoutes.delete(
|
||||
'/:id/file',
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
const is4k = media.serviceUrl4k !== undefined;
|
||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||
let serviceSettings;
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
media.serviceId &&
|
||||
media.serviceId >= 0 &&
|
||||
serviceSettings?.id !== media.serviceId
|
||||
) {
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === media.serviceId
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === media.serviceId
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!serviceSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
}/ server configured. Did you set any of your ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
} servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
mediaId: media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
let service;
|
||||
if (isMovie) {
|
||||
service = new RadarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
} else {
|
||||
service = new SonarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMovie) {
|
||||
await (service as RadarrAPI).removeMovie(
|
||||
parseInt(
|
||||
is4k
|
||||
? (media.externalServiceSlug4k as string)
|
||||
: (media.externalServiceSlug as string)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
if (!tvdbId) {
|
||||
throw new Error('TVDB ID not found');
|
||||
}
|
||||
await (service as SonarrAPI).removeSerie(tvdbId);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching media in delete request', {
|
||||
label: 'Media',
|
||||
message: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Media not found' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
|
||||
'/:id/watch_data',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
|
||||
@@ -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 { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -45,6 +47,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -86,6 +89,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -116,6 +120,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint backed by RottenTomatoes
|
||||
*/
|
||||
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const rtapi = new RottenTomatoes();
|
||||
@@ -151,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;
|
||||
|
||||
@@ -42,10 +42,12 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ searchRoutes.get('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
|
||||
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,12 +12,12 @@ import type {
|
||||
LogsResultsResponse,
|
||||
SettingsAboutResponse,
|
||||
} from '@server/interfaces/api/settingsInterfaces';
|
||||
import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
|
||||
import { scheduledJobs } from '@server/job/schedule';
|
||||
import type { AvailableCacheIds } from '@server/lib/cache';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
|
||||
import { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { JobId, Library, MainSettings } 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 rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
|
||||
import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
@@ -260,7 +261,7 @@ settingsRoutes.post('/jellyfin', (req, res) => {
|
||||
return res.status(200).json(settings.jellyfin);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
||||
settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
if (req.query.sync) {
|
||||
@@ -280,6 +281,19 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
||||
|
||||
const libraries = await jellyfinClient.getLibraries();
|
||||
|
||||
if (libraries.length === 0) {
|
||||
// Check if no libraries are found due to the fallback to user views
|
||||
// This only affects LDAP users
|
||||
const account = await jellyfinClient.getUser();
|
||||
|
||||
// Automatic Library grouping is not supported when user views are used to get library
|
||||
if (account.Configuration.GroupedFolders.length > 0) {
|
||||
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
|
||||
}
|
||||
|
||||
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
|
||||
}
|
||||
|
||||
const newLibraries: Library[] = libraries.map((library) => {
|
||||
const existing = settings.jellyfin.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
@@ -337,7 +351,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
id: user.Id,
|
||||
thumb: user.PrimaryImageTag
|
||||
? `${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,
|
||||
}));
|
||||
|
||||
@@ -345,16 +359,16 @@ settingsRoutes.get('/jellyfin/users', async (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) => {
|
||||
if (req.body.cancel) {
|
||||
jobJellyfinFullSync.cancel();
|
||||
jellyfinFullScanner.cancel();
|
||||
} 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) => {
|
||||
const settings = getSettings();
|
||||
@@ -367,25 +381,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
||||
|
||||
Object.assign(settings.tautulli, req.body);
|
||||
|
||||
try {
|
||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||
if (settings.tautulli.hostname) {
|
||||
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')) {
|
||||
throw new Error('Tautulli version not supported');
|
||||
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -69,6 +69,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -109,6 +110,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
QuotaResponse,
|
||||
@@ -382,7 +383,14 @@ router.delete<{ id: string }>(
|
||||
* we manually remove all requests from the user here so the parent media's
|
||||
* 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);
|
||||
return res.status(200).json(user.filter());
|
||||
@@ -529,7 +537,10 @@ router.post(
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: jellyfinUser?.PrimaryImageTag
|
||||
? `${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,
|
||||
});
|
||||
|
||||
@@ -685,7 +696,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
router.get<{ id: string }, WatchlistResponse>(
|
||||
'/:id/watchlist',
|
||||
async (req, res, next) => {
|
||||
if (
|
||||
@@ -699,22 +710,41 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message:
|
||||
"You do not have permission to view this user's Plex Watchlist.",
|
||||
message: "You do not have permission to view this user's Watchlist.",
|
||||
});
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
const page = req.params.page ?? 1;
|
||||
const page = Number(req.query.page) ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const user = await getRepository(User).findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
select: { id: true, plexToken: true },
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (!user?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
if (user) {
|
||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||
where: { requestedBy: { id: user?.id } },
|
||||
relations: {
|
||||
/*requestedBy: true,media:true*/
|
||||
},
|
||||
// loadRelationIds: true,
|
||||
take: itemsPerPage,
|
||||
skip: offset,
|
||||
});
|
||||
if (total) {
|
||||
return res.json({
|
||||
page: page,
|
||||
totalPages: Math.ceil(total / itemsPerPage),
|
||||
totalResults: total,
|
||||
results: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
if (!user.plexToken) {
|
||||
return res.json({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
@@ -729,8 +759,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||
totalResults: watchlist.totalSize,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
|
||||
@@ -265,7 +265,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
emailEnabled: settings?.email.enabled,
|
||||
emailEnabled: settings.email.enabled,
|
||||
pgpKey: user.settings?.pgpKey,
|
||||
discordEnabled:
|
||||
settings?.discord.enabled && settings.discord.options.enableMentions,
|
||||
@@ -277,11 +277,12 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramEnabled: settings?.telegram.enabled,
|
||||
telegramBotUsername: settings?.telegram.options.botUsername,
|
||||
pushoverSound: user.settings?.pushoverSound,
|
||||
telegramEnabled: settings.telegram.enabled,
|
||||
telegramBotUsername: settings.telegram.options.botUsername,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||
webPushEnabled: settings?.webpush.enabled,
|
||||
telegramSendSilently: user.settings?.telegramSendSilently,
|
||||
webPushEnabled: settings.webpush.enabled,
|
||||
notificationTypes: user.settings?.notificationTypes ?? {},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -332,6 +333,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
user.settings.pushoverApplicationToken =
|
||||
req.body.pushoverApplicationToken;
|
||||
user.settings.pushoverUserKey = req.body.pushoverUserKey;
|
||||
user.settings.pushoverSound = req.body.pushoverSound;
|
||||
user.settings.telegramChatId = req.body.telegramChatId;
|
||||
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
||||
user.settings.notificationTypes = Object.assign(
|
||||
@@ -344,13 +346,14 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
userRepository.save(user);
|
||||
|
||||
return res.status(200).json({
|
||||
pgpKey: user.settings?.pgpKey,
|
||||
discordId: user.settings?.discordId,
|
||||
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings?.pushoverUserKey,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||
pgpKey: user.settings.pgpKey,
|
||||
discordId: user.settings.discordId,
|
||||
pushbulletAccessToken: user.settings.pushbulletAccessToken,
|
||||
pushoverApplicationToken: user.settings.pushoverApplicationToken,
|
||||
pushoverUserKey: user.settings.pushoverUserKey,
|
||||
pushoverSound: user.settings.pushoverSound,
|
||||
telegramChatId: user.settings.telegramChatId,
|
||||
telegramSendSilently: user.settings.telegramSendSilently,
|
||||
notificationTypes: user.settings.notificationTypes,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
73
server/routes/watchlist.ts
Normal file
73
server/routes/watchlist.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DuplicateWatchlistRequestError,
|
||||
NotFoundError,
|
||||
Watchlist,
|
||||
} from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
import { watchlistCreate } from '@server/interfaces/api/watchlistCreate';
|
||||
|
||||
const watchlistRoutes = Router();
|
||||
|
||||
watchlistRoutes.post<never, Watchlist, Watchlist>(
|
||||
'/',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You must be logged in to add watchlist.',
|
||||
});
|
||||
}
|
||||
const values = watchlistCreate.parse(req.body);
|
||||
|
||||
const request = await Watchlist.createWatchlist({
|
||||
watchlistRequest: values,
|
||||
user: req.user,
|
||||
});
|
||||
return res.status(201).json(request);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
switch (error.constructor) {
|
||||
case QueryFailedError:
|
||||
logger.warn('Something wrong with data watchlist', {
|
||||
tmdbId: req.body.tmdbId,
|
||||
mediaType: req.body.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
return next({ status: 409, message: 'Something wrong' });
|
||||
case DuplicateWatchlistRequestError:
|
||||
return next({ status: 409, message: error.message });
|
||||
default:
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You must be logged in to delete watchlist data.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user);
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default watchlistRoutes;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -8,17 +9,35 @@ import type {
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
|
||||
export const isMovie = (
|
||||
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
movie:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
): movie is TmdbMovieResult => {
|
||||
return (movie as TmdbMovieResult).title !== undefined;
|
||||
};
|
||||
|
||||
export const isPerson = (
|
||||
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
person:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
): person is TmdbPersonResult => {
|
||||
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 = (
|
||||
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
||||
): movie is TmdbMovieDetails => {
|
||||
|
||||
@@ -6,7 +6,7 @@ description: >
|
||||
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 integrates with your existing services such as Sonarr, Radarr, and Jellyfin/Emby/Plex.
|
||||
base: core18
|
||||
base: core20
|
||||
confinement: strict
|
||||
|
||||
architectures:
|
||||
@@ -16,12 +16,12 @@ architectures:
|
||||
|
||||
parts:
|
||||
jellyseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: '16.17.0'
|
||||
nodejs-package-manager: 'yarn'
|
||||
nodejs-yarn-version: v1.22.17
|
||||
plugin: nil
|
||||
build-packages:
|
||||
- git
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
- on arm64:
|
||||
- build-essential
|
||||
- automake
|
||||
@@ -37,7 +37,7 @@ parts:
|
||||
override-pull: |
|
||||
snapcraftctl pull
|
||||
# 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
|
||||
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
@@ -65,13 +65,30 @@ parts:
|
||||
snapcraftctl set-version "$SNAP_VERSION"
|
||||
snapcraftctl set-grade "$GRADE"
|
||||
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'
|
||||
override-build: |
|
||||
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
|
||||
export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt)
|
||||
snapcraftctl build
|
||||
yarn install --frozen-lockfile --network-timeout 1000000
|
||||
yarn build
|
||||
# Copy files needed for staging
|
||||
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/node_modules $SNAPCRAFT_PART_INSTALL/
|
||||
# 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:
|
||||
- on armhf:
|
||||
- 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 |
@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { Collection } from '@server/models/Collection';
|
||||
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||
fallbackData: collection,
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
return [
|
||||
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||
const [downloadStatus, downloadStatus4k] = [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
return { downloadStatus, downloadStatus4k };
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||
fallbackData: collection,
|
||||
revalidateOnMount: true,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
returnCollectionDownloadItems(collection),
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
const downloadItems = returnCollectionDownloadItems(data);
|
||||
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
||||
}, [data]);
|
||||
|
||||
const [titles, titles4k] = useMemo(() => {
|
||||
return [
|
||||
@@ -326,6 +338,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<TitleCard
|
||||
key={`collection-movie-${title.id}`}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -336,7 +349,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
<div className="extra-bottom-space relative" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ const Badge = (
|
||||
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
|
||||
);
|
||||
if (href) {
|
||||
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
|
||||
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isOpen}
|
||||
enter="transition ease-out duration-100 opacity-0"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75 opacity-100"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
CollectionResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
@@ -12,12 +13,13 @@ import type {
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
type ListViewProps = {
|
||||
items?: (TvResult | MovieResult | PersonResult)[];
|
||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||
plexItems?: WatchlistItem[];
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
isReachingEnd?: boolean;
|
||||
onScrollBottom: () => void;
|
||||
mutateParent?: () => void;
|
||||
};
|
||||
|
||||
const ListView = ({
|
||||
@@ -27,6 +29,7 @@ const ListView = ({
|
||||
onScrollBottom,
|
||||
isReachingEnd,
|
||||
plexItems,
|
||||
mutateParent,
|
||||
}: ListViewProps) => {
|
||||
const intl = useIntl();
|
||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||
@@ -45,7 +48,9 @@ const ListView = ({
|
||||
id={title.tmdbId}
|
||||
tmdbId={title.tmdbId}
|
||||
type={title.mediaType}
|
||||
isAddedToWatchlist={true}
|
||||
canExpand
|
||||
mutateParent={mutateParent}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -57,7 +62,9 @@ const ListView = ({
|
||||
case 'movie':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -75,7 +82,9 @@ const ListView = ({
|
||||
case 'tv':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -90,6 +99,18 @@ const ListView = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'collection':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
mediaType={title.mediaType}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'person':
|
||||
titleCard = (
|
||||
<PersonCard
|
||||
|
||||
@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
appear
|
||||
as="div"
|
||||
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
ref={parentRef}
|
||||
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
<Transition
|
||||
appear
|
||||
as={Fragment}
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={loading}
|
||||
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
}}
|
||||
appear
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={!loading}
|
||||
|
||||
@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -37,10 +37,10 @@ const SlideOver = ({
|
||||
as={Fragment}
|
||||
show={show}
|
||||
appear
|
||||
enter="opacity-0 transition ease-in-out duration-300"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition ease-in-out duration-300"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
@@ -58,16 +58,16 @@ const SlideOver = ({
|
||||
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
||||
<Transition.Child
|
||||
appear
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enter="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leave="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
|
||||
ref={slideoverRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import MediaSlider from '@app/components/MediaSlider';
|
||||
import { WatchProviderSelector } from '@app/components/Selector';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
@@ -55,7 +56,7 @@ type CreateOption = {
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText: string;
|
||||
dataPlaceholderText?: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
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 (
|
||||
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
/>
|
||||
);
|
||||
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:
|
||||
dataInput = (
|
||||
<Field
|
||||
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={
|
||||
activeOption.type ===
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
|
||||
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
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:
|
||||
return 'Unknown Slider';
|
||||
}
|
||||
@@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
|
||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
<div>{getSliderTitle(slider)}</div>
|
||||
<div className="w-7/12 truncate md:w-full">
|
||||
{getSliderTitle(slider)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discoverwatchlist: 'Your Plex Watchlist',
|
||||
discoverwatchlist: 'Your Watchlist',
|
||||
watchlist: 'Plex Watchlist',
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const DiscoverWatchlist = () => {
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
mutate,
|
||||
} = useDiscover<WatchlistItem>(
|
||||
`/api/v1/${
|
||||
router.pathname.startsWith('/profile')
|
||||
@@ -76,6 +77,7 @@ const DiscoverWatchlist = () => {
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
mutateParent={mutate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,10 @@ const messages = defineMessages({
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
tmdbuservotecount: 'TMDB User Vote Count',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -246,6 +248,45 @@ const FilterSlideover = ({
|
||||
})}
|
||||
/>
|
||||
</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">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
|
||||
@@ -139,6 +139,12 @@ const networks: Network[] = [
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||
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 = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { UserType, useUser } from '@app/hooks/useUser';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import Link from 'next/link';
|
||||
@@ -8,7 +8,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
plexwatchlist: 'Your Watchlist',
|
||||
emptywatchlist:
|
||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||
});
|
||||
@@ -22,12 +22,11 @@ const PlexWatchlistSlider = () => {
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||
}>('/api/v1/discover/watchlist', {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
if (
|
||||
user?.userType !== UserType.PLEX ||
|
||||
(watchlistItems &&
|
||||
watchlistItems.results.length === 0 &&
|
||||
!user?.settings?.watchlistSyncMovies &&
|
||||
@@ -69,6 +68,7 @@ const PlexWatchlistSlider = () => {
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
isAddedToWatchlist={true}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const sliderTitles = defineMessages({
|
||||
recentlyAdded: 'Recently Added',
|
||||
upcoming: 'Upcoming Movies',
|
||||
trending: 'Trending',
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
plexwatchlist: 'Your Watchlist',
|
||||
moviegenres: 'Movie Genres',
|
||||
tvgenres: 'Series Genres',
|
||||
studios: 'Studios',
|
||||
@@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
|
||||
tmdbnetwork: 'TMDB Network',
|
||||
tmdbstudio: 'TMDB Studio',
|
||||
tmdbsearch: 'TMDB Search',
|
||||
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
@@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
voteCountLte: z.string().optional(),
|
||||
voteCountGte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
});
|
||||
@@ -167,6 +171,14 @@ export const prepareFilterValues = (
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
if (values.voteCountGte) {
|
||||
filterValues.voteCountGte = values.voteCountGte;
|
||||
}
|
||||
|
||||
if (values.voteCountLte) {
|
||||
filterValues.voteCountLte = values.voteCountLte;
|
||||
}
|
||||
|
||||
if (values.watchProviders) {
|
||||
filterValues.watchProviders = values.watchProviders;
|
||||
}
|
||||
@@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.voteAverageLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.voteCountGte;
|
||||
delete clonedFilters.voteCountLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.withRuntimeGte;
|
||||
|
||||
@@ -165,10 +165,10 @@ const Discover = () => {
|
||||
</Transition>
|
||||
<Transition
|
||||
show={isEditing}
|
||||
enter="transition transform duration-300"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 translate-y-6"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition duration-300 transform"
|
||||
leave="transition duration-300"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-6"
|
||||
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
@@ -365,6 +365,36 @@ const Discover = () => {
|
||||
/>
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import EmbyLogo from '@app/assets/services/emby.svg';
|
||||
import ImdbLogo from '@app/assets/services/imdb.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 RTLogo from '@app/assets/services/rt.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 { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -28,6 +31,7 @@ const ExternalLinkBlock = ({
|
||||
mediaUrl,
|
||||
}: ExternalLinkBlockProps) => {
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -41,6 +45,8 @@ const ExternalLinkBlock = ({
|
||||
>
|
||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLogo />
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
<EmbyLogo />
|
||||
) : (
|
||||
<JellyfinLogo />
|
||||
)}
|
||||
@@ -98,6 +104,16 @@ const ExternalLinkBlock = ({
|
||||
<TraktLogo />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,10 +65,10 @@ const IssueComment = ({
|
||||
>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
@@ -115,11 +115,11 @@ const IssueComment = ({
|
||||
as={Fragment}
|
||||
show={open}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
@@ -164,7 +164,7 @@ const IssueComment = ({
|
||||
</Menu>
|
||||
)}
|
||||
<div
|
||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||
isReversed ? '-left-1' : '-right-1'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -57,11 +57,11 @@ const IssueDescription = ({
|
||||
show={open}
|
||||
as="div"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
|
||||
@@ -187,10 +187,10 @@ const IssueDetails = () => {
|
||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
|
||||
@@ -12,10 +12,10 @@ interface IssueModalProps {
|
||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
|
||||
@@ -34,12 +34,12 @@ const LanguagePicker = () => {
|
||||
<Transition
|
||||
as="div"
|
||||
show={isDropdownOpen}
|
||||
enter="transition ease-out duration-100 opacity-0"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75 opacity-100"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user