mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
162 Commits
v1.2.1
...
preview-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf04f2abd | ||
|
|
bc9017f54d | ||
|
|
ee23de6d2f | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
ddbc377d79 | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
7bd4c4d1d4 | ||
|
|
3005e577d7 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
572a7db4aa | ||
|
|
6f23abaa6d | ||
|
|
81518df89a | ||
|
|
604335a16d | ||
|
|
78ccea94bd | ||
|
|
a487ab4506 | ||
|
|
c93467b3ac | ||
|
|
c709e8596a | ||
|
|
26e49e73a5 | ||
|
|
d954328911 | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
4c22a71cdf | ||
|
|
b9e7933e09 | ||
|
|
2508b4340f | ||
|
|
e1f67ad8ba | ||
|
|
91abdb2ba5 | ||
|
|
8942eb8b7c | ||
|
|
ad3d922440 | ||
|
|
51b05cd8fb | ||
|
|
374c78c989 | ||
|
|
507693881b | ||
|
|
f4a22dc437 | ||
|
|
5d1c6f7065 | ||
|
|
3db010b9ea | ||
|
|
fd219717c0 | ||
|
|
d328485161 | ||
|
|
da00d454e1 | ||
|
|
d7bfc73727 | ||
|
|
5940ff7f5f | ||
|
|
fcbca1722f | ||
|
|
19370f856c | ||
|
|
154f3e72ef | ||
|
|
6fd11cf425 | ||
|
|
1154156459 | ||
|
|
cb650745f6 | ||
|
|
3aefddd488 | ||
|
|
1bf0103422 | ||
|
|
a672b324ec | ||
|
|
a343f8ad91 | ||
|
|
7a69cb35f8 | ||
|
|
c2a1a20a3b | ||
|
|
dd00e48f59 | ||
|
|
b5157010c4 | ||
|
|
812fb2f087 | ||
|
|
7b6db50ae5 | ||
|
|
3ba6df1a41 | ||
|
|
2eebb7fd39 | ||
|
|
c60667ba63 | ||
|
|
7d6831483a | ||
|
|
58c5c27929 | ||
|
|
f5c7a4fa97 | ||
|
|
50e85c975a | ||
|
|
62e2de70bf | ||
|
|
0683f4f000 | ||
|
|
b1bd569335 | ||
|
|
a49ea92692 | ||
|
|
d23b2132de | ||
|
|
8bd10b5bf3 | ||
|
|
08c9085f0d | ||
|
|
0d8b390b67 | ||
|
|
19b4dc424f | ||
|
|
7fef48df63 | ||
|
|
8220ea55ae | ||
|
|
4de4a1a52c | ||
|
|
042a1a950f | ||
|
|
421029ebab | ||
|
|
4e9be7a3f7 | ||
|
|
af4a3b4279 | ||
|
|
5ce59cc2ee | ||
|
|
ff2fa66002 | ||
|
|
9388a1e61c | ||
|
|
b44a1b4a99 | ||
|
|
6f73dbc36a | ||
|
|
9d3446d370 | ||
|
|
2f680b4cec | ||
|
|
2179637d43 | ||
|
|
e084649878 | ||
|
|
edf5010659 | ||
|
|
93afead92e | ||
|
|
dd48d59b20 | ||
|
|
c4b16abc62 | ||
|
|
1a95d423f2 | ||
|
|
cd3574851a | ||
|
|
299f65c597 | ||
|
|
6021d1e336 | ||
|
|
3ce1ef350e | ||
|
|
06c91744f3 | ||
|
|
f14d9407d8 | ||
|
|
68223f4b1e | ||
|
|
76335ec8d3 | ||
|
|
2714cbcefd | ||
|
|
3309f77aa4 | ||
|
|
6face8cc45 | ||
|
|
27feeea691 | ||
|
|
03853a1b91 | ||
|
|
357cab87ac | ||
|
|
d18e3d185f | ||
|
|
e222463a63 | ||
|
|
03b9bda287 | ||
|
|
7e20c7cb78 | ||
|
|
d0cdce9e90 | ||
|
|
113b09bf2b | ||
|
|
b16f192b92 | ||
|
|
d9ca3c6e52 | ||
|
|
ba82ecec5c | ||
|
|
c052a2455c | ||
|
|
2d99a8b03c | ||
|
|
7434c0cf2f | ||
|
|
afcb096f49 | ||
|
|
9dc11cedbf | ||
|
|
22aab783d4 | ||
|
|
a2babb83ad | ||
|
|
76a7ceb758 | ||
|
|
9688acaa87 | ||
|
|
64339e5f03 | ||
|
|
1ceea3dcca | ||
|
|
e3c3283603 | ||
|
|
4ac02d3aac | ||
|
|
8eacfe045f | ||
|
|
15e246929b | ||
|
|
c1424634fb | ||
|
|
07ec3efbca | ||
|
|
9b07b10901 | ||
|
|
b1e9cdbea2 | ||
|
|
9aee630392 | ||
|
|
6b50f77624 | ||
|
|
16f1c286c4 | ||
|
|
64aab6dd82 | ||
|
|
144bb84bdc | ||
|
|
76260f9b22 | ||
|
|
500cd1f872 | ||
|
|
bfe56c3470 | ||
|
|
1dfa9431a9 | ||
|
|
bcd2bb7c96 | ||
|
|
5a72f5f86e | ||
|
|
7d4455ba6b | ||
|
|
2e7458457e |
@@ -737,6 +737,78 @@
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Eclipseop",
|
||||
"name": "Mackenzie",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
|
||||
"profile": "https://github.com/Eclipseop",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "s0up4200",
|
||||
"name": "soup",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
|
||||
"profile": "https://github.com/s0up4200",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ceptonit",
|
||||
"name": "ceptonit",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
|
||||
"profile": "https://github.com/ceptonit",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "aedelbro",
|
||||
"name": "aedelbro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
|
||||
"profile": "https://github.com/aedelbro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lunks",
|
||||
"name": "Pedro Nascimento",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
|
||||
"profile": "http://twitter.com/lunks/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "owenvoke",
|
||||
"name": "Owen Voke",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
|
||||
"profile": "https://voke.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Nimelrian",
|
||||
"name": "Sebastian K",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
|
||||
"profile": "https://github.com/Nimelrian",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jariz",
|
||||
"name": "jariz",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
|
||||
"profile": "https://github.com/jariz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
@@ -745,5 +817,6 @@
|
||||
"projectOwner": "sct",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": false
|
||||
"skipCi": false,
|
||||
"commitConvention": "angular"
|
||||
}
|
||||
|
||||
5
.github/holopin.yml
vendored
Normal file
5
.github/holopin.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
organization: overseerr
|
||||
defaultSticker: clcyagj1j329008l468ya8pu2
|
||||
stickers:
|
||||
- id: clcyagj1j329008l468ya8pu2
|
||||
alias: overseerr-contributor
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -91,9 +91,9 @@ jobs:
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
|
||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
schedule:
|
||||
- cron: '50 7 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
- name: Get the version
|
||||
id: get_version
|
||||
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -51,8 +51,8 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Switch to master branch
|
||||
run: git checkout master
|
||||
- name: Switch to main branch
|
||||
run: git checkout main
|
||||
- name: Pull latest changes
|
||||
run: git pull
|
||||
- name: Prepare
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
run: |
|
||||
git fetch --prune --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
@@ -84,8 +84,9 @@ jobs:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
if: always()
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
@@ -102,9 +103,9 @@ jobs:
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
|
||||
13
.github/workflows/snap.yaml
vendored
13
.github/workflows/snap.yaml
vendored
@@ -35,9 +35,9 @@ jobs:
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
echo "RELEASE=stable" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
echo "RELEASE=edge" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
name: jellyseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
@@ -57,8 +57,9 @@ jobs:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
@@ -75,9 +76,9 @@ jobs:
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,3 +67,6 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# Webstorm
|
||||
.idea
|
||||
|
||||
# Config Cache Directory
|
||||
config/cache
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.next/
|
||||
dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
98
CHANGELOG.md
98
CHANGELOG.md
@@ -1,95 +1,23 @@
|
||||
## [1.2.1](https://github.com/fallenbagel/jellyseerr/compare/v1.2.0...v1.2.1) (2022-10-18)
|
||||
|
||||
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** fix jellyfinHost to not be undefined ([ab09664](https://github.com/fallenbagel/jellyseerr/commit/ab09664d418e07198ac72e7f342b53ba441ab7c3)), closes [#237](https://github.com/fallenbagel/jellyseerr/issues/237)
|
||||
|
||||
# [1.2.0](https://github.com/fallenbagel/jellyseerr/compare/v1.1.1...v1.2.0) (2022-10-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** add rate limiter to TMDb requests to hopefully deal with 429s ([#2881](https://github.com/fallenbagel/jellyseerr/issues/2881)) ([aed1409](https://github.com/fallenbagel/jellyseerr/commit/aed1409f29d5b6360e87381d78dfeb4cc86d6fc6)), closes [#2853](https://github.com/fallenbagel/jellyseerr/issues/2853)
|
||||
* **api:** ignore filter if unset in media route ([#2647](https://github.com/fallenbagel/jellyseerr/issues/2647)) ([a6c1f3f](https://github.com/fallenbagel/jellyseerr/commit/a6c1f3f7ce498e32817cc8c74d439e8d99d6fbf4))
|
||||
* **api:** lookup shows using english title only ([#2911](https://github.com/fallenbagel/jellyseerr/issues/2911)) ([004e1bb](https://github.com/fallenbagel/jellyseerr/commit/004e1bb17e14bef1697b6b993f6ff92b77cdaeb4)), closes [#2801](https://github.com/fallenbagel/jellyseerr/issues/2801)
|
||||
* **api:** use correct path param type in openapi spec ([#2834](https://github.com/fallenbagel/jellyseerr/issues/2834)) ([6cd0c9b](https://github.com/fallenbagel/jellyseerr/commit/6cd0c9b2c81db2728ce09a9bc273a9aee366acbc))
|
||||
* **backend:** fixes Jellyfin/Emby links if server is initially setup with a trailing / ([6635701](https://github.com/fallenbagel/jellyseerr/commit/66357019f034ffac342ebd49a32bd75c95d4dec9)), closes [#168](https://github.com/fallenbagel/jellyseerr/issues/168) [#220](https://github.com/fallenbagel/jellyseerr/issues/220)
|
||||
* better ordering of RequestButton options & properly handle failed requests ([#2944](https://github.com/fallenbagel/jellyseerr/issues/2944)) ([c143c0b](https://github.com/fallenbagel/jellyseerr/commit/c143c0b8d285896a7ad5f42f2a7dc1e38b55cc3a))
|
||||
* check perms to view watchlist slider on user profile ([#2980](https://github.com/fallenbagel/jellyseerr/issues/2980)) ([5d73bc2](https://github.com/fallenbagel/jellyseerr/commit/5d73bc22389a2606ecc630f88b6ec2328f690975))
|
||||
* clicking outside modal closes modal again ([#2984](https://github.com/fallenbagel/jellyseerr/issues/2984)) ([1a00532](https://github.com/fallenbagel/jellyseerr/commit/1a0053221b58d198bdcca0249fcd76226122c6a2))
|
||||
* compatibility issue with safari ([#3019](https://github.com/fallenbagel/jellyseerr/issues/3019)) ([e486623](https://github.com/fallenbagel/jellyseerr/commit/e486623310b1c0de12a5b38bf934543fde250c37))
|
||||
* correct safe margin for slideover ([#2977](https://github.com/fallenbagel/jellyseerr/issues/2977)) ([23409e6](https://github.com/fallenbagel/jellyseerr/commit/23409e6f2ffb060e7914db28375e141a6087ddfa))
|
||||
* correct spacing on season header badges ([#2983](https://github.com/fallenbagel/jellyseerr/issues/2983)) ([c4aa08f](https://github.com/fallenbagel/jellyseerr/commit/c4aa08f5f0bcc856e993416936cc74e4db6aed5d))
|
||||
* **deps:** do not list email-validator as a devDependency ([9518cb3](https://github.com/fallenbagel/jellyseerr/commit/9518cb36357cd36b4b75439ac74ba910faa9a217))
|
||||
* **deps:** pin dependencies ([#2946](https://github.com/fallenbagel/jellyseerr/issues/2946)) [skip ci] ([103c4ca](https://github.com/fallenbagel/jellyseerr/commit/103c4ca49ca6f7a8101a18a540d3955a1ac6eae7))
|
||||
* **deps:** pin dependency @formatjs/intl-utils to 3.8.4 ([#2975](https://github.com/fallenbagel/jellyseerr/issues/2975)) [skip ci] ([baf1ea9](https://github.com/fallenbagel/jellyseerr/commit/baf1ea95a38537fab56cc324aa9359f40b615fe6))
|
||||
* **deps:** pin dependency @headlessui/react to v0.0.0-insiders.b301f04 ([#2993](https://github.com/fallenbagel/jellyseerr/issues/2993)) [skip ci] ([833f52d](https://github.com/fallenbagel/jellyseerr/commit/833f52de56cc4715673c5d2593f05528d260ae15))
|
||||
* **deps:** pin dependency cronstrue to 2.11.0 ([#3018](https://github.com/fallenbagel/jellyseerr/issues/3018)) [skip ci] ([f20ba3f](https://github.com/fallenbagel/jellyseerr/commit/f20ba3fc2e6cd42c2c73ba47812d9d177e75a8c2))
|
||||
* **deps:** pin dependency react-popper-tooltip to 4.4.2 ([#2952](https://github.com/fallenbagel/jellyseerr/issues/2952)) [skip ci] ([5247f14](https://github.com/fallenbagel/jellyseerr/commit/5247f14968c1f5a1a2efdc26f850cf81c0c04f0e))
|
||||
* do not display 'Request More' button if no requestable seasons ([#2998](https://github.com/fallenbagel/jellyseerr/issues/2998)) ([23f93e3](https://github.com/fallenbagel/jellyseerr/commit/23f93e311d3c4ed413b2fabca520b5c97869c9b8))
|
||||
* failure to load SearchByNameModal ([#3000](https://github.com/fallenbagel/jellyseerr/issues/3000)) ([410ad0d](https://github.com/fallenbagel/jellyseerr/commit/410ad0d4b40e2ae3ca315a9c808a886ab2c7bf2d))
|
||||
* fix play on Jellyfin/Emby button after previous merge ([3b0c091](https://github.com/fallenbagel/jellyseerr/commit/3b0c0915fb8e1ad671227f60cc181d52f226ad6b))
|
||||
* **frontend:** better request/media cards for items without valid TMDb IDs ([#2181](https://github.com/fallenbagel/jellyseerr/issues/2181)) ([9bc1f89](https://github.com/fallenbagel/jellyseerr/commit/9bc1f8977707f2d3383a7af8efa0521ced096777))
|
||||
* **frontend:** only allow 'request as' users w/ request perms ([#2991](https://github.com/fallenbagel/jellyseerr/issues/2991)) ([dbdecb1](https://github.com/fallenbagel/jellyseerr/commit/dbdecb1e0afb450e0cda6965a6d89408a831db0e))
|
||||
* **import statement:** import statement ([8724058](https://github.com/fallenbagel/jellyseerr/commit/8724058aa568b5eb96420c28f9082ca8a9ed714f))
|
||||
* issues and login page still had incorrect animations ([#2979](https://github.com/fallenbagel/jellyseerr/issues/2979)) ([095048d](https://github.com/fallenbagel/jellyseerr/commit/095048d94af28f25f88f956aba8e01d1199082f3))
|
||||
* **lang:** correct capitalization of 'TMDB' ([#2953](https://github.com/fallenbagel/jellyseerr/issues/2953)) ([9021696](https://github.com/fallenbagel/jellyseerr/commit/9021696cf085bd6387701811e64466e14893d1db))
|
||||
* **lang:** manage movie -> manage series ([#2963](https://github.com/fallenbagel/jellyseerr/issues/2963)) ([f5e6b62](https://github.com/fallenbagel/jellyseerr/commit/f5e6b620c133e38cf18978a619d3d4757bbab755))
|
||||
* log level value should not be case sensitive ([#2913](https://github.com/fallenbagel/jellyseerr/issues/2913)) ([6428b8d](https://github.com/fallenbagel/jellyseerr/commit/6428b8d4195c736a384441a761a746a35a3ad009))
|
||||
* new status indicators added to series list on mobile ([#3024](https://github.com/fallenbagel/jellyseerr/issues/3024)) ([407af32](https://github.com/fallenbagel/jellyseerr/commit/407af32d32a30d23ee14a9ff763cb4aa582d3ede))
|
||||
* only request Tautulli watch data for Plex media servers (to avoid error messages in logs) ([6dbb99e](https://github.com/fallenbagel/jellyseerr/commit/6dbb99e0b685c6a0e7c9cb62c300ceee85b43bf4))
|
||||
* **plex:** add container-size header to recently added api call ([#3023](https://github.com/fallenbagel/jellyseerr/issues/3023)) ([d8da5cb](https://github.com/fallenbagel/jellyseerr/commit/d8da5cbe9d0700e02cbece70caf9103bc5376505))
|
||||
* remove backdrop-blur class from warning buttons ([#3037](https://github.com/fallenbagel/jellyseerr/issues/3037)) ([430b1ab](https://github.com/fallenbagel/jellyseerr/commit/430b1ab871f8e3eefbebb37c74aa1ce3f0862efe))
|
||||
* remove failing ci job that builds a test copy to a private repo ([5f7e7ee](https://github.com/fallenbagel/jellyseerr/commit/5f7e7eef119c1ba2fb7b1cb8c5e06f372b92ac7f))
|
||||
* scroll restoration ([#3005](https://github.com/fallenbagel/jellyseerr/issues/3005)) ([14280c5](https://github.com/fallenbagel/jellyseerr/commit/14280c54370fd9a3e73120e4208e36183d39f9a3))
|
||||
* settings log modal when closing ([#2985](https://github.com/fallenbagel/jellyseerr/issues/2985)) ([4d56320](https://github.com/fallenbagel/jellyseerr/commit/4d563208709fe581ef6bf475e969197d586d907b))
|
||||
* sidebar close button placement when using PWA ([#3045](https://github.com/fallenbagel/jellyseerr/issues/3045)) ([21d20fd](https://github.com/fallenbagel/jellyseerr/commit/21d20fdfd61b7a5a2ec265d420aec103b1430a06))
|
||||
* start scheduled jobs on initial admin account setup ([b080251](https://github.com/fallenbagel/jellyseerr/commit/b08025195e1e1fd07e274b6846728770ec1d3e18)), closes [#170](https://github.com/fallenbagel/jellyseerr/issues/170)
|
||||
* transition animation ([#2974](https://github.com/fallenbagel/jellyseerr/issues/2974)) ([98028bf](https://github.com/fallenbagel/jellyseerr/commit/98028bf2f4d29175fa0922e744fee168849d653f))
|
||||
* **ui:** hide 'Recently Added' & 'Recent Requests' sliders when empty ([#2190](https://github.com/fallenbagel/jellyseerr/issues/2190)) ([03d5e56](https://github.com/fallenbagel/jellyseerr/commit/03d5e56678c3a372114a0256ee3431deee42cb57))
|
||||
* **ui:** hide null dates in episodes list ([#3035](https://github.com/fallenbagel/jellyseerr/issues/3035)) ([7404d68](https://github.com/fallenbagel/jellyseerr/commit/7404d68143e830df73b9d2779a6d7ea65bc9fd4f))
|
||||
* **ui:** minor fixes ([#3036](https://github.com/fallenbagel/jellyseerr/issues/3036)) ([f5c2fc1](https://github.com/fallenbagel/jellyseerr/commit/f5c2fc1c209b2d04f0e39a97d8b65bcac00667dc))
|
||||
* **ui:** remove 'all' badge from request cards ([#2992](https://github.com/fallenbagel/jellyseerr/issues/2992)) ([5c01313](https://github.com/fallenbagel/jellyseerr/commit/5c01313cc4c8277de398124848b4481a2b0080b3))
|
||||
* update Discord ID regex to include 19 digit IDs ([#2860](https://github.com/fallenbagel/jellyseerr/issues/2860)) ([9da8461](https://github.com/fallenbagel/jellyseerr/commit/9da84612254fe0c36b38bc49184cc1fc52ff6212))
|
||||
* use fallbackData to prepare user data during SSR ([#2968](https://github.com/fallenbagel/jellyseerr/issues/2968)) ([6e60a27](https://github.com/fallenbagel/jellyseerr/commit/6e60a275c7c2451beb1922d66eaa6c5411d36c3d))
|
||||
* use image.tmdb.org for setup/login backdrop images ([#2966](https://github.com/fallenbagel/jellyseerr/issues/2966)) ([3b26338](https://github.com/fallenbagel/jellyseerr/commit/3b2633812b5ca8cde010b3f8c2ea9a8646b33e1d))
|
||||
* username will not show undefined on cancel or delete ([#2982](https://github.com/fallenbagel/jellyseerr/issues/2982)) ([b925857](https://github.com/fallenbagel/jellyseerr/commit/b925857dfa27483558eab24bc5f20f654e294a08))
|
||||
* watch data not required to show Tautulli button ([#2976](https://github.com/fallenbagel/jellyseerr/issues/2976)) ([dd28200](https://github.com/fallenbagel/jellyseerr/commit/dd282000407ae9656640f38561a104916d12571a))
|
||||
|
||||
- added deep links to issues and status badges ([#3065](https://github.com/fallenbagel/jellyseerr/issues/3065)) ([bfe56c3](https://github.com/fallenbagel/jellyseerr/commit/bfe56c347073001795b1c3e917eb7a5afcc4462c))
|
||||
- **api:** handle auth for accounts where the plex id may have been set to null ([#3125](https://github.com/fallenbagel/jellyseerr/issues/3125)) ([15e2469](https://github.com/fallenbagel/jellyseerr/commit/15e246929bdbc2b7b5bdab7a84bd7882b79d5cb1))
|
||||
- **api:** ignore Music,Books,Photos,MusicVideo libraries ([d9ca3c6](https://github.com/fallenbagel/jellyseerr/commit/d9ca3c6e52c118698ca71021217f6ca409e71974))
|
||||
- count combined episodes ([64339e5](https://github.com/fallenbagel/jellyseerr/commit/64339e5f0374f8490e685e5c086e088bb7fd737e))
|
||||
- improved PTR scrolling performance ([#3095](https://github.com/fallenbagel/jellyseerr/issues/3095)) ([07ec3ef](https://github.com/fallenbagel/jellyseerr/commit/07ec3efbcaf669de7ccde4421c1112bfd23675d6))
|
||||
- **locale:** fix the duplicated wording in the Clear Media Warning message ([7e20c7c](https://github.com/fallenbagel/jellyseerr/commit/7e20c7cb78a44c32ab8a5f21203e285f23f402ab))
|
||||
- **ui:** adds mediaServerName to statusBadge and manageSlideOver ([d0cdce9](https://github.com/fallenbagel/jellyseerr/commit/d0cdce9e90fba642d2bf934a4266e1421424bc73)), closes [#254](https://github.com/fallenbagel/jellyseerr/issues/254)
|
||||
- update API docs to allow 'all' seasons value ([#3073](https://github.com/fallenbagel/jellyseerr/issues/3073)) ([1dfa943](https://github.com/fallenbagel/jellyseerr/commit/1dfa9431a95e7e2a1843746c2473d8a06f03e184))
|
||||
|
||||
### Features
|
||||
|
||||
* add 20th Century Studios to Studio Slider ([#2288](https://github.com/fallenbagel/jellyseerr/issues/2288)) ([b33956e](https://github.com/fallenbagel/jellyseerr/commit/b33956e6b85b2c2fc12e23951c06f36e009fb627))
|
||||
* **frontend:** a few more tooltips ([#2972](https://github.com/fallenbagel/jellyseerr/issues/2972)) ([815d709](https://github.com/fallenbagel/jellyseerr/commit/815d709bcfa4cca1528cc697defe9cd773ea0089))
|
||||
* **frontend:** add more tooltips ([#2961](https://github.com/fallenbagel/jellyseerr/issues/2961)) ([950b171](https://github.com/fallenbagel/jellyseerr/commit/950b1712b7449dc48a9c1589950907dd581f069a))
|
||||
* improved user dropdown ([#2969](https://github.com/fallenbagel/jellyseerr/issues/2969)) ([67f3a38](https://github.com/fallenbagel/jellyseerr/commit/67f3a3829e2f629e72ec3c32b042be2ef08c38e9))
|
||||
* **jobs:** show current job frequency in edit modal ([#3008](https://github.com/fallenbagel/jellyseerr/issues/3008)) ([99fc9a2](https://github.com/fallenbagel/jellyseerr/commit/99fc9a2da01b1628d5f849ce56f016c0ab26c3db))
|
||||
* **lang:** add Arabic and Lithuanian display languages ([#2916](https://github.com/fallenbagel/jellyseerr/issues/2916)) ([3db3044](https://github.com/fallenbagel/jellyseerr/commit/3db3044210316817144ecacd81be97f159d0df2b))
|
||||
* **lang:** translations update from Hosted Weblate ([#2659](https://github.com/fallenbagel/jellyseerr/issues/2659)) ([e939dc6](https://github.com/fallenbagel/jellyseerr/commit/e939dc678e124bd1f630c98d1d4927dba6372af5))
|
||||
* **lang:** translations update from Hosted Weblate ([#2915](https://github.com/fallenbagel/jellyseerr/issues/2915)) ([a0301e2](https://github.com/fallenbagel/jellyseerr/commit/a0301e2d83b2fca07f69a2c274d4745edef6d0cd))
|
||||
* **lang:** translations update from Hosted Weblate ([#2958](https://github.com/fallenbagel/jellyseerr/issues/2958)) ([29ab178](https://github.com/fallenbagel/jellyseerr/commit/29ab178fb0048c055a86bc40f7b47117903a3b2c))
|
||||
* **lang:** translations update from Hosted Weblate ([#2971](https://github.com/fallenbagel/jellyseerr/issues/2971)) ([2950cf4](https://github.com/fallenbagel/jellyseerr/commit/2950cf4438e2d10a9c0543a848519a113527fe91))
|
||||
* **lang:** translations update from Hosted Weblate ([#2999](https://github.com/fallenbagel/jellyseerr/issues/2999)) ([8672869](https://github.com/fallenbagel/jellyseerr/commit/867286996b805fefd3c16f2a1a05d5f2c10daced))
|
||||
* **lang:** translations update from Hosted Weblate ([#3006](https://github.com/fallenbagel/jellyseerr/issues/3006)) ([611ceeb](https://github.com/fallenbagel/jellyseerr/commit/611ceeb5f49ec4760f157eafb03b2dae465d476e))
|
||||
* **lang:** translations update from Hosted Weblate ([#3014](https://github.com/fallenbagel/jellyseerr/issues/3014)) ([3d458dd](https://github.com/fallenbagel/jellyseerr/commit/3d458dd2fdc8807f09af8b4c29bed794b3886f0f))
|
||||
* **lang:** translations update from Hosted Weblate ([#3026](https://github.com/fallenbagel/jellyseerr/issues/3026)) ([16cb53f](https://github.com/fallenbagel/jellyseerr/commit/16cb53f703a6c42b99aa776515f90b3ed153b382))
|
||||
* **language:** update czech language ([8619724](https://github.com/fallenbagel/jellyseerr/commit/8619724c652bfb0604373bcdb8c32a8e87a73fe7))
|
||||
* **logs:** add search filter ([#2505](https://github.com/fallenbagel/jellyseerr/issues/2505)) ([30141f7](https://github.com/fallenbagel/jellyseerr/commit/30141f76e025763bf79fd3c8fb344d45519d5d8d))
|
||||
* **notif:** auto-request notif type ([#2956](https://github.com/fallenbagel/jellyseerr/issues/2956)) ([6c0fd40](https://github.com/fallenbagel/jellyseerr/commit/6c0fd408779bc084698499bca861d042cd735d77))
|
||||
* **perms:** add new permission for viewing recently added media ([#2129](https://github.com/fallenbagel/jellyseerr/issues/2129)) ([a12697b](https://github.com/fallenbagel/jellyseerr/commit/a12697b06143e9a9c5c240c104faabdf2096ffd3))
|
||||
* plex deep links for iOS devices ([#2680](https://github.com/fallenbagel/jellyseerr/issues/2680)) ([575da30](https://github.com/fallenbagel/jellyseerr/commit/575da306b03eea3561de8d7dbe1b4b69674c7b2b))
|
||||
* plex watchlist sync integration ([#2885](https://github.com/fallenbagel/jellyseerr/issues/2885)) ([301f2bf](https://github.com/fallenbagel/jellyseerr/commit/301f2bf7ab0c5e7c5aef9d78a58d6449df0f55b8))
|
||||
* pull down to refresh ([#2908](https://github.com/fallenbagel/jellyseerr/issues/2908)) ([87825a0](https://github.com/fallenbagel/jellyseerr/commit/87825a0e058162e82634503c81deeff1a59634e5))
|
||||
* restore option to cache and optimize images locally ([#2964](https://github.com/fallenbagel/jellyseerr/issues/2964)) ([507227a](https://github.com/fallenbagel/jellyseerr/commit/507227aa496a60d1f46ce1a34c13bb162edc4bb6))
|
||||
* season/episode list on series details ([#2967](https://github.com/fallenbagel/jellyseerr/issues/2967)) ([8a2acb7](https://github.com/fallenbagel/jellyseerr/commit/8a2acb7f2bbe91feb3c6ee45c2066e3a036b83f9))
|
||||
* show alert/prompt when settings changes require restart ([#2401](https://github.com/fallenbagel/jellyseerr/issues/2401)) ([f3e56da](https://github.com/fallenbagel/jellyseerr/commit/f3e56da3b719285095a59d5a0e822087e095b709))
|
||||
* tooltip foundation ([#2950](https://github.com/fallenbagel/jellyseerr/issues/2950)) ([16545ee](https://github.com/fallenbagel/jellyseerr/commit/16545eec225ce942b55935019185a94e471fb93b))
|
||||
* **ui:** revalidate requests slider on discover page ([#2818](https://github.com/fallenbagel/jellyseerr/issues/2818)) ([91e0928](https://github.com/fallenbagel/jellyseerr/commit/91e0928aa0e1353431754750095cb64b93348c21))
|
||||
* user delete modal shows username and requires confirmation ([#2779](https://github.com/fallenbagel/jellyseerr/issues/2779)) ([36d17fe](https://github.com/fallenbagel/jellyseerr/commit/36d17fed6e4e1ca651a4e29f087b2abb53f794cf))
|
||||
* view other users' watchlists ([#2959](https://github.com/fallenbagel/jellyseerr/issues/2959)) ([0839718](https://github.com/fallenbagel/jellyseerr/commit/0839718806a04ee094445dd7276bba7f49424ab7))
|
||||
- **api:** adds support for Mixed Libraries ([ba82ece](https://github.com/fallenbagel/jellyseerr/commit/ba82ecec5c994e79d7c9b658372041522b58a120)), closes [#95](https://github.com/fallenbagel/jellyseerr/issues/95)
|
||||
- custom image proxy ([#3056](https://github.com/fallenbagel/jellyseerr/issues/3056)) ([500cd1f](https://github.com/fallenbagel/jellyseerr/commit/500cd1f872942923d2b9c3b835e6329e335d4a3f))
|
||||
- **lang:** add Croatian display language ([#3041](https://github.com/fallenbagel/jellyseerr/issues/3041)) ([64aab6d](https://github.com/fallenbagel/jellyseerr/commit/64aab6dd8240e191026512733b34cc046b6e508a))
|
||||
|
||||
# [1.1.1](https://github.com/fallenbagel/jellyseerr/compare/v1.1.0...v1.1.1) (2022-06-20)
|
||||
## [1.29.1](https://github.com/sct/overseerr/compare/v1.29.0...v1.29.1) (2022-04-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
107
README.md
107
README.md
@@ -2,9 +2,23 @@
|
||||
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
||||
</p>
|
||||
<p align="center">
|
||||
<<<<<<< HEAD
|
||||
<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/sct/overseerr/workflows/Overseerr%20Release/badge.svg?branch=master" alt="Overseerr Release" />
|
||||
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
|
||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></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-88-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
>>>>>>> upstream/develop
|
||||
</p>
|
||||
|
||||
**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!
|
||||
@@ -13,37 +27,105 @@ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on
|
||||
|
||||
## Current Features
|
||||
|
||||
- Jellyfin Support
|
||||
- Emby Support
|
||||
|
||||
(Upcoming Features include: Multiple Server Instances, Music Support, Ability to change email address and much more!)
|
||||
|
||||
Along with all the existing Overseerr features:
|
||||
|
||||
- Full Plex integration. Authenticate and manage user access with Plex!
|
||||
- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
|
||||
- Supports Movies, Shows, 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!
|
||||
- Plex library scan, to keep track of the titles which are already available.
|
||||
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
|
||||
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
|
||||
- Incredibly simple request management UI. Don't dig through the app to simply approve recent requests!
|
||||
- Granular permission system.
|
||||
- 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!)
|
||||
|
||||
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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
#### 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*_
|
||||
|
||||
### Launching Jellyseerr using Docker
|
||||
|
||||
Check out our dockerhub for instructions on how to install and run Jellyseerr:
|
||||
https://hub.docker.com/r/fallenbagel/jellyseerr
|
||||
|
||||
### Launching Jellyseerr manually:
|
||||
|
||||
#### Windows
|
||||
|
||||
Pre-requisites:
|
||||
|
||||
- Nodejs (atleast LTS version)
|
||||
- Yarn
|
||||
- Download the source code from the github (Either develop branch or main for stable)
|
||||
|
||||
```bash
|
||||
npm i -g win-node-env
|
||||
yarn install
|
||||
yarn run build
|
||||
yarn start
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
||||
Pre-requisites:
|
||||
|
||||
- Nodejs (atleast LTS version)
|
||||
- Yarn
|
||||
- Git
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
_Systemd-service:_
|
||||
|
||||
- assuming jellyseerr was cloned to `/opt/`
|
||||
and the environmentfile is located at `/etc/jellyseerr`
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
# Jellyseerr's default port is 5055, if you want to use both, change this.
|
||||
# specify on which port to listen
|
||||
PORT=5055
|
||||
|
||||
# specify on which interface to listen, by default jellyseerr listens on all interfaces
|
||||
#HOST=127.0.0.1
|
||||
|
||||
# Uncomment if your media server is emby instead of jellyfin.
|
||||
# JELLYFIN_TYPE=emby
|
||||
```
|
||||
|
||||
### Packages:
|
||||
|
||||
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
|
||||
@@ -72,4 +154,11 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
|
||||
|
||||
## Contributing
|
||||
|
||||
<<<<<<< HEAD
|
||||
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
=======
|
||||
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
projectId: 'onnqy3',
|
||||
projectId: 'xkm1b4',
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5055',
|
||||
experimentalSessionAndOrigin: true,
|
||||
|
||||
@@ -36,7 +36,9 @@ describe('Discover', () => {
|
||||
});
|
||||
|
||||
it('loads upcoming movies', () => {
|
||||
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
|
||||
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
|
||||
'getUpcomingMovies'
|
||||
);
|
||||
cy.visit('/');
|
||||
cy.wait('@getUpcomingMovies');
|
||||
clickFirstTitleCardInSlider('Upcoming Movies');
|
||||
@@ -50,7 +52,9 @@ describe('Discover', () => {
|
||||
});
|
||||
|
||||
it('loads upcoming series', () => {
|
||||
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
|
||||
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
|
||||
'getUpcomingSeries'
|
||||
);
|
||||
cy.visit('/');
|
||||
cy.wait('@getUpcomingSeries');
|
||||
clickFirstTitleCardInSlider('Upcoming Series');
|
||||
|
||||
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
163
cypress/e2e/settings/discover-customization.cy.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
describe('Discover Customization', () => {
|
||||
beforeEach(() => {
|
||||
cy.loginAsAdmin();
|
||||
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
|
||||
});
|
||||
|
||||
it('show the discover customization settings', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=create-slider-header')
|
||||
.should('contain', 'Create New Slider')
|
||||
.scrollIntoView();
|
||||
|
||||
// There should be some built in options
|
||||
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||
'contain',
|
||||
'Recently Added'
|
||||
);
|
||||
cy.get('[data-testid=discover-slider-edit-mode]').should(
|
||||
'contain',
|
||||
'Recent Requests'
|
||||
);
|
||||
});
|
||||
|
||||
it('can drag to re-order elements and save to persist the changes', () => {
|
||||
let dataTransfer = new DataTransfer();
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('drop', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.should('contain', 'Recently Added');
|
||||
|
||||
cy.get('[data-testid=discover-customize-submit').click();
|
||||
cy.wait('@getDiscoverSliders');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
dataTransfer = new DataTransfer();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.should('contain', 'Recently Added');
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.trigger('dragstart', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('drop', { dataTransfer });
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.trigger('dragend', { dataTransfer });
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.eq(1)
|
||||
.should('contain', 'Recent Requests');
|
||||
|
||||
cy.get('[data-testid=discover-customize-submit').click();
|
||||
cy.wait('@getDiscoverSliders');
|
||||
});
|
||||
|
||||
it('can create a new discover option and remove it', () => {
|
||||
cy.visit('/');
|
||||
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
|
||||
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
const sliderTitle = 'Custom Keyword Slider';
|
||||
|
||||
cy.get('#sliderType').select('TMDB Movie Keyword');
|
||||
|
||||
cy.get('#title').type(sliderTitle);
|
||||
// First confirm that an invalid keyword doesn't allow us to submit anything
|
||||
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
|
||||
cy.wait('@searchKeyword');
|
||||
|
||||
cy.get('[data-testid=create-discover-option-form]')
|
||||
.find('button')
|
||||
.should('be.disabled');
|
||||
|
||||
cy.get('#data').clear();
|
||||
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||
|
||||
// Confirming we have some results
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]');
|
||||
|
||||
cy.get('[data-testid=create-discover-option-form]').submit();
|
||||
|
||||
cy.wait('@discoverSlider');
|
||||
cy.wait('@getDiscoverSliders');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.should('contain', sliderTitle);
|
||||
|
||||
// Make sure its still there even if we reload
|
||||
cy.reload();
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.should('contain', sliderTitle);
|
||||
|
||||
// Verify it's not rendering on our discover page (its still disabled!)
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('.slider-header').should('not.contain', sliderTitle);
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
// Enable it, and check again
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.find('[role="checkbox"]')
|
||||
.click();
|
||||
|
||||
cy.get('[data-testid=discover-customize-submit').click();
|
||||
cy.wait('@getDiscoverSliders');
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]');
|
||||
|
||||
cy.get('[data-testid=discover-start-editing]').click();
|
||||
|
||||
// let's delete it and confirm its deleted.
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.find('[data-testid=discover-slider-remove-button]')
|
||||
.click();
|
||||
|
||||
cy.wait('@discoverSlider');
|
||||
cy.wait('@getDiscoverSliders');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('[data-testid=discover-slider-edit-mode]')
|
||||
.first()
|
||||
.should('not.contain', sliderTitle);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ describe('General Settings', () => {
|
||||
cy.visit('/settings');
|
||||
|
||||
cy.get('#trustProxy').click();
|
||||
cy.get('form').submit();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should(
|
||||
'contain',
|
||||
'Server Restart Required'
|
||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
|
||||
cy.get('[type=checkbox]#trustProxy').click();
|
||||
cy.get('form').submit();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
|
||||
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
|
||||
```
|
||||
|
||||
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
||||
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
|
||||
|
||||
@@ -28,6 +28,7 @@ docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=debug \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-e PORT=5055 `#optional` \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
@@ -81,6 +82,7 @@ services:
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- TZ=Asia/Tokyo
|
||||
- PORT=5055 #optional
|
||||
ports:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
@@ -88,7 +90,7 @@ services:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Then, start all services defined in the your Compose file:
|
||||
Then, start all services defined in the Compose file:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
@@ -146,8 +148,6 @@ Then, create and start the Overseerr container:
|
||||
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
||||
```
|
||||
|
||||
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
|
||||
|
||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
|
||||
|
||||
{% hint style="info" %}
|
||||
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
|
||||
|
||||
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
|
||||
|
||||
Named volumes, like in the example commands above, are automatically mounted inside the VM.
|
||||
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
|
||||
{% endhint %}
|
||||
|
||||
## Linux
|
||||
|
||||
@@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
### Enable Image Caching
|
||||
|
||||
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
|
||||
|
||||
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
|
||||
|
||||
You should enable this if you are having issues with loading images directly from TMDB in your browser.
|
||||
|
||||
### Display Language
|
||||
|
||||
Set the default display language for Overseerr. Users can override this setting in their user settings.
|
||||
|
||||
@@ -23,5 +23,6 @@ module.exports = {
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
largePageDataBytes: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ tags:
|
||||
description: Endpoints related to retrieving movies and their details.
|
||||
- name: tv
|
||||
description: Endpoints related to retrieving TV series and their details.
|
||||
- name: other
|
||||
description: Endpoints related to other TMDB data
|
||||
- name: person
|
||||
description: Endpoints related to retrieving person details.
|
||||
- name: media
|
||||
@@ -648,6 +650,17 @@ components:
|
||||
name:
|
||||
type: string
|
||||
example: Adventure
|
||||
Company:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
logo_path:
|
||||
type: string
|
||||
nullable: true
|
||||
name:
|
||||
type: string
|
||||
ProductionCompany:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1087,6 +1100,8 @@ components:
|
||||
nullable: true
|
||||
status:
|
||||
type: number
|
||||
example: 0
|
||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
||||
requests:
|
||||
type: array
|
||||
readOnly: true
|
||||
@@ -1828,6 +1843,40 @@ components:
|
||||
message:
|
||||
type: string
|
||||
example: A comment
|
||||
DiscoverSlider:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
type:
|
||||
type: number
|
||||
example: 1
|
||||
title:
|
||||
type: string
|
||||
nullable: true
|
||||
isBuiltIn:
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
data:
|
||||
type: string
|
||||
example: '1234'
|
||||
nullable: true
|
||||
required:
|
||||
- type
|
||||
- enabled
|
||||
- title
|
||||
- data
|
||||
WatchProviderRegion:
|
||||
type: object
|
||||
properties:
|
||||
iso_3166_1:
|
||||
type: string
|
||||
english_name:
|
||||
type: string
|
||||
native_name:
|
||||
type: string
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
@@ -2667,29 +2716,44 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: cache-id
|
||||
name:
|
||||
type: string
|
||||
example: cache name
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
imageCache:
|
||||
type: object
|
||||
properties:
|
||||
tmdb:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 123456
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
hits:
|
||||
type: number
|
||||
misses:
|
||||
type: number
|
||||
keys:
|
||||
type: number
|
||||
ksize:
|
||||
type: number
|
||||
vsize:
|
||||
type: number
|
||||
id:
|
||||
type: string
|
||||
example: cache-id
|
||||
name:
|
||||
type: string
|
||||
example: cache name
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
hits:
|
||||
type: number
|
||||
misses:
|
||||
type: number
|
||||
keys:
|
||||
type: number
|
||||
ksize:
|
||||
type: number
|
||||
vsize:
|
||||
type: number
|
||||
/settings/cache/{cacheId}/flush:
|
||||
post:
|
||||
summary: Flush a specific cache
|
||||
@@ -3219,6 +3283,133 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/discover:
|
||||
get:
|
||||
summary: Get all discover sliders
|
||||
description: Returns all discovery sliders. Built-in and custom made.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned all discovery sliders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
post:
|
||||
summary: Batch update all sliders.
|
||||
description: |
|
||||
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
|
||||
and will not delete any sliders not present in the request. If a slider is missing a required field,
|
||||
it will be ignored. Requires the `ADMIN` permission.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
responses:
|
||||
'200':
|
||||
description: Returned all newly updated discovery sliders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
/settings/discover/{sliderId}:
|
||||
put:
|
||||
summary: Update a single slider
|
||||
description: |
|
||||
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: 'Slider Title'
|
||||
type:
|
||||
type: number
|
||||
example: 1
|
||||
data:
|
||||
type: string
|
||||
example: '1'
|
||||
responses:
|
||||
'200':
|
||||
description: Returns newly added discovery slider
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
delete:
|
||||
summary: Delete slider by ID
|
||||
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: sliderId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: Slider successfully deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
/settings/discover/add:
|
||||
post:
|
||||
summary: Add a new slider
|
||||
description: |
|
||||
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: 'New Slider'
|
||||
type:
|
||||
type: number
|
||||
example: 1
|
||||
data:
|
||||
type: string
|
||||
example: '1'
|
||||
responses:
|
||||
'200':
|
||||
description: Returns newly added discovery slider
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscoverSlider'
|
||||
/settings/discover/reset:
|
||||
get:
|
||||
summary: Reset all discover sliders
|
||||
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'204':
|
||||
description: All sliders reset to defaults
|
||||
/settings/about:
|
||||
get:
|
||||
summary: Get server stats
|
||||
@@ -3677,7 +3868,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:
|
||||
@@ -3773,7 +3964,7 @@ paths:
|
||||
example: false
|
||||
/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:
|
||||
@@ -4100,6 +4291,86 @@ paths:
|
||||
- $ref: '#/components/schemas/MovieResult'
|
||||
- $ref: '#/components/schemas/TvResult'
|
||||
- $ref: '#/components/schemas/PersonResult'
|
||||
/search/keyword:
|
||||
get:
|
||||
summary: Search for keywords
|
||||
description: Returns a list of TMDB keywords matching the search query
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'christmas'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
example: 1
|
||||
totalPages:
|
||||
type: number
|
||||
example: 20
|
||||
totalResults:
|
||||
type: number
|
||||
example: 200
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Keyword'
|
||||
/search/company:
|
||||
get:
|
||||
summary: Search for companies
|
||||
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'Disney'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
page:
|
||||
type: number
|
||||
example: 1
|
||||
totalPages:
|
||||
type: number
|
||||
example: 20
|
||||
totalResults:
|
||||
type: number
|
||||
example: 200
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Company'
|
||||
/discover/movies:
|
||||
get:
|
||||
summary: Discover movies
|
||||
@@ -4121,13 +4392,63 @@ paths:
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
type: string
|
||||
example: 18
|
||||
- in: query
|
||||
name: studio
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
- in: query
|
||||
name: keywords
|
||||
schema:
|
||||
type: string
|
||||
example: 1,2
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
type: string
|
||||
example: popularity.desc
|
||||
- in: query
|
||||
name: primaryReleaseDateGte
|
||||
schema:
|
||||
type: string
|
||||
example: 2022-01-01
|
||||
- in: query
|
||||
name: primaryReleaseDateLte
|
||||
schema:
|
||||
type: string
|
||||
example: 2023-01-01
|
||||
- in: query
|
||||
name: withRuntimeGte
|
||||
schema:
|
||||
type: number
|
||||
example: 60
|
||||
- in: query
|
||||
name: withRuntimeLte
|
||||
schema:
|
||||
type: number
|
||||
example: 120
|
||||
- in: query
|
||||
name: voteAverageGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteAverageLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
- in: query
|
||||
name: watchProviders
|
||||
schema:
|
||||
type: string
|
||||
example: 8|9
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -4350,13 +4671,63 @@ paths:
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
type: string
|
||||
example: 18
|
||||
- in: query
|
||||
name: network
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
- in: query
|
||||
name: keywords
|
||||
schema:
|
||||
type: string
|
||||
example: 1,2
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
type: string
|
||||
example: popularity.desc
|
||||
- in: query
|
||||
name: firstAirDateGte
|
||||
schema:
|
||||
type: string
|
||||
example: 2022-01-01
|
||||
- in: query
|
||||
name: firstAirDateLte
|
||||
schema:
|
||||
type: string
|
||||
example: 2023-01-01
|
||||
- in: query
|
||||
name: withRuntimeGte
|
||||
schema:
|
||||
type: number
|
||||
example: 60
|
||||
- in: query
|
||||
name: withRuntimeLte
|
||||
schema:
|
||||
type: number
|
||||
example: 120
|
||||
- in: query
|
||||
name: voteAverageGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteAverageLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
- in: query
|
||||
name: watchProviders
|
||||
schema:
|
||||
type: string
|
||||
example: 8|9
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -4838,9 +5209,13 @@ paths:
|
||||
type: number
|
||||
example: 123
|
||||
seasons:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
type: number
|
||||
minimum: 1
|
||||
- type: string
|
||||
enum: [all]
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -4919,7 +5294,7 @@ paths:
|
||||
$ref: '#/components/schemas/MediaRequest'
|
||||
put:
|
||||
summary: Update MediaRequest
|
||||
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission.
|
||||
description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
|
||||
tags:
|
||||
- request
|
||||
parameters:
|
||||
@@ -4930,6 +5305,37 @@ paths:
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
mediaType:
|
||||
type: string
|
||||
enum: [movie, tv]
|
||||
seasons:
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
minimum: 1
|
||||
is4k:
|
||||
type: boolean
|
||||
example: false
|
||||
serverId:
|
||||
type: number
|
||||
profileId:
|
||||
type: number
|
||||
rootFolder:
|
||||
type: string
|
||||
languageProfileId:
|
||||
type: number
|
||||
userId:
|
||||
type: number
|
||||
nullable: true
|
||||
required:
|
||||
- mediaType
|
||||
responses:
|
||||
'200':
|
||||
description: Succesfully updated request
|
||||
@@ -5000,7 +5406,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, approve, decline, available]
|
||||
enum: [approve, decline]
|
||||
responses:
|
||||
'200':
|
||||
description: Request status changed
|
||||
@@ -5470,6 +5876,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
|
||||
@@ -6120,6 +6543,89 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Issue'
|
||||
/keyword/{keywordId}:
|
||||
get:
|
||||
summary: Get keyword
|
||||
description: |
|
||||
Returns a single keyword in JSON format.
|
||||
tags:
|
||||
- other
|
||||
parameters:
|
||||
- in: path
|
||||
name: keywordId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Keyword returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Keyword'
|
||||
/watchproviders/regions:
|
||||
get:
|
||||
summary: Get watch provider regions
|
||||
description: |
|
||||
Returns a list of all available watch provider regions.
|
||||
tags:
|
||||
- other
|
||||
responses:
|
||||
'200':
|
||||
description: Watch provider regions returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WatchProviderRegion'
|
||||
/watchproviders/movies:
|
||||
get:
|
||||
summary: Get watch provider movies
|
||||
description: |
|
||||
Returns a list of all available watch providers for movies.
|
||||
tags:
|
||||
- other
|
||||
parameters:
|
||||
- in: query
|
||||
name: watchRegion
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
responses:
|
||||
'200':
|
||||
description: Watch providers for movies returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WatchProviderDetails'
|
||||
/watchproviders/tv:
|
||||
get:
|
||||
summary: Get watch provider series
|
||||
description: |
|
||||
Returns a list of all available watch providers for series.
|
||||
tags:
|
||||
- other
|
||||
parameters:
|
||||
- in: query
|
||||
name: watchRegion
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: US
|
||||
responses:
|
||||
'200':
|
||||
description: Watch providers for series returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WatchProviderDetails'
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- apiKey: []
|
||||
|
||||
150
package.json
150
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyseerr",
|
||||
"version": "1.2.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",
|
||||
@@ -29,145 +29,149 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-displaynames": "6.0.3",
|
||||
"@formatjs/intl-locale": "3.0.3",
|
||||
"@formatjs/intl-pluralrules": "5.0.3",
|
||||
"@formatjs/intl-displaynames": "6.2.3",
|
||||
"@formatjs/intl-locale": "3.0.11",
|
||||
"@formatjs/intl-pluralrules": "5.1.8",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "0.0.0-insiders.b301f04",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@headlessui/react": "1.7.7",
|
||||
"@heroicons/react": "2.0.13",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.3.1",
|
||||
"@tanem/react-nprogress": "5.0.11",
|
||||
"ace-builds": "1.9.6",
|
||||
"axios": "0.27.2",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.22",
|
||||
"ace-builds": "1.14.0",
|
||||
"axios": "1.2.2",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.0.1",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.6",
|
||||
"copy-to-clipboard": "3.3.2",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.11.0",
|
||||
"cronstrue": "2.21.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.1",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"email-templates": "9.0.0",
|
||||
"email-validator": "2.0.4",
|
||||
"express": "4.18.1",
|
||||
"express": "4.18.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.5.1",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
"formik": "2.2.9",
|
||||
"gravatar-url": "3.1.0",
|
||||
"intl": "1.2.5",
|
||||
"lodash": "4.17.21",
|
||||
"next": "12.2.5",
|
||||
"next": "12.3.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.1.0",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.0",
|
||||
"nodemailer": "6.7.8",
|
||||
"openpgp": "5.4.0",
|
||||
"nodemailer": "6.8.0",
|
||||
"openpgp": "5.5.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-dom": "18.2.0",
|
||||
"react-intersection-observer": "9.4.0",
|
||||
"react-intl": "6.0.5",
|
||||
"react-markdown": "8.0.3",
|
||||
"react-intersection-observer": "9.4.1",
|
||||
"react-intl": "6.2.5",
|
||||
"react-markdown": "8.0.4",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.4.0",
|
||||
"react-spring": "9.5.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-spring": "9.6.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.8",
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.7",
|
||||
"sqlite3": "5.0.11",
|
||||
"swagger-ui-express": "4.5.0",
|
||||
"swr": "1.3.0",
|
||||
"typeorm": "0.3.7",
|
||||
"semver": "7.3.8",
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.0",
|
||||
"swr": "2.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"web-push": "3.5.0",
|
||||
"winston": "3.8.1",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11"
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.18.10",
|
||||
"@commitlint/cli": "17.0.3",
|
||||
"@commitlint/config-conventional": "17.0.3",
|
||||
"@semantic-release/changelog": "6.0.1",
|
||||
"@babel/cli": "7.20.7",
|
||||
"@commitlint/cli": "17.4.0",
|
||||
"@commitlint/config-conventional": "17.4.0",
|
||||
"@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.0",
|
||||
"@tailwindcss/forms": "0.5.2",
|
||||
"@tailwindcss/typography": "0.5.4",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.8",
|
||||
"@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.13",
|
||||
"@types/express-session": "1.17.4",
|
||||
"@types/lodash": "4.14.183",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.5",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/pulltorefreshjs": "0.1.5",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.12",
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/swagger-ui-express": "4.1.3",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.33.1",
|
||||
"@typescript-eslint/parser": "5.33.1",
|
||||
"autoprefixer": "10.4.8",
|
||||
"@typescript-eslint/eslint-plugin": "5.48.0",
|
||||
"@typescript-eslint/parser": "5.48.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"babel-plugin-react-intl-auto": "3.3.0",
|
||||
"commitizen": "4.2.5",
|
||||
"commitizen": "4.2.6",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "10.6.0",
|
||||
"cypress": "12.3.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.22.0",
|
||||
"eslint-config-next": "12.2.5",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-formatjs": "4.1.0",
|
||||
"eslint": "8.31.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-no-relative-import-paths": "1.4.0",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.30.1",
|
||||
"eslint-plugin-react": "7.31.11",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"extract-react-intl-messages": "4.1.1",
|
||||
"husky": "8.0.1",
|
||||
"lint-staged": "12.4.3",
|
||||
"nodemon": "2.0.19",
|
||||
"postcss": "8.4.16",
|
||||
"prettier": "2.7.1",
|
||||
"prettier-plugin-organize-imports": "3.1.0",
|
||||
"prettier-plugin-tailwindcss": "0.1.13",
|
||||
"semantic-release": "19.0.3",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.0",
|
||||
"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",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.1.8",
|
||||
"tailwindcss": "3.2.4",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.7.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "4.7.4"
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.17",
|
||||
"@types/react-dom": "18.0.6"
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
@@ -225,7 +229,7 @@
|
||||
{
|
||||
"path": "semantic-release-docker-buildx",
|
||||
"buildArgs": {
|
||||
"COMMIT_TAG": "$GITHUB_SHA"
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
},
|
||||
"imageNames": [
|
||||
"fallenbagel/jellyseerr"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[ZoneTransfer]
|
||||
LastWriterPackageFamilyName=Microsoft.ScreenSketch_8wekyb3d8bbwe
|
||||
ZoneId=3
|
||||
@@ -69,6 +69,30 @@ class ExternalAPI {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async post<T>(
|
||||
endpoint: string,
|
||||
data: Record<string, unknown>,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: config?.params,
|
||||
data,
|
||||
});
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
if (cachedItem) {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<T>(endpoint, data, config);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
protected async getRolling<T>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface JellyfinLibraryItem {
|
||||
SeasonId?: string;
|
||||
SeasonName?: string;
|
||||
IndexNumber?: number;
|
||||
IndexNumberEnd?: number;
|
||||
ParentIndexNumber?: number;
|
||||
MediaType: string;
|
||||
}
|
||||
@@ -178,8 +179,10 @@ class JellyfinAPI {
|
||||
(Item: any) => {
|
||||
return (
|
||||
Item.Type === 'CollectionFolder' &&
|
||||
(Item.CollectionType === 'tvshows' ||
|
||||
Item.CollectionType === 'movies')
|
||||
Item.CollectionType !== 'music' &&
|
||||
Item.CollectionType !== 'books' &&
|
||||
Item.CollectionType !== 'musicvideos' &&
|
||||
Item.CollectionType !== 'homevideos'
|
||||
);
|
||||
}
|
||||
).map((Item: any) => {
|
||||
@@ -204,7 +207,7 @@ class JellyfinAPI {
|
||||
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&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||
);
|
||||
|
||||
return contents.data.Items.filter(
|
||||
|
||||
@@ -226,12 +226,13 @@ class PlexAPI {
|
||||
id: string,
|
||||
options: { addedAt: number } = {
|
||||
addedAt: Date.now() - 1000 * 60 * 60,
|
||||
}
|
||||
},
|
||||
mediaType: 'movie' | 'show'
|
||||
): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>({
|
||||
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
|
||||
options.addedAt / 1000
|
||||
)}`,
|
||||
uri: `/library/sections/${id}/all?type=${
|
||||
mediaType === 'show' ? '4' : '1'
|
||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||
extraHeaders: {
|
||||
'X-Plex-Container-Start': `0`,
|
||||
'X-Plex-Container-Size': `500`,
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTSearchResult {
|
||||
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
|
||||
meterScore: number;
|
||||
url: string;
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
hits: RTAlgoliaHit[];
|
||||
index: 'content_rt' | 'people_rt';
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RTTvSearchResult extends RTSearchResult {
|
||||
interface RTAlgoliaHit {
|
||||
emsId: string;
|
||||
emsVersionId: string;
|
||||
tmsId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
}
|
||||
interface RTMovieSearchResult extends RTSearchResult {
|
||||
name: string;
|
||||
url: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
interface RTMultiSearchResponse {
|
||||
tvCount: number;
|
||||
tvSeries: RTTvSearchResult[];
|
||||
movieCount: number;
|
||||
movies: RTMovieSearchResult[];
|
||||
titles: string[];
|
||||
description: string;
|
||||
releaseYear: string;
|
||||
rating: string;
|
||||
genres: string[];
|
||||
updateDate: string;
|
||||
isEmsSearchable: boolean;
|
||||
rtId: number;
|
||||
vanity: string;
|
||||
aka: string[];
|
||||
posterImageUrl: string;
|
||||
rottenTomatoes: {
|
||||
audienceScore: number;
|
||||
criticsIconUrl: string;
|
||||
wantToSeeCount: number;
|
||||
audienceIconUrl: string;
|
||||
scoreSentiment: string;
|
||||
certifiedFresh: boolean;
|
||||
criticsScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RTRating {
|
||||
@@ -47,13 +59,20 @@ export interface RTRating {
|
||||
*/
|
||||
class RottenTomatoes extends ExternalAPI {
|
||||
constructor() {
|
||||
const settings = getSettings();
|
||||
super(
|
||||
'https://www.rottentomatoes.com/api/private',
|
||||
{},
|
||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||
{
|
||||
'x-algolia-agent':
|
||||
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||
'x-algolia-application-id': '79FRDP12PN',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-algolia-usertoken': settings.clientId,
|
||||
},
|
||||
nodeCache: cacheManager.getCache('rt').data,
|
||||
}
|
||||
@@ -61,14 +80,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the 1.0 api for the movie title
|
||||
* Search the RT algolia api for the movie title
|
||||
*
|
||||
* We compare the release date to make sure its the correct
|
||||
* match. But it's not guaranteed to have results.
|
||||
*
|
||||
* We use the 1.0 API here because the 2.0 search api does
|
||||
* not return audience ratings.
|
||||
*
|
||||
* @param name Movie name
|
||||
* @param year Release Year
|
||||
*/
|
||||
@@ -77,30 +93,45 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name === name
|
||||
let movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = data.movies.find(
|
||||
(movie) => movie.year === year && movie.name.includes(name)
|
||||
movie = contentResults.hits.find(
|
||||
(movie) =>
|
||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = data.movies.find((movie) => movie.year === year);
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = data.movies.find((movie) => movie.name === name);
|
||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie) {
|
||||
@@ -108,16 +139,15 @@ class RottenTomatoes extends ExternalAPI {
|
||||
}
|
||||
|
||||
return {
|
||||
title: movie.name,
|
||||
url: `https://www.rottentomatoes.com${movie.url}`,
|
||||
criticsRating:
|
||||
movie.meterClass === 'certified_fresh'
|
||||
? 'Certified Fresh'
|
||||
: movie.meterClass === 'fresh'
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.meterScore,
|
||||
year: movie.year,
|
||||
title: movie.title,
|
||||
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
|
||||
criticsRating: movie.rottenTomatoes.certifiedFresh
|
||||
? 'Certified Fresh'
|
||||
: movie.rottenTomatoes.criticsScore >= 60
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
year: Number(movie.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
@@ -131,14 +161,28 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
|
||||
params: { q: name, limit: 10 },
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = data.tvSeries.find((series) => series.startYear === year);
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow) {
|
||||
@@ -147,10 +191,11 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
return {
|
||||
title: tvshow.title,
|
||||
url: `https://www.rottentomatoes.com${tvshow.url}`,
|
||||
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.meterScore,
|
||||
year: tvshow.startYear,
|
||||
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
|
||||
criticsRating:
|
||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||
year: Number(tvshow.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
|
||||
|
||||
@@ -158,7 +158,12 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`
|
||||
`/queue`,
|
||||
{
|
||||
params: {
|
||||
includeEpisode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.records;
|
||||
|
||||
@@ -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?: {
|
||||
@@ -13,6 +13,21 @@ interface SonarrSeason {
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
interface EpisodeResult {
|
||||
seriesId: number;
|
||||
episodeFileId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate: string;
|
||||
airDateUtc: string;
|
||||
overview: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
absoluteEpisodeNumber: number;
|
||||
unverifiedSceneNumbering: boolean;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface SonarrSeries {
|
||||
title: string;
|
||||
@@ -82,7 +97,11 @@ export interface LanguageProfile {
|
||||
name: string;
|
||||
}
|
||||
|
||||
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
class SonarrAPI extends ServarrBase<{
|
||||
seriesId: number;
|
||||
episodeId: number;
|
||||
episode: EpisodeResult;
|
||||
}> {
|
||||
constructor({ url, apiKey }: { url: string; apiKey: string }) {
|
||||
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
|
||||
}
|
||||
@@ -302,6 +321,20 @@ class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
|
||||
|
||||
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;
|
||||
|
||||
@@ -3,9 +3,12 @@ import cacheManager from '@server/lib/cache';
|
||||
import { sortBy } from 'lodash';
|
||||
import type {
|
||||
TmdbCollection,
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
TmdbGenresResult,
|
||||
TmdbKeyword,
|
||||
TmdbKeywordSearchResponse,
|
||||
TmdbLanguage,
|
||||
TmdbMovieDetails,
|
||||
TmdbNetwork,
|
||||
@@ -19,6 +22,8 @@ import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbWatchProviderDetails,
|
||||
TmdbWatchProviderRegion,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -32,30 +37,41 @@ interface SingleSearchOptions extends SearchOptions {
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export type SortOptions =
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'release_date.asc'
|
||||
| 'release_date.desc'
|
||||
| 'revenue.asc'
|
||||
| 'revenue.desc'
|
||||
| 'primary_release_date.asc'
|
||||
| 'primary_release_date.desc'
|
||||
| 'original_title.asc'
|
||||
| 'original_title.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
|
||||
interface DiscoverMovieOptions {
|
||||
page?: number;
|
||||
includeAdult?: boolean;
|
||||
language?: string;
|
||||
primaryReleaseDateGte?: string;
|
||||
primaryReleaseDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
studio?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'release_date.asc'
|
||||
| 'release_date.desc'
|
||||
| 'revenue.asc'
|
||||
| 'revenue.desc'
|
||||
| 'primary_release_date.asc'
|
||||
| 'primary_release_date.desc'
|
||||
| 'original_title.asc'
|
||||
| 'original_title.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc';
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
interface DiscoverTvOptions {
|
||||
@@ -63,19 +79,18 @@ interface DiscoverTvOptions {
|
||||
language?: string;
|
||||
firstAirDateGte?: string;
|
||||
firstAirDateLte?: string;
|
||||
withRuntimeGte?: string;
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
genre?: string;
|
||||
network?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
| 'vote_average.asc'
|
||||
| 'vote_average.desc'
|
||||
| 'vote_count.asc'
|
||||
| 'vote_count.desc'
|
||||
| 'first_air_date.asc'
|
||||
| 'first_air_date.desc';
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -237,7 +252,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,release_dates,watch/providers',
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
},
|
||||
},
|
||||
43200
|
||||
@@ -440,8 +455,25 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
studio,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||
)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const defaultPastDate = new Date('1900-01-01')
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
@@ -449,11 +481,31 @@ class TheMovieDb extends ExternalAPI {
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
region: this.region,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
'primary_release_date.gte': primaryReleaseDateGte,
|
||||
'primary_release_date.lte': primaryReleaseDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||
? defaultPastDate
|
||||
: primaryReleaseDateGte,
|
||||
'primary_release_date.lte':
|
||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||
? defaultFutureDate
|
||||
: primaryReleaseDateLte,
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -473,20 +525,57 @@ class TheMovieDb extends ExternalAPI {
|
||||
originalLanguage,
|
||||
genre,
|
||||
network,
|
||||
keywords,
|
||||
withRuntimeGte,
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
|
||||
)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const defaultPastDate = new Date('1900-01-01')
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
'first_air_date.gte': firstAirDateGte,
|
||||
'first_air_date.lte': firstAirDateLte,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'first_air_date.gte':
|
||||
!firstAirDateGte && firstAirDateLte
|
||||
? defaultPastDate
|
||||
: firstAirDateGte,
|
||||
'first_air_date.lte':
|
||||
!firstAirDateLte && firstAirDateGte
|
||||
? defaultFutureDate
|
||||
: firstAirDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -874,6 +963,152 @@ class TheMovieDb extends ExternalAPI {
|
||||
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getKeywordDetails({
|
||||
keywordId,
|
||||
}: {
|
||||
keywordId: number;
|
||||
}): Promise<TmdbKeyword> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeyword>(
|
||||
`/keyword/${keywordId}`,
|
||||
undefined,
|
||||
604800 // 7 days
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchKeyword({
|
||||
query,
|
||||
page = 1,
|
||||
}: {
|
||||
query: string;
|
||||
page?: number;
|
||||
}): Promise<TmdbKeywordSearchResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||
'/search/keyword',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async searchCompany({
|
||||
query,
|
||||
page = 1,
|
||||
}: {
|
||||
query: string;
|
||||
page?: number;
|
||||
}): Promise<TmdbCompanySearchResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbCompanySearchResponse>(
|
||||
'/search/company',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAvailableWatchProviderRegions({
|
||||
language,
|
||||
}: {
|
||||
language?: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||
'/watch/providers/regions',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch available watch regions: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieWatchProviders({
|
||||
language,
|
||||
watchRegion,
|
||||
}: {
|
||||
language?: string;
|
||||
watchRegion: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/movie',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvWatchProviders({
|
||||
language,
|
||||
watchRegion,
|
||||
}: {
|
||||
language?: string;
|
||||
watchRegion: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/tv',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
return data.results;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
|
||||
@@ -171,6 +171,9 @@ export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
results?: { [iso_3166_1: string]: TmdbWatchProviders };
|
||||
};
|
||||
keywords: {
|
||||
keywords: TmdbKeyword[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
@@ -428,3 +431,24 @@ export interface TmdbWatchProviderDetails {
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
}
|
||||
|
||||
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbKeyword[];
|
||||
}
|
||||
|
||||
// We have production companies, but the company search results return less data
|
||||
export interface TmdbCompany {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
|
||||
results: TmdbCompany[];
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviderRegion {
|
||||
iso_3166_1: string;
|
||||
english_name: string;
|
||||
native_name: string;
|
||||
}
|
||||
|
||||
98
server/constants/discover.ts
Normal file
98
server/constants/discover.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
|
||||
export enum DiscoverSliderType {
|
||||
RECENTLY_ADDED = 1,
|
||||
RECENT_REQUESTS,
|
||||
PLEX_WATCHLIST,
|
||||
TRENDING,
|
||||
POPULAR_MOVIES,
|
||||
MOVIE_GENRES,
|
||||
UPCOMING_MOVIES,
|
||||
STUDIOS,
|
||||
POPULAR_TV,
|
||||
TV_GENRES,
|
||||
UPCOMING_TV,
|
||||
NETWORKS,
|
||||
TMDB_MOVIE_KEYWORD,
|
||||
TMDB_MOVIE_GENRE,
|
||||
TMDB_TV_KEYWORD,
|
||||
TMDB_TV_GENRE,
|
||||
TMDB_SEARCH,
|
||||
TMDB_STUDIO,
|
||||
TMDB_NETWORK,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
{
|
||||
type: DiscoverSliderType.RECENTLY_ADDED,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.RECENT_REQUESTS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.PLEX_WATCHLIST,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TRENDING,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_MOVIES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.MOVIE_GENRES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.UPCOMING_MOVIES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.STUDIOS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.POPULAR_TV,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TV_GENRES,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.UPCOMING_TV,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.NETWORKS,
|
||||
enabled: true,
|
||||
isBuiltIn: true,
|
||||
order: 11,
|
||||
},
|
||||
];
|
||||
@@ -34,7 +34,7 @@ const dataSource = new DataSource(
|
||||
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||
);
|
||||
|
||||
export const getRepository = <Entity>(
|
||||
export const getRepository = <Entity extends object>(
|
||||
target: EntityTarget<Entity>
|
||||
): Repository<Entity> => {
|
||||
return dataSource.getRepository(target);
|
||||
|
||||
69
server/entity/DiscoverSlider.ts
Normal file
69
server/entity/DiscoverSlider.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DiscoverSliderType } from '@server/constants/discover';
|
||||
import { defaultSliders } from '@server/constants/discover';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
class DiscoverSlider {
|
||||
public static async bootstrapSliders(): Promise<void> {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
for (const slider of defaultSliders) {
|
||||
const existingSlider = await sliderRepository.findOne({
|
||||
where: {
|
||||
type: slider.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSlider) {
|
||||
logger.info('Creating built-in discovery slider', {
|
||||
label: 'Discover Slider',
|
||||
slider,
|
||||
});
|
||||
await sliderRepository.save(new DiscoverSlider(slider));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public type: DiscoverSliderType;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
public order: number;
|
||||
|
||||
@Column({ default: false })
|
||||
public isBuiltIn: boolean;
|
||||
|
||||
@Column({ default: true })
|
||||
public enabled: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
// Title is not required for built in sliders because we will
|
||||
// use translations for them.
|
||||
public title?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public data?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<DiscoverSlider>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default DiscoverSlider;
|
||||
@@ -115,29 +115,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;
|
||||
@@ -205,8 +205,8 @@ class Media {
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost!.endsWith('/')
|
||||
? jellyfinHost!.slice(0, -1)
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
|
||||
if (this.jellyfinMediaId) {
|
||||
@@ -288,7 +288,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 +300,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 +314,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 +326,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,
|
||||
|
||||
@@ -767,7 +767,16 @@ export class MediaRequest {
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrMovieOptions: RadarrMovieOptions = {
|
||||
@@ -908,7 +917,16 @@ export class MediaRequest {
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
throw new Error('Media already available');
|
||||
logger.warn('Media already exists, marking request as APPROVED', {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
@@ -1169,3 +1187,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;
|
||||
|
||||
@@ -39,7 +39,7 @@ export class User {
|
||||
return users.map((u) => u.filter(showFiltered));
|
||||
}
|
||||
|
||||
static readonly filteredFields: string[] = ['email'];
|
||||
static readonly filteredFields: string[] = ['email', 'plexId'];
|
||||
|
||||
public displayName: string;
|
||||
|
||||
@@ -76,7 +76,7 @@ export class User {
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import { Session } from '@server/entity/Session';
|
||||
import { User } from '@server/entity/User';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
@@ -16,7 +17,9 @@ 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';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
@@ -104,6 +107,9 @@ app
|
||||
);
|
||||
}
|
||||
|
||||
// Bootstrap Discovery Sliders
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
@@ -186,6 +192,10 @@ app
|
||||
next();
|
||||
});
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
(
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface CacheItem {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
version: string;
|
||||
commitTag: string;
|
||||
|
||||
@@ -257,8 +257,19 @@ class JobJellyfinSync {
|
||||
//use for loop to make sure this loop _completes_ in full
|
||||
//before the next section
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
totalStandard++;
|
||||
totalStandard += episodeCount;
|
||||
} else {
|
||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||
episode.Id
|
||||
@@ -267,11 +278,11 @@ class JobJellyfinSync {
|
||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if (MediaStream.Width ?? 0 < 2000) {
|
||||
totalStandard++;
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
total4k += episodeCount;
|
||||
} else {
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
} else {
|
||||
total4k++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
|
||||
import { radarrScanner } from '@server/lib/scanners/radarr';
|
||||
import { sonarrScanner } from '@server/lib/scanners/sonarr';
|
||||
@@ -15,7 +17,7 @@ interface ScheduledJob {
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
@@ -33,7 +35,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,
|
||||
@@ -53,7 +55,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', {
|
||||
@@ -73,7 +75,7 @@ export const startJobs = (): void => {
|
||||
id: 'jellyfin-recently-added-sync',
|
||||
name: 'Jellyfin Recently Added Sync',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
||||
job: schedule.scheduleJob(
|
||||
jobs['jellyfin-recently-added-sync'].schedule,
|
||||
@@ -93,7 +95,7 @@ export const startJobs = (): void => {
|
||||
id: 'jellyfin-full-sync',
|
||||
name: 'Jellyfin Full Library Sync',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||
@@ -111,7 +113,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'short',
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
@@ -126,7 +128,7 @@ export const startJobs = (): void => {
|
||||
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' });
|
||||
@@ -141,7 +143,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' });
|
||||
@@ -151,12 +153,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', {
|
||||
@@ -171,7 +190,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', {
|
||||
@@ -181,5 +200,21 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Run image cache cleanup every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'image-cache-cleanup',
|
||||
name: 'Image Cache Cleanup',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
};
|
||||
|
||||
718
server/lib/availabilitySync.ts
Normal file
718
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
import type { PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import type { SonarrSeason } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import Season from '@server/entity/Season';
|
||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
class AvailabilitySync {
|
||||
public running = false;
|
||||
private plexClient: PlexAPI;
|
||||
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
|
||||
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
|
||||
private radarrServers: RadarrSettings[];
|
||||
private sonarrServers: SonarrSettings[];
|
||||
|
||||
async run() {
|
||||
const settings = getSettings();
|
||||
this.running = true;
|
||||
this.plexSeasonsCache = {};
|
||||
this.sonarrSeasonsCache = {};
|
||||
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
||||
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
||||
await this.initPlexClient();
|
||||
|
||||
if (!this.plexClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting availability sync...`, {
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
const seasonRepository = getRepository(Season);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
try {
|
||||
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||
try {
|
||||
if (!this.running) {
|
||||
throw new Error('Job aborted');
|
||||
}
|
||||
|
||||
const mediaExists = await this.mediaExists(media);
|
||||
|
||||
//We can not delete media so if both versions do not exist, we will change both columns to unknown or null
|
||||
if (!mediaExists) {
|
||||
if (
|
||||
media.status !== MediaStatus.UNKNOWN ||
|
||||
media.status4k !== MediaStatus.UNKNOWN
|
||||
) {
|
||||
const request = await requestRepository.find({
|
||||
relations: {
|
||||
media: true,
|
||||
},
|
||||
where: { media: { id: media.id } },
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`${
|
||||
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
|
||||
} does not exist in any of your media instances. We will change its status to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
await mediaRepository.update(media.id, {
|
||||
status: MediaStatus.UNKNOWN,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
serviceId: null,
|
||||
serviceId4k: null,
|
||||
externalServiceId: null,
|
||||
externalServiceId4k: null,
|
||||
externalServiceSlug: null,
|
||||
externalServiceSlug4k: null,
|
||||
ratingKey: null,
|
||||
ratingKey4k: null,
|
||||
});
|
||||
|
||||
await requestRepository.remove(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
// ok, the show itself exists, but do all it's seasons?
|
||||
const seasons = await seasonRepository.find({
|
||||
where: [
|
||||
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||
{
|
||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
media: { id: media.id },
|
||||
},
|
||||
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
|
||||
{
|
||||
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
media: { id: media.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let didDeleteSeasons = false;
|
||||
for (const season of seasons) {
|
||||
if (
|
||||
!mediaExists &&
|
||||
(season.status !== MediaStatus.UNKNOWN ||
|
||||
season.status4k !== MediaStatus.UNKNOWN)
|
||||
) {
|
||||
await seasonRepository.update(
|
||||
{ id: season.id },
|
||||
{
|
||||
status: MediaStatus.UNKNOWN,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const seasonExists = await this.seasonExists(media, season);
|
||||
|
||||
if (!seasonExists) {
|
||||
logger.info(
|
||||
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
if (
|
||||
season.status !== MediaStatus.UNKNOWN ||
|
||||
season.status4k !== MediaStatus.UNKNOWN
|
||||
) {
|
||||
await seasonRepository.update(
|
||||
{ id: season.id },
|
||||
{
|
||||
status: MediaStatus.UNKNOWN,
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const seasonToBeDeleted =
|
||||
await seasonRequestRepository.findOne({
|
||||
relations: {
|
||||
request: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
where: {
|
||||
request: {
|
||||
media: {
|
||||
id: media.id,
|
||||
},
|
||||
},
|
||||
seasonNumber: season.seasonNumber,
|
||||
},
|
||||
});
|
||||
|
||||
if (seasonToBeDeleted) {
|
||||
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||
}
|
||||
|
||||
didDeleteSeasons = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didDeleteSeasons) {
|
||||
if (
|
||||
media.status === MediaStatus.AVAILABLE ||
|
||||
media.status4k === MediaStatus.AVAILABLE
|
||||
) {
|
||||
logger.info(
|
||||
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
await mediaRepository.update(media.id, {
|
||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||
await mediaRepository.update(media.id, {
|
||||
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error('Failure with media.', {
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error('Failed to complete availability sync.', {
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
} finally {
|
||||
logger.info(`Availability sync complete.`, {
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async *loadAvailableMediaPaginated(pageSize: number) {
|
||||
let offset = 0;
|
||||
const mediaRepository = getRepository(Media);
|
||||
const whereOptions = [
|
||||
{ status: MediaStatus.AVAILABLE },
|
||||
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||
{ status4k: MediaStatus.AVAILABLE },
|
||||
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||
];
|
||||
|
||||
let mediaPage: Media[];
|
||||
|
||||
do {
|
||||
yield* (mediaPage = await mediaRepository.find({
|
||||
where: whereOptions,
|
||||
skip: offset,
|
||||
take: pageSize,
|
||||
}));
|
||||
offset += pageSize;
|
||||
} while (mediaPage.length > 0);
|
||||
}
|
||||
|
||||
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const isTVType = media.mediaType === 'tv';
|
||||
|
||||
const request = await requestRepository.findOne({
|
||||
relations: {
|
||||
media: true,
|
||||
},
|
||||
where: { media: { id: media.id }, is4k: is4k ? true : false },
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
|
||||
isTVType ? 'sonarr' : 'radarr'
|
||||
} and plex instance. We will change its status to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
await mediaRepository.update(
|
||||
media.id,
|
||||
is4k
|
||||
? {
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
serviceId4k: null,
|
||||
externalServiceId4k: null,
|
||||
externalServiceSlug4k: null,
|
||||
ratingKey4k: null,
|
||||
}
|
||||
: {
|
||||
status: MediaStatus.UNKNOWN,
|
||||
serviceId: null,
|
||||
externalServiceId: null,
|
||||
externalServiceSlug: null,
|
||||
ratingKey: null,
|
||||
}
|
||||
);
|
||||
|
||||
if (isTVType) {
|
||||
const seasonRepository = getRepository(Season);
|
||||
|
||||
await seasonRepository?.update(
|
||||
{ media: { id: media.id } },
|
||||
is4k
|
||||
? { status4k: MediaStatus.UNKNOWN }
|
||||
: { status: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
await requestRepository.delete({ id: request?.id });
|
||||
}
|
||||
|
||||
private async mediaExistsInRadarr(
|
||||
media: Media,
|
||||
existsInPlex: boolean,
|
||||
existsInPlex4k: boolean
|
||||
): Promise<boolean> {
|
||||
let existsInRadarr = true;
|
||||
let existsInRadarr4k = true;
|
||||
|
||||
for (const server of this.radarrServers) {
|
||||
const api = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
const meta = await api.getMovieByTmdbId(media.tmdbId);
|
||||
|
||||
//check if both exist or if a single non-4k or 4k exists
|
||||
//if both do not exist we will return false
|
||||
if (!server.is4k && !meta.id) {
|
||||
existsInRadarr = false;
|
||||
}
|
||||
|
||||
if (server.is4k && !meta.id) {
|
||||
existsInRadarr4k = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr && existsInRadarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsInRadarr && existsInPlex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsInRadarr4k && existsInPlex4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||
//related media request will then be deleted
|
||||
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
|
||||
if (media.status !== MediaStatus.UNKNOWN) {
|
||||
this.mediaUpdater(media, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) {
|
||||
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||
this.mediaUpdater(media, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInRadarr || existsInRadarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async mediaExistsInSonarr(
|
||||
media: Media,
|
||||
existsInPlex: boolean,
|
||||
existsInPlex4k: boolean
|
||||
): Promise<boolean> {
|
||||
if (!media.tvdbId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let existsInSonarr = true;
|
||||
let existsInSonarr4k = true;
|
||||
|
||||
for (const server of this.sonarrServers) {
|
||||
const api = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const meta = await api.getSeriesByTvdbId(media.tvdbId);
|
||||
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons;
|
||||
|
||||
//check if both exist or if a single non-4k or 4k exists
|
||||
//if both do not exist we will return false
|
||||
if (!server.is4k && !meta.id) {
|
||||
existsInSonarr = false;
|
||||
}
|
||||
|
||||
if (server.is4k && !meta.id) {
|
||||
existsInSonarr4k = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInSonarr && existsInSonarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsInSonarr && existsInPlex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsInSonarr4k && existsInPlex4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//if only a single non-4k or 4k exists, then change entity columns accordingly
|
||||
//related media request will then be deleted
|
||||
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
|
||||
if (media.status !== MediaStatus.UNKNOWN) {
|
||||
this.mediaUpdater(media, false);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) {
|
||||
if (media.status4k !== MediaStatus.UNKNOWN) {
|
||||
this.mediaUpdater(media, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (existsInSonarr || existsInSonarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async seasonExistsInSonarr(
|
||||
media: Media,
|
||||
season: Season,
|
||||
seasonExistsInPlex: boolean,
|
||||
seasonExistsInPlex4k: boolean
|
||||
): Promise<boolean> {
|
||||
if (!media.tvdbId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let seasonExistsInSonarr = true;
|
||||
let seasonExistsInSonarr4k = true;
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
const seasonRepository = getRepository(Season);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
for (const server of this.sonarrServers) {
|
||||
const api = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
const seasons =
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ??
|
||||
(await api.getSeriesByTvdbId(media.tvdbId)).seasons;
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
|
||||
|
||||
const hasMonitoredSeason = seasons.find(
|
||||
({ monitored, seasonNumber }) =>
|
||||
monitored && season.seasonNumber === seasonNumber
|
||||
);
|
||||
|
||||
if (!server.is4k && !hasMonitoredSeason) {
|
||||
seasonExistsInSonarr = false;
|
||||
}
|
||||
|
||||
if (server.is4k && !hasMonitoredSeason) {
|
||||
seasonExistsInSonarr4k = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!seasonExistsInSonarr && seasonExistsInPlex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const seasonToBeDeleted = await seasonRequestRepository.findOne({
|
||||
relations: {
|
||||
request: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
where: {
|
||||
request: {
|
||||
is4k: seasonExistsInSonarr ? true : false,
|
||||
media: {
|
||||
id: media.id,
|
||||
},
|
||||
},
|
||||
seasonNumber: season.seasonNumber,
|
||||
},
|
||||
});
|
||||
|
||||
//if season does not exist, we will change status to unknown and delete related season request
|
||||
//if parent media request is empty(all related seasons have been removed), parent is automatically deleted
|
||||
if (
|
||||
!seasonExistsInSonarr &&
|
||||
seasonExistsInSonarr4k &&
|
||||
!seasonExistsInPlex
|
||||
) {
|
||||
if (season.status !== MediaStatus.UNKNOWN) {
|
||||
logger.info(
|
||||
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
await seasonRepository.update(season.id, {
|
||||
status: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
if (seasonToBeDeleted) {
|
||||
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
logger.info(
|
||||
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
await mediaRepository.update(media.id, {
|
||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
seasonExistsInSonarr &&
|
||||
!seasonExistsInSonarr4k &&
|
||||
!seasonExistsInPlex4k
|
||||
) {
|
||||
if (season.status4k !== MediaStatus.UNKNOWN) {
|
||||
logger.info(
|
||||
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
await seasonRepository.update(season.id, {
|
||||
status4k: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
if (seasonToBeDeleted) {
|
||||
await seasonRequestRepository.remove(seasonToBeDeleted);
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||
logger.info(
|
||||
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
await mediaRepository.update(media.id, {
|
||||
status4k: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seasonExistsInSonarr || seasonExistsInSonarr4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async mediaExists(media: Media): Promise<boolean> {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
|
||||
let existsInPlex = false;
|
||||
let existsInPlex4k = false;
|
||||
|
||||
//check each plex instance to see if media exists
|
||||
try {
|
||||
if (ratingKey) {
|
||||
const meta = await this.plexClient?.getMetadata(ratingKey);
|
||||
if (meta) {
|
||||
existsInPlex = true;
|
||||
}
|
||||
}
|
||||
if (ratingKey4k) {
|
||||
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
|
||||
if (meta4k) {
|
||||
existsInPlex4k = true;
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
|
||||
if (!ex.message.includes('response code: 404')) {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
//base case for if both media versions exist in plex
|
||||
if (existsInPlex && existsInPlex4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//we then check radarr or sonarr has that specific media. If not, then we will move to delete
|
||||
//if a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
||||
if (media.mediaType === 'movie') {
|
||||
const existsInRadarr = await this.mediaExistsInRadarr(
|
||||
media,
|
||||
existsInPlex,
|
||||
existsInPlex4k
|
||||
);
|
||||
|
||||
//if true, media exists in at least one radarr or plex instance.
|
||||
if (existsInRadarr) {
|
||||
logger.warn(
|
||||
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
const existsInSonarr = await this.mediaExistsInSonarr(
|
||||
media,
|
||||
existsInPlex,
|
||||
existsInPlex4k
|
||||
);
|
||||
|
||||
//if true, media exists in at least one sonarr or plex instance.
|
||||
if (existsInSonarr) {
|
||||
logger.warn(
|
||||
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async seasonExists(media: Media, season: Season) {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
|
||||
let seasonExistsInPlex = false;
|
||||
let seasonExistsInPlex4k = false;
|
||||
|
||||
if (ratingKey) {
|
||||
const children =
|
||||
this.plexSeasonsCache[ratingKey] ??
|
||||
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
|
||||
[];
|
||||
this.plexSeasonsCache[ratingKey] = children;
|
||||
const seasonMeta = children?.find(
|
||||
(child) => child.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonMeta) {
|
||||
seasonExistsInPlex = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingKey4k) {
|
||||
const children4k =
|
||||
this.plexSeasonsCache[ratingKey4k] ??
|
||||
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
|
||||
[];
|
||||
this.plexSeasonsCache[ratingKey4k] = children4k;
|
||||
const seasonMeta4k = children4k?.find(
|
||||
(child) => child.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonMeta4k) {
|
||||
seasonExistsInPlex4k = true;
|
||||
}
|
||||
}
|
||||
|
||||
//base case for if both season versions exist in plex
|
||||
if (seasonExistsInPlex && seasonExistsInPlex4k) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existsInSonarr = await this.seasonExistsInSonarr(
|
||||
media,
|
||||
season,
|
||||
seasonExistsInPlex,
|
||||
seasonExistsInPlex4k
|
||||
);
|
||||
|
||||
if (existsInSonarr) {
|
||||
logger.warn(
|
||||
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async initPlexClient() {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
logger.warning('No admin configured. Availability sync skipped.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
}
|
||||
}
|
||||
|
||||
const availabilitySync = new AvailabilitySync();
|
||||
export default availabilitySync;
|
||||
@@ -5,6 +5,12 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
interface EpisodeNumberResult {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber: number;
|
||||
id: number;
|
||||
}
|
||||
export interface DownloadingItem {
|
||||
mediaType: MediaType;
|
||||
externalId: number;
|
||||
@@ -14,6 +20,7 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
class DownloadTracker {
|
||||
@@ -164,6 +171,7 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
||||
266
server/lib/imageproxy.ts
Normal file
266
server/lib/imageproxy.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
|
||||
type ImageResponse = {
|
||||
meta: {
|
||||
revalidateAfter: number;
|
||||
curRevalidate: number;
|
||||
isStale: boolean;
|
||||
etag: string;
|
||||
extension: string;
|
||||
cacheKey: string;
|
||||
cacheMiss: boolean;
|
||||
};
|
||||
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(baseCacheDirectory, key);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const imageFiles = await promises.readdir(filePath);
|
||||
|
||||
for (const imageFile of imageFiles) {
|
||||
const [, expireAtSt] = imageFile.split('.');
|
||||
const expireAt = Number(expireAtSt);
|
||||
const now = Date.now();
|
||||
|
||||
if (now > expireAt) {
|
||||
await promises.rm(path.join(filePath, imageFile));
|
||||
deletedImages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||
label: 'Image Cache',
|
||||
});
|
||||
}
|
||||
|
||||
public static async getImageStats(
|
||||
key: string
|
||||
): Promise<{ size: number; imageCount: number }> {
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||
|
||||
return {
|
||||
size: imageTotalSize,
|
||||
imageCount,
|
||||
};
|
||||
}
|
||||
|
||||
private static async getDirectorySize(dir: string): Promise<number> {
|
||||
const files = await promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
const paths = files.map(async (file) => {
|
||||
const path = join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||
|
||||
if (file.isFile()) {
|
||||
const { size } = await promises.stat(path);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
}
|
||||
|
||||
private static async getImageCount(dir: string) {
|
||||
const files = await promises.readdir(dir);
|
||||
|
||||
return files.length;
|
||||
}
|
||||
|
||||
private axios;
|
||||
private cacheVersion;
|
||||
private key;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
options: {
|
||||
cacheVersion?: number;
|
||||
rateLimitOptions?: rateLimitOptions;
|
||||
} = {}
|
||||
) {
|
||||
this.cacheVersion = options.cacheVersion ?? 1;
|
||||
this.key = key;
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
});
|
||||
|
||||
if (options.rateLimitOptions) {
|
||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||
}
|
||||
}
|
||||
|
||||
public async getImage(path: string): Promise<ImageResponse> {
|
||||
const cacheKey = this.getCacheKey(path);
|
||||
|
||||
const imageResponse = await this.get(cacheKey);
|
||||
|
||||
if (!imageResponse) {
|
||||
const newImage = await this.set(path, cacheKey);
|
||||
|
||||
if (!newImage) {
|
||||
throw new Error('Failed to load image');
|
||||
}
|
||||
|
||||
return newImage;
|
||||
}
|
||||
|
||||
// If the image is stale, we will revalidate it in the background.
|
||||
if (imageResponse.meta.isStale) {
|
||||
this.set(path, cacheKey);
|
||||
}
|
||||
|
||||
return imageResponse;
|
||||
}
|
||||
|
||||
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const files = await promises.readdir(directory);
|
||||
const now = Date.now();
|
||||
|
||||
for (const file of files) {
|
||||
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
|
||||
const buffer = await promises.readFile(join(directory, file));
|
||||
const expireAt = Number(expireAtSt);
|
||||
const maxAge = Number(maxAgeSt);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
curRevalidate: maxAge,
|
||||
revalidateAfter: maxAge * 1000 + now,
|
||||
isStale: now > expireAt,
|
||||
etag,
|
||||
extension,
|
||||
cacheKey,
|
||||
cacheMiss: false,
|
||||
},
|
||||
imageBuffer: buffer,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// No files. Treat as empty cache.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async set(
|
||||
path: string,
|
||||
cacheKey: string
|
||||
): Promise<ImageResponse | null> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const response = await this.axios.get(path, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(response.data, 'binary');
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
const maxAge = Number(
|
||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||
);
|
||||
const expireAt = Date.now() + maxAge * 1000;
|
||||
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||
|
||||
await this.writeToCacheDir(
|
||||
directory,
|
||||
extension,
|
||||
maxAge,
|
||||
expireAt,
|
||||
buffer,
|
||||
etag
|
||||
);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
curRevalidate: maxAge,
|
||||
revalidateAfter: expireAt,
|
||||
isStale: false,
|
||||
etag,
|
||||
extension,
|
||||
cacheKey,
|
||||
cacheMiss: true,
|
||||
},
|
||||
imageBuffer: buffer,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong caching image.', {
|
||||
label: 'Image Cache',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeToCacheDir(
|
||||
dir: string,
|
||||
extension: string,
|
||||
maxAge: number,
|
||||
expireAt: number,
|
||||
buffer: Buffer,
|
||||
etag: string
|
||||
) {
|
||||
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
|
||||
|
||||
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
|
||||
await promises.mkdir(dir, { recursive: true });
|
||||
await promises.writeFile(filename, buffer);
|
||||
}
|
||||
|
||||
private getCacheKey(path: string) {
|
||||
return this.getHash([this.key, this.cacheVersion, path]);
|
||||
}
|
||||
|
||||
private getHash(items: (string | number | Buffer)[]) {
|
||||
const hash = createHash('sha256');
|
||||
for (const item of items) {
|
||||
if (typeof item === 'number') hash.update(String(item));
|
||||
else {
|
||||
hash.update(item);
|
||||
}
|
||||
}
|
||||
// See https://en.wikipedia.org/wiki/Base64#Filenames
|
||||
return hash.digest('base64').replace(/\//g, '-');
|
||||
}
|
||||
|
||||
private getCacheDirectory() {
|
||||
return path.join(baseCacheDirectory, this.key);
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageProxy;
|
||||
@@ -96,7 +96,8 @@ class PlexScanner
|
||||
// We remove 10 minutes from the last scan as a buffer
|
||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
library.type
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface PlexSettings {
|
||||
|
||||
export interface JellyfinSettings {
|
||||
name: string;
|
||||
hostname?: string;
|
||||
hostname: string;
|
||||
externalHostname?: string;
|
||||
libraries: Library[];
|
||||
serverId: string;
|
||||
@@ -263,7 +263,9 @@ export type JobId =
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-sync'
|
||||
| 'jellyfin-full-sync';
|
||||
| 'jellyfin-full-sync'
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -434,6 +436,9 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'download-sync': {
|
||||
schedule: '0 * * * * *',
|
||||
},
|
||||
@@ -446,6 +451,9 @@ class Settings {
|
||||
'jellyfin-full-sync': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'image-cache-cleanup': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
@@ -586,7 +594,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 {
|
||||
|
||||
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;
|
||||
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
15
server/migration/1672041273674-AddDiscoverSlider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
|
||||
name = 'AddDiscoverSlider1672041273674';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "discover_slider"`);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Crew,
|
||||
ExternalIds,
|
||||
Genre,
|
||||
Keyword,
|
||||
ProductionCompany,
|
||||
WatchProviders,
|
||||
} from './common';
|
||||
@@ -83,6 +84,7 @@ export interface MovieDetails {
|
||||
externalIds: ExternalIds;
|
||||
mediaUrl?: string;
|
||||
watchProviders?: WatchProviders[];
|
||||
keywords: Keyword[];
|
||||
}
|
||||
|
||||
export const mapProductionCompany = (
|
||||
@@ -142,4 +144,8 @@ export const mapMovieDetails = (
|
||||
externalIds: mapExternalIds(movie.external_ids),
|
||||
mediaInfo: media,
|
||||
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
|
||||
keywords: movie.keywords.keywords.map((keyword) => ({
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
select: { id: true, plexToken: true, plexId: true, email: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!account.id) {
|
||||
logger.error('Plex ID was missing from Plex.tv response', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: account.email,
|
||||
plexUsername: account.username,
|
||||
});
|
||||
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Try again.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
account.id === mainUser.plexId ||
|
||||
(account.email === mainUser.email && !mainUser.plexId) ||
|
||||
(await mainPlexTv.checkUserAccess(account.id))
|
||||
) {
|
||||
if (user) {
|
||||
@@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
const hostname =
|
||||
settings.jellyfin.hostname !== ''
|
||||
? settings.jellyfin.hostname
|
||||
: body.hostname;
|
||||
: body.hostname ?? '';
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
|
||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||
@@ -249,8 +264,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost!.endsWith('/')
|
||||
? jellyfinHost!.slice(0, -1)
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
|
||||
const account = await jellyfinserver.login(body.username, body.password);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import type { SortOptions } from '@server/api/themoviedb';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -20,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv';
|
||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
const settings = getSettings();
|
||||
@@ -46,25 +49,76 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
|
||||
|
||||
const discoverRoutes = Router();
|
||||
|
||||
const QueryFilterOptions = z.object({
|
||||
page: z.coerce.string().optional(),
|
||||
sortBy: z.coerce.string().optional(),
|
||||
primaryReleaseDateGte: z.coerce.string().optional(),
|
||||
primaryReleaseDateLte: z.coerce.string().optional(),
|
||||
firstAirDateGte: z.coerce.string().optional(),
|
||||
firstAirDateLte: z.coerce.string().optional(),
|
||||
studio: z.coerce.string().optional(),
|
||||
genre: z.coerce.string().optional(),
|
||||
keywords: z.coerce.string().optional(),
|
||||
language: z.coerce.string().optional(),
|
||||
withRuntimeGte: z.coerce.string().optional(),
|
||||
withRuntimeLte: z.coerce.string().optional(),
|
||||
voteAverageGte: z.coerce.string().optional(),
|
||||
voteAverageLte: z.coerce.string().optional(),
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
|
||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
||||
studio: req.query.studio ? Number(req.query.studio) : undefined,
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
language: req.locale ?? query.language,
|
||||
originalLanguage: query.language,
|
||||
genre: query.genre,
|
||||
studio: query.studio,
|
||||
primaryReleaseDateLte: query.primaryReleaseDateLte
|
||||
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
primaryReleaseDateGte: query.primaryReleaseDateGte
|
||||
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
keywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
let keywordData: TmdbKeyword[] = [];
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
keywords: keywordData,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
@@ -163,7 +217,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: Number(req.params.genreId),
|
||||
genre: req.params.genreId as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -210,7 +264,7 @@ discoverRoutes.get<{ studioId: string }>(
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
studio: Number(req.params.studioId),
|
||||
studio: req.params.studioId as string,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -296,21 +350,50 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||
|
||||
try {
|
||||
const query = QueryFilterOptions.parse(req.query);
|
||||
const keywords = query.keywords;
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: req.query.genre ? Number(req.query.genre) : undefined,
|
||||
network: req.query.network ? Number(req.query.network) : undefined,
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
language: req.locale ?? query.language,
|
||||
genre: query.genre,
|
||||
network: query.network ? Number(query.network) : undefined,
|
||||
firstAirDateLte: query.firstAirDateLte
|
||||
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
firstAirDateGte: query.firstAirDateGte
|
||||
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
originalLanguage: query.language,
|
||||
keywords,
|
||||
withRuntimeGte: query.withRuntimeGte,
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
let keywordData: TmdbKeyword[] = [];
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
keywords: keywordData,
|
||||
results: data.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
@@ -408,7 +491,7 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
genre: Number(req.params.genreId),
|
||||
genre: req.params.genreId,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
@@ -643,7 +726,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
|
||||
await Promise.all(
|
||||
genres.map(async (genre) => {
|
||||
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
|
||||
const genreData = await tmdb.getDiscoverMovies({
|
||||
genre: genre.id.toString(),
|
||||
});
|
||||
|
||||
mappedGenres.push({
|
||||
id: genre.id,
|
||||
@@ -685,7 +770,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
|
||||
await Promise.all(
|
||||
genres.map(async (genre) => {
|
||||
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
|
||||
const genreData = await tmdb.getDiscoverTv({
|
||||
genre: genre.id.toString(),
|
||||
});
|
||||
|
||||
mappedGenres.push({
|
||||
id: genre.id,
|
||||
@@ -713,12 +800,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({
|
||||
@@ -742,8 +829,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,
|
||||
|
||||
39
server/routes/imageproxy.ts
Normal file
39
server/routes/imageproxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import ImageProxy from '@server/lib/imageproxy';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Image Proxy
|
||||
*/
|
||||
router.get('/*', async (req, res) => {
|
||||
const imagePath = req.path.replace('/image', '');
|
||||
try {
|
||||
const imageData = await tmdbImageProxy.getImage(imagePath);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `image/${imageData.meta.extension}`,
|
||||
'Content-Length': imageData.imageBuffer.length,
|
||||
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||
});
|
||||
|
||||
res.end(imageData.imageBuffer);
|
||||
} catch (e) {
|
||||
logger.error('Failed to proxy image', {
|
||||
imagePath,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
res.status(500).send();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
TmdbMovieResult,
|
||||
TmdbTvResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||
import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
@@ -102,6 +105,13 @@ router.get('/settings/public', async (req, res) => {
|
||||
return res.status(200).json(settings.fullPublicSettings);
|
||||
}
|
||||
});
|
||||
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
|
||||
|
||||
return res.json(sliders);
|
||||
});
|
||||
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
||||
router.use('/search', isAuthenticated(), searchRoutes);
|
||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||
@@ -269,6 +279,87 @@ router.get('/backdrops', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/keyword/:keywordId', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getKeywordDetails({
|
||||
keywordId: Number(req.params.keywordId),
|
||||
});
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving keyword data', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve keyword data.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/regions', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getAvailableWatchProviderRegions({});
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving watch provider regions', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve watch provider regions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/movies', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getMovieWatchProviders({
|
||||
watchRegion: req.query.watchRegion as string,
|
||||
});
|
||||
|
||||
return res.status(200).json(mapWatchProviderDetails(result));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie watch providers', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve movie watch providers.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/watchproviders/tv', async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
try {
|
||||
const result = await tmdb.getTvWatchProviders({
|
||||
watchRegion: req.query.watchRegion as string,
|
||||
});
|
||||
|
||||
return res.status(200).json(mapWatchProviderDetails(result));
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving tv watch providers', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve tv watch providers.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Overseerr API',
|
||||
|
||||
@@ -308,7 +308,9 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
|
||||
|
||||
issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
'/:issueId/:status',
|
||||
isAuthenticated(Permission.MANAGE_ISSUES),
|
||||
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const issueRepository = getRepository(Issue);
|
||||
// Satisfy typescript here. User is set, we assure you!
|
||||
@@ -321,6 +323,16 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
|
||||
where: { id: Number(req.params.issueId) },
|
||||
});
|
||||
|
||||
if (
|
||||
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== req.user?.id
|
||||
) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You do not have permission to modify this issue.',
|
||||
});
|
||||
}
|
||||
|
||||
let newStatus: IssueStatus | undefined;
|
||||
|
||||
switch (req.params.status) {
|
||||
|
||||
@@ -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,7 +1,5 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
mapCastCredits,
|
||||
@@ -36,7 +34,6 @@ personRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const settings = getSettings();
|
||||
|
||||
try {
|
||||
const combinedCredits = await tmdb.getPersonCombinedCredits({
|
||||
@@ -44,30 +41,14 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||
language: req.locale ?? (req.query.language as string),
|
||||
});
|
||||
|
||||
let castMedia = await Media.getRelatedMedia(
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
let crewMedia = await Media.getRelatedMedia(
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
if (settings.main.hideAvailable) {
|
||||
castMedia = castMedia.filter(
|
||||
(media) =>
|
||||
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
|
||||
crewMedia = crewMedia.filter(
|
||||
(media) =>
|
||||
(media.mediaType === 'movie' || media.mediaType === 'tv') &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
cast: combinedCredits.cast
|
||||
.map((result) =>
|
||||
|
||||
@@ -492,8 +492,10 @@ requestRoutes.post<{
|
||||
relations: { requestedBy: true, modifiedBy: true },
|
||||
});
|
||||
|
||||
await request.updateParentStatus();
|
||||
await request.sendMedia();
|
||||
// this also triggers updating the parent media's status & sending to *arr
|
||||
request.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(request);
|
||||
|
||||
return res.status(200).json(request);
|
||||
} catch (e) {
|
||||
logger.error('Error processing request retry', {
|
||||
|
||||
@@ -56,4 +56,50 @@ searchRoutes.get('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
searchRoutes.get('/keyword', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const results = await tmdb.searchKeyword({
|
||||
query: req.query.query as string,
|
||||
page: Number(req.query.page),
|
||||
});
|
||||
|
||||
return res.status(200).json(results);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving keyword search results', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
query: req.query.query,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve keyword search results.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
searchRoutes.get('/company', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const results = await tmdb.searchCompany({
|
||||
query: req.query.query as string,
|
||||
page: Number(req.query.page),
|
||||
});
|
||||
|
||||
return res.status(200).json(results);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving company search results', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
query: req.query.query,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve company search results.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default searchRoutes;
|
||||
|
||||
131
server/routes/settings/discover.ts
Normal file
131
server/routes/settings/discover.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
const discoverSettingRoutes = Router();
|
||||
|
||||
discoverSettingRoutes.post('/', async (req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const sliders = req.body as DiscoverSlider[];
|
||||
|
||||
if (!Array.isArray(sliders)) {
|
||||
return res.status(400).json({ message: 'Invalid request body.' });
|
||||
}
|
||||
|
||||
for (let x = 0; x < sliders.length; x++) {
|
||||
const slider = sliders[x];
|
||||
const existingSlider = await sliderRepository.findOne({
|
||||
where: {
|
||||
id: slider.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSlider && slider.id) {
|
||||
existingSlider.enabled = slider.enabled;
|
||||
existingSlider.order = x;
|
||||
|
||||
// Only allow changes to the following when the slider is not built in
|
||||
if (!existingSlider.isBuiltIn) {
|
||||
existingSlider.title = slider.title;
|
||||
existingSlider.data = slider.data;
|
||||
existingSlider.type = slider.type;
|
||||
}
|
||||
|
||||
await sliderRepository.save(existingSlider);
|
||||
} else {
|
||||
const newSlider = new DiscoverSlider({
|
||||
isBuiltIn: false,
|
||||
data: slider.data,
|
||||
title: slider.title,
|
||||
enabled: slider.enabled,
|
||||
order: x,
|
||||
type: slider.type,
|
||||
});
|
||||
await sliderRepository.save(newSlider);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(sliders);
|
||||
});
|
||||
|
||||
discoverSettingRoutes.post('/add', async (req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const slider = req.body as DiscoverSlider;
|
||||
|
||||
const newSlider = new DiscoverSlider({
|
||||
isBuiltIn: false,
|
||||
data: slider.data,
|
||||
title: slider.title,
|
||||
enabled: false,
|
||||
order: -1,
|
||||
type: slider.type,
|
||||
});
|
||||
await sliderRepository.save(newSlider);
|
||||
|
||||
return res.json(newSlider);
|
||||
});
|
||||
|
||||
discoverSettingRoutes.get('/reset', async (_req, res) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
await sliderRepository.clear();
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
const slider = req.body as DiscoverSlider;
|
||||
|
||||
try {
|
||||
const existingSlider = await sliderRepository.findOneOrFail({
|
||||
where: {
|
||||
id: Number(req.params.sliderId),
|
||||
},
|
||||
});
|
||||
|
||||
// Only allow changes to the following when the slider is not built in
|
||||
if (!existingSlider.isBuiltIn) {
|
||||
existingSlider.title = slider.title;
|
||||
existingSlider.data = slider.data;
|
||||
existingSlider.type = slider.type;
|
||||
}
|
||||
|
||||
await sliderRepository.save(existingSlider);
|
||||
|
||||
return res.status(200).json(existingSlider);
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong updating a slider.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Slider not found or cannot be updated.' });
|
||||
}
|
||||
});
|
||||
|
||||
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
|
||||
const sliderRepository = getRepository(DiscoverSlider);
|
||||
|
||||
try {
|
||||
const slider = await sliderRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.sliderId), isBuiltIn: false },
|
||||
});
|
||||
|
||||
await sliderRepository.remove(slider);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting a slider.', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default discoverSettingRoutes;
|
||||
@@ -16,12 +16,14 @@ 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 { plexFullScanner } from '@server/lib/scanners/plex';
|
||||
import type { Library, MainSettings } from '@server/lib/settings';
|
||||
import type { JobId, Library, MainSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { Router } from 'express';
|
||||
@@ -41,6 +43,7 @@ const settingsRoutes = Router();
|
||||
settingsRoutes.use('/notifications', notificationRoutes);
|
||||
settingsRoutes.use('/radarr', radarrRoutes);
|
||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
@@ -312,8 +315,8 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost!.endsWith('/')
|
||||
? jellyfinHost!.slice(0, -1)
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
@@ -604,7 +607,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.post<{ jobId: string }>(
|
||||
settingsRoutes.post<{ jobId: JobId }>(
|
||||
'/jobs/:jobId/cancel',
|
||||
(req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
@@ -631,7 +634,7 @@ settingsRoutes.post<{ jobId: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ jobId: string }>(
|
||||
settingsRoutes.post<{ jobId: JobId }>(
|
||||
'/jobs/:jobId/schedule',
|
||||
(req, res, next) => {
|
||||
const scheduledJob = scheduledJobs.find(
|
||||
@@ -666,16 +669,23 @@ settingsRoutes.post<{ jobId: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.get('/cache', (req, res) => {
|
||||
const caches = cacheManager.getAllCaches();
|
||||
settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const cacheManagerCaches = cacheManager.getAllCaches();
|
||||
|
||||
return res.status(200).json(
|
||||
Object.values(caches).map((cache) => ({
|
||||
id: cache.id,
|
||||
name: cache.name,
|
||||
stats: cache.getStats(),
|
||||
}))
|
||||
);
|
||||
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
|
||||
id: cache.id,
|
||||
name: cache.name,
|
||||
stats: cache.getStats(),
|
||||
}));
|
||||
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
|
||||
@@ -502,8 +502,8 @@ router.post(
|
||||
? externalHostname
|
||||
: hostname;
|
||||
|
||||
jellyfinHost = jellyfinHost!.endsWith('/')
|
||||
? jellyfinHost!.slice(0, -1)
|
||||
jellyfinHost = jellyfinHost.endsWith('/')
|
||||
? jellyfinHost.slice(0, -1)
|
||||
: jellyfinHost;
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||
@@ -685,7 +685,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 (
|
||||
@@ -705,7 +705,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -729,8 +729,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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import IssueComment from '@server/entity/IssueComment';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
@@ -32,6 +33,10 @@ export class IssueCommentSubscriber
|
||||
})
|
||||
).issue;
|
||||
|
||||
const createdBy = await getRepository(User).findOneOrFail({
|
||||
where: { id: issue.createdBy.id },
|
||||
});
|
||||
|
||||
const media = await getRepository(Media).findOneOrFail({
|
||||
where: { id: issue.media.id },
|
||||
});
|
||||
@@ -71,9 +76,9 @@ export class IssueCommentSubscriber
|
||||
notifyAdmin: true,
|
||||
notifySystem: true,
|
||||
notifyUser:
|
||||
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
issue.createdBy.id !== entity.user.id
|
||||
? issue.createdBy
|
||||
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
createdBy.id !== entity.user.id
|
||||
? createdBy
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
|
||||
notifySystem: true,
|
||||
notifyUser:
|
||||
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
|
||||
entity.modifiedBy?.id !== entity.createdBy.id &&
|
||||
(type === Notification.ISSUE_RESOLVED ||
|
||||
type === Notification.ISSUE_REOPENED)
|
||||
? entity.createdBy
|
||||
|
||||
9
server/types/express-session.d.ts
vendored
Normal file
9
server/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'express-session';
|
||||
|
||||
// Declaration merging to apply our own types to SessionData
|
||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId: number;
|
||||
}
|
||||
}
|
||||
9
server/types/express.d.ts
vendored
9
server/types/express.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import 'express-session';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -16,11 +17,3 @@ declare global {
|
||||
next: NextFunction
|
||||
) => Promise<void | NextFunction> | void | NextFunction;
|
||||
}
|
||||
|
||||
// Declaration merging to apply our own types to SessionData
|
||||
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
|
||||
declare module 'express-session' {
|
||||
export interface SessionData {
|
||||
userId: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: overseerr
|
||||
adopt-info: overseerr
|
||||
name: jellyseerr
|
||||
adopt-info: jellyseerr
|
||||
license: MIT
|
||||
summary: Request management and media discovery tool for the Plex ecosystem.
|
||||
summary: Request management and media discovery tool for media servers
|
||||
description: >
|
||||
Overseerr is a free and open source software application for managing requests for your media library.
|
||||
It integrates with your existing services such as Sonarr, Radarr and Plex!
|
||||
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
|
||||
confinement: strict
|
||||
|
||||
@@ -14,7 +15,7 @@ architectures:
|
||||
- build-on: armhf
|
||||
|
||||
parts:
|
||||
overseerr:
|
||||
jellyseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: '16.17.0'
|
||||
nodejs-package-manager: 'yarn'
|
||||
@@ -36,7 +37,7 @@ parts:
|
||||
override-pull: |
|
||||
snapcraftctl pull
|
||||
# Get information to determine snap grade and version
|
||||
git config --global --add safe.directory /data/parts/overseerr/src
|
||||
git config --global --add safe.directory /data/parts/jellyyseerr/src
|
||||
#setup yarn.rc
|
||||
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</Badge>
|
||||
{showRelative && (
|
||||
|
||||
@@ -10,13 +10,14 @@ 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 { DownloadIcon } from '@heroicons/react/outline';
|
||||
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';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -39,6 +40,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||
const [downloadStatus, downloadStatus4k] = [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
|
||||
return { downloadStatus, downloadStatus4k };
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
@@ -46,11 +60,31 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
} = 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 [
|
||||
data?.parts
|
||||
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
|
||||
.map((title) => title.title),
|
||||
data?.parts
|
||||
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
|
||||
.map((title) => title.title),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -205,6 +239,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
downloadItem={downloadStatus}
|
||||
title={titles}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
)}
|
||||
@@ -218,6 +254,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={collectionStatus4k}
|
||||
downloadItem={downloadStatus4k}
|
||||
title={titles4k}
|
||||
is4k
|
||||
inProgress={data.parts.some(
|
||||
(part) =>
|
||||
@@ -250,7 +288,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
<DownloadIcon />
|
||||
<ArrowDownTrayIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
hasRequestable
|
||||
@@ -269,7 +307,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
setIs4k(true);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
<ArrowDownTrayIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestcollection4k)}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ExclamationIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
interface AlertProps {
|
||||
title?: React.ReactNode;
|
||||
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
|
||||
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
|
||||
titleColor: 'text-yellow-100',
|
||||
textColor: 'text-yellow-300',
|
||||
svg: <ExclamationIcon className="h-5 w-5" />,
|
||||
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
|
||||
@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
ref?: React.Ref<Element<P>>
|
||||
): JSX.Element {
|
||||
const buttonStyle = [
|
||||
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
|
||||
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
|
||||
];
|
||||
switch (buttonType) {
|
||||
case 'primary':
|
||||
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
break;
|
||||
case 'ghost':
|
||||
buttonStyle.push(
|
||||
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/solid';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import type { ImageProps } from 'next/image';
|
||||
import type { ImageLoader, ImageProps } from 'next/image';
|
||||
import Image from 'next/image';
|
||||
|
||||
const imageLoader: ImageLoader = ({ src }) => src;
|
||||
|
||||
/**
|
||||
* The CachedImage component should be used wherever
|
||||
* we want to offer the option to locally cache images.
|
||||
*
|
||||
* It uses the `next/image` Image component but overrides
|
||||
* the `unoptimized` prop based on the application setting `cacheImages`.
|
||||
**/
|
||||
const CachedImage = (props: ImageProps) => {
|
||||
const CachedImage = ({ src, ...props }: ImageProps) => {
|
||||
const { currentSettings } = useSettings();
|
||||
|
||||
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
|
||||
let imageUrl = src;
|
||||
|
||||
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
|
||||
const parsedUrl = new URL(imageUrl);
|
||||
|
||||
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
|
||||
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
|
||||
}
|
||||
}
|
||||
|
||||
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
|
||||
};
|
||||
|
||||
export default CachedImage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import { useRef, useState } from 'react';
|
||||
import { forwardRef, useRef, useState } from 'react';
|
||||
|
||||
interface ConfirmButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ConfirmButton = ({
|
||||
onClick,
|
||||
children,
|
||||
confirmText,
|
||||
className,
|
||||
}: ConfirmButtonProps) => {
|
||||
const ref = useRef(null);
|
||||
useClickOutside(ref, () => setIsClicked(false));
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
|
||||
({ onClick, children, confirmText, className }, parentRef) => {
|
||||
const ref = useRef(null);
|
||||
useClickOutside(ref, () => setIsClicked(false));
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
ref={parentRef}
|
||||
buttonType="danger"
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isClicked) {
|
||||
setIsClicked(true);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||
isClicked
|
||||
? '-translate-y-full opacity-0'
|
||||
: 'translate-y-0 opacity-100'
|
||||
}`}
|
||||
if (!isClicked) {
|
||||
setIsClicked(true);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||
isClicked
|
||||
? '-translate-y-full opacity-0'
|
||||
: 'translate-y-0 opacity-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
|
||||
isClicked
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-full opacity-0'
|
||||
}`}
|
||||
>
|
||||
{confirmText}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ConfirmButton.displayName = 'ConfirmButton';
|
||||
|
||||
export default ConfirmButton;
|
||||
|
||||
@@ -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}
|
||||
|
||||
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import useDebouncedState from '@app/hooks/useDebouncedState';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type MultiRangeSliderProps = {
|
||||
min: number;
|
||||
max: number;
|
||||
defaultMinValue?: number;
|
||||
defaultMaxValue?: number;
|
||||
subText?: string;
|
||||
onUpdateMin: (min: number) => void;
|
||||
onUpdateMax: (max: number) => void;
|
||||
};
|
||||
|
||||
const MultiRangeSlider = ({
|
||||
min,
|
||||
max,
|
||||
defaultMinValue,
|
||||
defaultMaxValue,
|
||||
subText,
|
||||
onUpdateMin,
|
||||
onUpdateMax,
|
||||
}: MultiRangeSliderProps) => {
|
||||
const touched = useRef(false);
|
||||
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
|
||||
defaultMinValue ?? min
|
||||
);
|
||||
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
|
||||
defaultMaxValue ?? max
|
||||
);
|
||||
|
||||
const minThumb = ((valueMin - min) / (max - min)) * 100;
|
||||
const maxThumb = ((valueMax - min) / (max - min)) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMin(finalValueMin);
|
||||
}
|
||||
}, [finalValueMin, onUpdateMin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMax(finalValueMax);
|
||||
}
|
||||
}, [finalValueMax, onUpdateMax]);
|
||||
|
||||
useEffect(() => {
|
||||
touched.current = false;
|
||||
setValueMax(defaultMaxValue ?? max);
|
||||
setValueMin(defaultMinValue ?? min);
|
||||
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
|
||||
<Tooltip
|
||||
content={valueMin.toString()}
|
||||
tooltipConfig={{
|
||||
placement: 'top',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMin}
|
||||
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
|
||||
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
|
||||
}`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value <= valueMax) {
|
||||
touched.current = true;
|
||||
setValueMin(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={valueMax}>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMax}
|
||||
step="1"
|
||||
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value >= valueMin) {
|
||||
touched.current = true;
|
||||
setValueMax(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
|
||||
style={{
|
||||
left: `${minThumb}%`,
|
||||
right: `${100 - maxThumb}%`,
|
||||
}}
|
||||
/>
|
||||
{subText && (
|
||||
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
|
||||
<span>{subText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiRangeSlider;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
|
||||
import { Field } from 'formik';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
||||
type="button"
|
||||
className="input-action"
|
||||
>
|
||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
38
src/components/Common/SlideCheckbox/index.tsx
Normal file
38
src/components/Common/SlideCheckbox/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
type SlideCheckboxProps = {
|
||||
onClick: () => void;
|
||||
checked?: boolean;
|
||||
};
|
||||
|
||||
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
||||
return (
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={false}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
checked ? 'bg-indigo-500' : 'bg-gray-700'
|
||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
} 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideCheckbox;
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
@@ -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,20 +58,20 @@ 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 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()}
|
||||
>
|
||||
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
||||
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
||||
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
<h2 className="text-overseerr text-2xl font-bold leading-7">
|
||||
@@ -83,7 +83,7 @@ const SlideOver = ({
|
||||
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<XIcon className="h-6 w-6" />
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,8 +95,10 @@ const SlideOver = ({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="relative flex-1 px-4 py-6 text-white">
|
||||
{children}
|
||||
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex-1 px-4 py-6 text-white">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
import {
|
||||
BellIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
MinusSmIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import { CheckCircleIcon } from '@heroicons/react/20/solid';
|
||||
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
|
||||
interface StatusBadgeMiniProps {
|
||||
status: MediaStatus;
|
||||
is4k?: boolean;
|
||||
inProgress?: boolean;
|
||||
// Should the badge shrink on mobile to a smaller size? (TitleCard)
|
||||
shrink?: boolean;
|
||||
}
|
||||
|
||||
const StatusBadgeMini = ({ status, is4k = false }: StatusBadgeMiniProps) => {
|
||||
const badgeStyle = ['w-5 rounded-full p-0.5 text-white ring-1'];
|
||||
const StatusBadgeMini = ({
|
||||
status,
|
||||
is4k = false,
|
||||
inProgress = false,
|
||||
shrink = false,
|
||||
}: StatusBadgeMiniProps) => {
|
||||
const badgeStyle = [
|
||||
`rounded-full bg-opacity-80 shadow-md ${
|
||||
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
|
||||
}`,
|
||||
];
|
||||
|
||||
let indicatorIcon: React.ReactNode;
|
||||
|
||||
switch (status) {
|
||||
case MediaStatus.PROCESSING:
|
||||
badgeStyle.push('bg-indigo-500 ring-indigo-400');
|
||||
badgeStyle.push(
|
||||
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
|
||||
);
|
||||
indicatorIcon = <ClockIcon />;
|
||||
break;
|
||||
case MediaStatus.AVAILABLE:
|
||||
badgeStyle.push('bg-green-500 ring-green-400');
|
||||
indicatorIcon = <CheckIcon />;
|
||||
badgeStyle.push(
|
||||
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||
);
|
||||
indicatorIcon = <CheckCircleIcon />;
|
||||
break;
|
||||
case MediaStatus.PENDING:
|
||||
badgeStyle.push('bg-yellow-500 ring-yellow-400');
|
||||
badgeStyle.push(
|
||||
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
);
|
||||
indicatorIcon = <BellIcon />;
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
badgeStyle.push('bg-green-500 ring-green-400');
|
||||
indicatorIcon = <MinusSmIcon />;
|
||||
badgeStyle.push(
|
||||
'bg-green-500 border-green-400 ring-green-400 text-green-100'
|
||||
);
|
||||
indicatorIcon = <MinusSmallIcon />;
|
||||
break;
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
indicatorIcon = <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex whitespace-nowrap rounded-full text-xs font-semibold leading-5 ring-1 ring-gray-700">
|
||||
<div
|
||||
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
|
||||
shrink ? '' : 'ring-1'
|
||||
}`}
|
||||
>
|
||||
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
|
||||
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
|
||||
</div>
|
||||
|
||||
24
src/components/Common/Tag/index.tsx
Normal file
24
src/components/Common/Tag/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TagIcon } from '@heroicons/react/24/outline';
|
||||
import React from 'react';
|
||||
|
||||
type TagProps = {
|
||||
children: React.ReactNode;
|
||||
iconSvg?: JSX.Element;
|
||||
};
|
||||
|
||||
const Tag = ({ children, iconSvg }: TagProps) => {
|
||||
return (
|
||||
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
|
||||
{iconSvg ? (
|
||||
React.cloneElement(iconSvg, {
|
||||
className: 'mr-1 h-4 w-4',
|
||||
})
|
||||
) : (
|
||||
<TagIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tag;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import type { Config } from 'react-popper-tooltip';
|
||||
import { usePopperTooltip } from 'react-popper-tooltip';
|
||||
|
||||
@@ -6,9 +7,15 @@ type TooltipProps = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactElement;
|
||||
tooltipConfig?: Partial<Config>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
|
||||
const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
tooltipConfig,
|
||||
className,
|
||||
}: TooltipProps) => {
|
||||
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
|
||||
usePopperTooltip({
|
||||
followCursor: true,
|
||||
@@ -17,20 +24,30 @@ const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
|
||||
...tooltipConfig,
|
||||
});
|
||||
|
||||
const tooltipStyle = [
|
||||
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
|
||||
];
|
||||
|
||||
if (className) {
|
||||
tooltipStyle.push(className);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, { ref: setTriggerRef })}
|
||||
{visible && content && (
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
{...getTooltipProps({
|
||||
className:
|
||||
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
|
||||
})}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{visible &&
|
||||
content &&
|
||||
ReactDOM.createPortal(
|
||||
<div
|
||||
ref={setTooltipRef}
|
||||
{...getTooltipProps({
|
||||
className: tooltipStyle.join(' '),
|
||||
})}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 max-h-full max-w-full"
|
||||
/>
|
||||
<div className="relative h-full w-full">
|
||||
<CachedImage
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 h-full w-full"
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
|
||||
28
src/components/CompanyTag/index.tsx
Normal file
28
src/components/CompanyTag/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
|
||||
import type { ProductionCompany, TvNetwork } from '@server/models/common';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type CompanyTagProps = {
|
||||
type: 'studio' | 'network';
|
||||
companyId: number;
|
||||
};
|
||||
|
||||
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
|
||||
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
|
||||
`/api/v1/${type}/${companyId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<Tag>
|
||||
<Spinner className="h-4 w-4" />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
|
||||
};
|
||||
|
||||
export default CompanyTag;
|
||||
506
src/components/Discover/CreateSlider/index.tsx
Normal file
506
src/components/Discover/CreateSlider/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
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 { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbGenre,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages({
|
||||
addSlider: 'Add Slider',
|
||||
editSlider: 'Edit Slider',
|
||||
slidernameplaceholder: 'Slider Name',
|
||||
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
|
||||
providetmdbgenreid: 'Provide a TMDB Genre ID',
|
||||
providetmdbsearch: 'Provide a search query',
|
||||
providetmdbstudio: 'Provide TMDB Studio ID',
|
||||
providetmdbnetwork: 'Provide TMDB Network ID',
|
||||
addsuccess: 'Created new slider and saved discover customization settings.',
|
||||
addfail: 'Failed to create new slider.',
|
||||
editsuccess: 'Edited slider and saved discover customization settings.',
|
||||
editfail: 'Failed to edit slider.',
|
||||
needresults: 'You need to have at least 1 result.',
|
||||
validationDatarequired: 'You must provide a data value.',
|
||||
validationTitlerequired: 'You must provide a title.',
|
||||
addcustomslider: 'Create Custom Slider',
|
||||
searchKeywords: 'Search keywords…',
|
||||
searchGenres: 'Search genres…',
|
||||
searchStudios: 'Search studios…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
});
|
||||
|
||||
type CreateSliderProps = {
|
||||
onCreate: () => void;
|
||||
slider?: Partial<DiscoverSlider>;
|
||||
};
|
||||
|
||||
type CreateOption = {
|
||||
type: DiscoverSliderType;
|
||||
title: string;
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const [resultCount, setResultCount] = useState(0);
|
||||
const [defaultDataValue, setDefaultDataValue] = useState<
|
||||
{ label: string; value: number }[] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (slider) {
|
||||
const loadDefaultKeywords = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keywords = await Promise.all(
|
||||
slider.data.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
})
|
||||
);
|
||||
|
||||
setDefaultDataValue(
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const loadDefaultGenre = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<TmdbGenre[]>(
|
||||
`/api/v1/genres/${
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
||||
}`
|
||||
);
|
||||
|
||||
const genre = response.data.find(
|
||||
(genre) => genre.id === Number(slider.data)
|
||||
);
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
label: genre?.name ?? '',
|
||||
value: genre?.id ?? 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const loadDefaultCompany = async (): Promise<void> => {
|
||||
if (!slider.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<ProductionCompany>(
|
||||
`/api/v1/studio/${slider.data}`
|
||||
);
|
||||
|
||||
const studio = response.data;
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
label: studio.name ?? '',
|
||||
value: studio.id ?? 0,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
switch (slider.type) {
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
loadDefaultKeywords();
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
loadDefaultGenre();
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
loadDefaultCompany();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [slider]);
|
||||
|
||||
const CreateSliderSchema = Yup.object().shape({
|
||||
title: Yup.string().required(
|
||||
intl.formatMessage(messages.validationTitlerequired)
|
||||
),
|
||||
data: Yup.string().required(
|
||||
intl.formatMessage(messages.validationDatarequired)
|
||||
),
|
||||
});
|
||||
|
||||
const updateResultCount = useCallback(
|
||||
(count: number) => {
|
||||
setResultCount(count);
|
||||
},
|
||||
[setResultCount]
|
||||
);
|
||||
|
||||
const loadKeywordOptions = async (inputValue: string) => {
|
||||
const results = await axios.get<TmdbKeywordSearchResponse>(
|
||||
'/api/v1/search/keyword',
|
||||
{
|
||||
params: {
|
||||
query: encodeURIExtraParams(inputValue),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return results.data.results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadCompanyOptions = async (inputValue: string) => {
|
||||
if (inputValue === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await axios.get<TmdbCompanySearchResponse>(
|
||||
'/api/v1/search/company',
|
||||
{
|
||||
params: {
|
||||
query: encodeURIExtraParams(inputValue),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return results.data.results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadMovieGenreOptions = async () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
'/api/v1/discover/genreslider/movie'
|
||||
);
|
||||
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadTvGenreOptions = async () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
'/api/v1/discover/genreslider/tv'
|
||||
);
|
||||
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const options: CreateOption[] = [
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
|
||||
dataUrl: '/api/v1/discover/movies',
|
||||
params: 'keywords=$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_TV_KEYWORD,
|
||||
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
|
||||
dataUrl: '/api/v1/discover/tv',
|
||||
params: 'keywords=$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
|
||||
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
|
||||
dataUrl: '/api/v1/discover/movies/genre/$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_TV_GENRE,
|
||||
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
|
||||
dataUrl: '/api/v1/discover/tv/genre/$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_STUDIO,
|
||||
title: intl.formatMessage(sliderTitles.tmdbstudio),
|
||||
dataUrl: '/api/v1/discover/movies/studio/$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_NETWORK,
|
||||
title: intl.formatMessage(sliderTitles.tmdbnetwork),
|
||||
dataUrl: '/api/v1/discover/tv/network/$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_SEARCH,
|
||||
title: intl.formatMessage(sliderTitles.tmdbsearch),
|
||||
dataUrl: '/api/v1/search',
|
||||
params: 'query=$value',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={
|
||||
slider
|
||||
? {
|
||||
sliderType: slider.type,
|
||||
title: slider.title,
|
||||
data: slider.data,
|
||||
}
|
||||
: {
|
||||
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
|
||||
title: '',
|
||||
data: '',
|
||||
}
|
||||
}
|
||||
validationSchema={CreateSliderSchema}
|
||||
enableReinitialize
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
try {
|
||||
if (slider) {
|
||||
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
});
|
||||
} else {
|
||||
await axios.post('/api/v1/settings/discover/add', {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
});
|
||||
}
|
||||
|
||||
addToast(
|
||||
intl.formatMessage(
|
||||
slider ? messages.editsuccess : messages.addsuccess
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
onCreate();
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(slider ? messages.editfail : messages.addfail),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
|
||||
const activeOption = options.find(
|
||||
(option) => option.type === Number(values.sliderType)
|
||||
);
|
||||
|
||||
let dataInput: React.ReactNode;
|
||||
|
||||
switch (activeOption?.type) {
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`keyword-select-${defaultDataValue}`}
|
||||
inputId="data"
|
||||
isMulti
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
noOptionsMessage={({ inputValue }) =>
|
||||
inputValue === ''
|
||||
? intl.formatMessage(messages.starttyping)
|
||||
: intl.formatMessage(messages.nooptions)
|
||||
}
|
||||
defaultValue={defaultDataValue}
|
||||
loadOptions={loadKeywordOptions}
|
||||
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||
onChange={(value) => {
|
||||
const keywords = value.map((item) => item.value).join(',');
|
||||
|
||||
setFieldValue('data', keywords);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`movie-genre-select-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadMovieGenreOptions}
|
||||
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`tv-genre-select-${defaultDataValue}}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadTvGenreOptions}
|
||||
placeholder={intl.formatMessage(messages.searchGenres)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
dataInput = (
|
||||
<AsyncSelect
|
||||
key={`studio-select-${defaultDataValue}`}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
defaultValue={defaultDataValue?.[0]}
|
||||
defaultOptions
|
||||
cacheOptions
|
||||
loadOptions={loadCompanyOptions}
|
||||
placeholder={intl.formatMessage(messages.searchStudios)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('data', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
dataInput = (
|
||||
<Field
|
||||
type="text"
|
||||
name="data"
|
||||
id="data"
|
||||
placeholder={activeOption?.dataPlaceholderText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form data-testid="create-discover-option-form">
|
||||
<div className="flex flex-col space-y-2 text-gray-100">
|
||||
<Field as="select" id="sliderType" name="sliderType">
|
||||
{options.map((option) => (
|
||||
<option value={option.type} key={`type-${option.type}`}>
|
||||
{option.title}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
<Field
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
placeholder={activeOption?.titlePlaceholderText}
|
||||
/>
|
||||
{errors.title &&
|
||||
touched.title &&
|
||||
typeof errors.title === 'string' && (
|
||||
<div className="error">{errors.title}</div>
|
||||
)}
|
||||
{dataInput}
|
||||
{errors.data &&
|
||||
touched.data &&
|
||||
typeof errors.data === 'string' && (
|
||||
<div className="error">{errors.data}</div>
|
||||
)}
|
||||
<div className="flex-1"></div>
|
||||
{resultCount === 0 ? (
|
||||
<Tooltip content={intl.formatMessage(messages.needresults)}>
|
||||
<div>
|
||||
<Button buttonType="primary" buttonSize="sm" disabled>
|
||||
{intl.formatMessage(messages.addSlider)}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
slider ? messages.editSlider : messages.addSlider
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeOption && values.title && values.data && (
|
||||
<div className="relative py-4">
|
||||
<MediaSlider
|
||||
sliderKey={`preview-${values.title}`}
|
||||
title={values.title}
|
||||
url={activeOption?.dataUrl.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
onNewTitles={updateResultCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSlider;
|
||||
@@ -1,16 +1,20 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import type { MovieResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Popular Movies',
|
||||
keywordMovies: '{keywordTitle} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovies = () => {
|
||||
const DiscoverMovieKeyword = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
@@ -21,13 +25,25 @@ const DiscoverMovies = () => {
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
||||
firstResultData,
|
||||
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
|
||||
`/api/v1/discover/movies`,
|
||||
{
|
||||
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.keywordMovies, {
|
||||
keywordTitle: firstResultData?.keywords
|
||||
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||
.join(', '),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,4 +64,4 @@ const DiscoverMovies = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovies;
|
||||
export default DiscoverMovieKeyword;
|
||||
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { MovieResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Movies',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortReleaseDateAsc: 'Release Date Ascending',
|
||||
sortReleaseDateDesc: 'Release Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
ReleaseDateAsc: 'release_date.asc',
|
||||
ReleaseDateDesc: 'release_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMovies = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult, unknown, FilterOptions>(
|
||||
'/api/v1/discover/movies',
|
||||
preparedFilters
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateDesc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="movie"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovies;
|
||||
334
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal file
334
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
|
||||
import Tag from '@app/components/Common/Tag';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import CompanyTag from '@app/components/CompanyTag';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import CreateSlider from '@app/components/Discover/CreateSlider';
|
||||
import GenreTag from '@app/components/GenreTag';
|
||||
import KeywordTag from '@app/components/KeywordTag';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
Bars3Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import axios from 'axios';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useDrag, useDrop } from 'react-aria';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
|
||||
const messages = defineMessages({
|
||||
deletesuccess: 'Sucessfully deleted slider.',
|
||||
deletefail: 'Failed to delete slider.',
|
||||
remove: 'Remove',
|
||||
enable: 'Toggle Visibility',
|
||||
});
|
||||
|
||||
const Position = {
|
||||
None: 'None',
|
||||
Above: 'Above',
|
||||
Below: 'Below',
|
||||
} as const;
|
||||
|
||||
type DiscoverSliderEditProps = {
|
||||
slider: Partial<DiscoverSlider>;
|
||||
onEnable: () => void;
|
||||
onDelete: () => void;
|
||||
onPositionUpdate: (
|
||||
updatedItemId: number,
|
||||
position: keyof typeof Position,
|
||||
isClickable: boolean
|
||||
) => void;
|
||||
children: React.ReactNode;
|
||||
disableUpButton: boolean;
|
||||
disableDownButton: boolean;
|
||||
};
|
||||
|
||||
const DiscoverSliderEdit = ({
|
||||
slider,
|
||||
children,
|
||||
onEnable,
|
||||
onDelete,
|
||||
onPositionUpdate,
|
||||
disableUpButton,
|
||||
disableDownButton,
|
||||
}: DiscoverSliderEditProps) => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
|
||||
Position.None
|
||||
);
|
||||
|
||||
const { dragProps, isDragging } = useDrag({
|
||||
getItems() {
|
||||
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSlider = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
|
||||
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
onDelete();
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.deletefail), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { dropProps } = useDrop({
|
||||
ref,
|
||||
onDropMove: (e) => {
|
||||
if (ref.current) {
|
||||
const middlePoint = ref.current.offsetHeight / 2;
|
||||
|
||||
if (e.y < middlePoint) {
|
||||
setHoverPosition(Position.Above);
|
||||
} else {
|
||||
setHoverPosition(Position.Below);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDropExit: () => {
|
||||
setHoverPosition(Position.None);
|
||||
},
|
||||
onDrop: async (e) => {
|
||||
const items = await Promise.all(
|
||||
e.items
|
||||
.filter((item) => item.kind === 'text' && item.types.has('id'))
|
||||
.map(async (item) => {
|
||||
if (item.kind === 'text') {
|
||||
return item.getText('id');
|
||||
}
|
||||
})
|
||||
);
|
||||
if (items?.[0]) {
|
||||
const dropped = Number(items[0]);
|
||||
onPositionUpdate(dropped, hoverPosition, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
|
||||
switch (slider.type) {
|
||||
case DiscoverSliderType.RECENTLY_ADDED:
|
||||
return intl.formatMessage(sliderTitles.recentlyAdded);
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
return intl.formatMessage(sliderTitles.recentrequests);
|
||||
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||
return intl.formatMessage(sliderTitles.plexwatchlist);
|
||||
case DiscoverSliderType.TRENDING:
|
||||
return intl.formatMessage(sliderTitles.trending);
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
return intl.formatMessage(sliderTitles.popularmovies);
|
||||
case DiscoverSliderType.MOVIE_GENRES:
|
||||
return intl.formatMessage(sliderTitles.moviegenres);
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
return intl.formatMessage(sliderTitles.upcoming);
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
return intl.formatMessage(sliderTitles.studios);
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
return intl.formatMessage(sliderTitles.populartv);
|
||||
case DiscoverSliderType.TV_GENRES:
|
||||
return intl.formatMessage(sliderTitles.tvgenres);
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
return intl.formatMessage(sliderTitles.upcomingtv);
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
return intl.formatMessage(sliderTitles.networks);
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
return intl.formatMessage(sliderTitles.tmdbtvgenre);
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
return intl.formatMessage(sliderTitles.tmdbstudio);
|
||||
case DiscoverSliderType.TMDB_NETWORK:
|
||||
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||
default:
|
||||
return 'Unknown Slider';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`discover-slider-${slider.id}-editing`}
|
||||
data-testid="discover-slider-edit-mode"
|
||||
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
|
||||
isDragging ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
{...dragProps}
|
||||
{...dropProps}
|
||||
ref={ref}
|
||||
>
|
||||
{hoverPosition === Position.Above && (
|
||||
<div
|
||||
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
|
||||
/>
|
||||
)}
|
||||
{hoverPosition === Position.Below && (
|
||||
<div
|
||||
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
|
||||
<div
|
||||
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>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
slider.data ? 'mb-4' : ''
|
||||
} flex-1 md:mb-0`}
|
||||
>
|
||||
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
|
||||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
|
||||
<div className="flex space-x-2">
|
||||
{slider.data?.split(',').map((keywordId) => (
|
||||
<KeywordTag
|
||||
key={`slider-keywords-${slider.id}-${keywordId}`}
|
||||
keywordId={Number(keywordId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
|
||||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
|
||||
<CompanyTag
|
||||
type={
|
||||
slider.type === DiscoverSliderType.TMDB_STUDIO
|
||||
? 'studio'
|
||||
: 'network'
|
||||
}
|
||||
companyId={Number(slider.data)}
|
||||
/>
|
||||
)}
|
||||
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
|
||||
<GenreTag
|
||||
type={
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
|
||||
? 'movie'
|
||||
: 'tv'
|
||||
}
|
||||
genreId={Number(slider.data)}
|
||||
/>
|
||||
)}
|
||||
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
|
||||
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!slider.isBuiltIn && (
|
||||
<>
|
||||
{!isEditing ? (
|
||||
<Button
|
||||
buttonType="warning"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
buttonType="default"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.cancel)}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
data-testid="discover-slider-remove-button"
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
deleteSlider();
|
||||
}}
|
||||
>
|
||||
<XMarkIcon />
|
||||
<span>{intl.formatMessage(messages.remove)}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
|
||||
<button
|
||||
className={'hover:text-white disabled:text-gray-800'}
|
||||
onClick={() =>
|
||||
onPositionUpdate(Number(slider.id), Position.Above, true)
|
||||
}
|
||||
disabled={disableUpButton}
|
||||
>
|
||||
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||
</button>
|
||||
<button
|
||||
className={'hover:text-white disabled:text-gray-800'}
|
||||
onClick={() =>
|
||||
onPositionUpdate(Number(slider.id), Position.Below, true)
|
||||
}
|
||||
disabled={disableDownButton}
|
||||
>
|
||||
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
|
||||
<Tooltip content={intl.formatMessage(messages.enable)}>
|
||||
<div>
|
||||
<SlideCheckbox
|
||||
onClick={() => {
|
||||
onEnable();
|
||||
}}
|
||||
checked={slider.enabled}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="p-4">
|
||||
<CreateSlider
|
||||
onCreate={() => {
|
||||
onDelete();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
slider={slider}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverSliderEdit;
|
||||
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Series',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortFirstAirDateAsc: 'First Air Date Ascending',
|
||||
sortFirstAirDateDesc: 'First Air Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
FirstAirDateAsc: 'first_air_date.asc',
|
||||
FirstAirDateDesc: 'first_air_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverTv = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
|
||||
...preparedFilters,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.FirstAirDateDesc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.FirstAirDateAsc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="tv"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTv;
|
||||
@@ -1,16 +1,20 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Popular Series',
|
||||
keywordSeries: '{keywordTitle} Series',
|
||||
});
|
||||
|
||||
const DiscoverTv = () => {
|
||||
const DiscoverTvKeyword = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
@@ -21,13 +25,25 @@ const DiscoverTv = () => {
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
||||
firstResultData,
|
||||
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
|
||||
`/api/v1/discover/tv`,
|
||||
{
|
||||
keywords: encodeURIExtraParams(router.query.keywords as string),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.keywordSeries, {
|
||||
keywordTitle: firstResultData?.keywords
|
||||
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
|
||||
.join(', '),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,14 +54,14 @@ const DiscoverTv = () => {
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTv;
|
||||
export default DiscoverTvKeyword;
|
||||
297
src/components/Discover/FilterSlideover/index.tsx
Normal file
297
src/components/Discover/FilterSlideover/index.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import {
|
||||
useBatchUpdateQueryParams,
|
||||
useUpdateQueryParams,
|
||||
} from '@app/hooks/useUpdateQueryParams';
|
||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||
|
||||
const messages = defineMessages({
|
||||
filters: 'Filters',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
releaseDate: 'Release Date',
|
||||
firstAirDate: 'First Air Date',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
studio: 'Studio',
|
||||
genres: 'Genres',
|
||||
keywords: 'Keywords',
|
||||
originalLanguage: 'Original Language',
|
||||
runtimeText: '{minValue}-{maxValue} minute runtime',
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
type: 'movie' | 'tv';
|
||||
currentFilters: FilterOptions;
|
||||
};
|
||||
|
||||
const FilterSlideover = ({
|
||||
show,
|
||||
onClose,
|
||||
type,
|
||||
currentFilters,
|
||||
}: FilterSlideoverProps) => {
|
||||
const intl = useIntl();
|
||||
const { currentSettings } = useSettings();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||
|
||||
const dateGte =
|
||||
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||
const dateLte =
|
||||
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
show={show}
|
||||
title={intl.formatMessage(messages.filters)}
|
||||
subText={intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(currentFilters),
|
||||
})}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-semibold">
|
||||
{intl.formatMessage(
|
||||
type === 'movie' ? messages.releaseDate : messages.firstAirDate
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-40 flex space-x-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateGte] ?? null,
|
||||
endDate: currentFilters[dateGte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateGte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="fromdate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateLte] ?? null,
|
||||
endDate: currentFilters[dateLte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateLte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="todate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4 text-base leading-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'movie' && (
|
||||
<>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.studio)}
|
||||
</span>
|
||||
<CompanySelector
|
||||
defaultValue={currentFilters.studio}
|
||||
onChange={(value) => {
|
||||
updateQueryParams('studio', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</span>
|
||||
<GenreSelector
|
||||
type={type}
|
||||
defaultValue={currentFilters.genre}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<KeywordSelector
|
||||
defaultValue={currentFilters.keywords}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.originalLanguage)}
|
||||
</span>
|
||||
<LanguageSelector
|
||||
value={currentFilters.language}
|
||||
serverValue={currentSettings.originalLanguage}
|
||||
isUserSettings
|
||||
setFieldValue={(_key, value) => {
|
||||
updateQueryParams('language', value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.runtime)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={0}
|
||||
max={400}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeGte',
|
||||
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeLte',
|
||||
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
defaultMaxValue={
|
||||
currentFilters.withRuntimeLte
|
||||
? Number(currentFilters.withRuntimeLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.withRuntimeGte
|
||||
? Number(currentFilters.withRuntimeGte)
|
||||
: undefined
|
||||
}
|
||||
subText={intl.formatMessage(messages.runtimeText, {
|
||||
minValue: currentFilters.withRuntimeGte ?? 0,
|
||||
maxValue: currentFilters.withRuntimeLte ?? 400,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.tmdbuserscore)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={1}
|
||||
max={10}
|
||||
defaultMaxValue={
|
||||
currentFilters.voteAverageLte
|
||||
? Number(currentFilters.voteAverageLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.voteAverageGte
|
||||
? Number(currentFilters.voteAverageGte)
|
||||
: undefined
|
||||
}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'voteAverageGte',
|
||||
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'voteAverageLte',
|
||||
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
subText={intl.formatMessage(messages.ratingText, {
|
||||
minValue: currentFilters.voteAverageGte ?? 1,
|
||||
maxValue: currentFilters.voteAverageLte ?? 10,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
<WatchProviderSelector
|
||||
type={type}
|
||||
region={currentFilters.watchRegion}
|
||||
activeProviders={
|
||||
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
|
||||
[]
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
if (providers.length) {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: region,
|
||||
watchProviders: providers.join('|'),
|
||||
});
|
||||
} else {
|
||||
batchUpdateQueryParams({
|
||||
watchRegion: undefined,
|
||||
watchProviders: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={Object.keys(currentFilters).length === 0}
|
||||
onClick={() => {
|
||||
const copyCurrent = Object.assign({}, currentFilters);
|
||||
(
|
||||
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
|
||||
).forEach((k) => {
|
||||
copyCurrent[k] = undefined;
|
||||
});
|
||||
batchUpdateQueryParams(copyCurrent);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<span>{intl.formatMessage(messages.clearfilters)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterSlideover;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { genreColorMap } from '@app/components/Discover/constants';
|
||||
import GenreCard from '@app/components/GenreCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
|
||||
<Link href="/discover/movies/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
<ArrowRightCircleIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
url={`/discover/movies?genre=${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
|
||||
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal file
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { UserType, 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';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
emptywatchlist:
|
||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||
});
|
||||
|
||||
const PlexWatchlistSlider = () => {
|
||||
const intl = useIntl();
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: watchlistItems, error: watchlistError } = useSWR<{
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
if (
|
||||
user?.userType !== UserType.PLEX ||
|
||||
(watchlistItems &&
|
||||
watchlistItems.results.length === 0 &&
|
||||
!user?.settings?.watchlistSyncMovies &&
|
||||
!user?.settings?.watchlistSyncTv) ||
|
||||
watchlistError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/watchlist">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||
<ArrowRightCircleIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="watchlist"
|
||||
isLoading={!watchlistItems}
|
||||
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
|
||||
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
|
||||
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
|
||||
<a
|
||||
href="https://support.plex.tv/articles/universal-watchlist/"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
items={watchlistItems?.results.map((item) => (
|
||||
<TmdbTitleCard
|
||||
id={item.tmdbId}
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexWatchlistSlider;
|
||||
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal file
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import RequestCard from '@app/components/RequestCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const RecentRequestsSlider = () => {
|
||||
const intl = useIntl();
|
||||
const { data: requests, error: requestError } =
|
||||
useSWR<RequestResultsResponse>(
|
||||
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
||||
{
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (requests && requests.results.length === 0 && !requestError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/requests?filter=all">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
|
||||
<ArrowRightCircleIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
isLoading={!requests}
|
||||
items={(requests?.results ?? []).map((request) => (
|
||||
<RequestCard
|
||||
key={`request-slider-item-${request.id}`}
|
||||
request={request}
|
||||
/>
|
||||
))}
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentRequestsSlider;
|
||||
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal file
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
recentlyAdded: 'Recently Added',
|
||||
});
|
||||
|
||||
const RecentlyAddedSlider = () => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
||||
{ revalidateOnMount: true }
|
||||
);
|
||||
|
||||
if (
|
||||
(media && !media.results.length && !mediaError) ||
|
||||
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
|
||||
type: 'or',
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="media"
|
||||
isLoading={!media}
|
||||
items={(media?.results ?? []).map((item) => (
|
||||
<TmdbTitleCard
|
||||
key={`media-slider-item-${item.id}`}
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentlyAddedSlider;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { genreColorMap } from '@app/components/Discover/constants';
|
||||
import GenreCard from '@app/components/GenreCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
@@ -28,7 +28,7 @@ const TvGenreSlider = () => {
|
||||
<Link href="/discover/tv/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
<ArrowRightCircleIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
url={`/discover/tv?genre=${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { ParsedUrlQuery } from 'querystring';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { z } from 'zod';
|
||||
|
||||
type AvailableColors =
|
||||
| 'black'
|
||||
| 'red'
|
||||
@@ -61,3 +65,142 @@ export const genreColorMap: Record<number, [string, string]> = {
|
||||
10767: colorTones.lightgreen, // Talk
|
||||
10768: colorTones.darkred, // War & Politics
|
||||
};
|
||||
|
||||
export const sliderTitles = defineMessages({
|
||||
recentrequests: 'Recent Requests',
|
||||
popularmovies: 'Popular Movies',
|
||||
populartv: 'Popular Series',
|
||||
upcomingtv: 'Upcoming Series',
|
||||
recentlyAdded: 'Recently Added',
|
||||
upcoming: 'Upcoming Movies',
|
||||
trending: 'Trending',
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
moviegenres: 'Movie Genres',
|
||||
tvgenres: 'Series Genres',
|
||||
studios: 'Studios',
|
||||
networks: 'Networks',
|
||||
tmdbmoviekeyword: 'TMDB Movie Keyword',
|
||||
tmdbtvkeyword: 'TMDB Series Keyword',
|
||||
tmdbmoviegenre: 'TMDB Movie Genre',
|
||||
tmdbtvgenre: 'TMDB Series Genre',
|
||||
tmdbnetwork: 'TMDB Network',
|
||||
tmdbstudio: 'TMDB Studio',
|
||||
tmdbsearch: 'TMDB Search',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
sortBy: z.string().optional(),
|
||||
primaryReleaseDateGte: z.string().optional(),
|
||||
primaryReleaseDateLte: z.string().optional(),
|
||||
firstAirDateGte: z.string().optional(),
|
||||
firstAirDateLte: z.string().optional(),
|
||||
studio: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
withRuntimeGte: z.string().optional(),
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
|
||||
export const prepareFilterValues = (
|
||||
inputValues: ParsedUrlQuery
|
||||
): FilterOptions => {
|
||||
const filterValues: FilterOptions = {};
|
||||
|
||||
const values = QueryFilterOptions.parse(inputValues);
|
||||
|
||||
if (values.sortBy) {
|
||||
filterValues.sortBy = values.sortBy;
|
||||
}
|
||||
|
||||
if (values.primaryReleaseDateGte) {
|
||||
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
|
||||
}
|
||||
|
||||
if (values.primaryReleaseDateLte) {
|
||||
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
|
||||
}
|
||||
|
||||
if (values.firstAirDateGte) {
|
||||
filterValues.firstAirDateGte = values.firstAirDateGte;
|
||||
}
|
||||
|
||||
if (values.firstAirDateLte) {
|
||||
filterValues.firstAirDateLte = values.firstAirDateLte;
|
||||
}
|
||||
|
||||
if (values.studio) {
|
||||
filterValues.studio = values.studio;
|
||||
}
|
||||
|
||||
if (values.genre) {
|
||||
filterValues.genre = values.genre;
|
||||
}
|
||||
|
||||
if (values.keywords) {
|
||||
filterValues.keywords = values.keywords;
|
||||
}
|
||||
|
||||
if (values.language) {
|
||||
filterValues.language = values.language;
|
||||
}
|
||||
|
||||
if (values.withRuntimeGte) {
|
||||
filterValues.withRuntimeGte = values.withRuntimeGte;
|
||||
}
|
||||
|
||||
if (values.withRuntimeLte) {
|
||||
filterValues.withRuntimeLte = values.withRuntimeLte;
|
||||
}
|
||||
|
||||
if (values.voteAverageGte) {
|
||||
filterValues.voteAverageGte = values.voteAverageGte;
|
||||
}
|
||||
|
||||
if (values.voteAverageLte) {
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
if (values.watchProviders) {
|
||||
filterValues.watchProviders = values.watchProviders;
|
||||
}
|
||||
|
||||
if (values.watchRegion) {
|
||||
filterValues.watchRegion = values.watchRegion;
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
let totalCount = 0;
|
||||
const clonedFilters = Object.assign({}, filterValues);
|
||||
|
||||
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.voteAverageGte;
|
||||
delete clonedFilters.voteAverageLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.withRuntimeGte;
|
||||
delete clonedFilters.withRuntimeLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.watchProviders) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.watchProviders;
|
||||
delete clonedFilters.watchRegion;
|
||||
}
|
||||
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
@@ -1,189 +1,430 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import CreateSlider from '@app/components/Discover/CreateSlider';
|
||||
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
|
||||
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
|
||||
import NetworkSlider from '@app/components/Discover/NetworkSlider';
|
||||
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
|
||||
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
|
||||
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
|
||||
import StudioSlider from '@app/components/Discover/StudioSlider';
|
||||
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
|
||||
import MediaSlider from '@app/components/MediaSlider';
|
||||
import RequestCard from '@app/components/RequestCard';
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
|
||||
import Link from 'next/link';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
ArrowDownOnSquareIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
discover: 'Discover',
|
||||
recentrequests: 'Recent Requests',
|
||||
popularmovies: 'Popular Movies',
|
||||
populartv: 'Popular Series',
|
||||
upcomingtv: 'Upcoming Series',
|
||||
recentlyAdded: 'Recently Added',
|
||||
upcoming: 'Upcoming Movies',
|
||||
trending: 'Trending',
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
emptywatchlist:
|
||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||
resettodefault: 'Reset to Default',
|
||||
resetwarning:
|
||||
'Reset all sliders to default. This will also delete any custom sliders!',
|
||||
updatesuccess: 'Updated discover customization settings.',
|
||||
updatefailed:
|
||||
'Something went wrong updating the discover customization settings.',
|
||||
resetsuccess: 'Sucessfully reset discover customization settings.',
|
||||
resetfailed:
|
||||
'Something went wrong resetting the discover customization settings.',
|
||||
customizediscover: 'Customize Discover',
|
||||
stopediting: 'Stop Editing',
|
||||
createnewslider: 'Create New Slider',
|
||||
});
|
||||
|
||||
const Discover = () => {
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { hasPermission } = useUser();
|
||||
const { addToast } = useToasts();
|
||||
const {
|
||||
data: discoverData,
|
||||
error: discoverError,
|
||||
mutate,
|
||||
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
|
||||
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
||||
{ revalidateOnMount: true }
|
||||
);
|
||||
// We need to sync the state here so that we can modify the changes locally without commiting
|
||||
// anything to the server until the user decides to save the changes
|
||||
useEffect(() => {
|
||||
if (discoverData && !isEditing) {
|
||||
setSliders(discoverData);
|
||||
}
|
||||
}, [discoverData, isEditing]);
|
||||
|
||||
const { data: requests, error: requestError } =
|
||||
useSWR<RequestResultsResponse>(
|
||||
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
||||
{
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
const hasChanged = () => !Object.is(discoverData, sliders);
|
||||
|
||||
const { data: watchlistItems, error: watchlistError } = useSWR<{
|
||||
page: number;
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
const updateSliders = async () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/discover', sliders);
|
||||
|
||||
addToast(intl.formatMessage(messages.updatesuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
setIsEditing(false);
|
||||
mutate();
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.updatefailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetSliders = async () => {
|
||||
try {
|
||||
await axios.get('/api/v1/settings/discover/reset');
|
||||
|
||||
addToast(intl.formatMessage(messages.resetsuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
setIsEditing(false);
|
||||
mutate();
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.resetfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
if (!discoverData && !discoverError) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||
{(!media || !!media.results.length) &&
|
||||
!mediaError &&
|
||||
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
|
||||
type: 'or',
|
||||
}) && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||
{hasPermission(Permission.ADMIN) && (
|
||||
<>
|
||||
{isEditing && (
|
||||
<div className="my-6 rounded-lg bg-gray-800">
|
||||
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
|
||||
<PlusIcon className="w-6" />
|
||||
<span data-testid="create-slider-header">
|
||||
{intl.formatMessage(messages.createnewslider)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<CreateSlider
|
||||
onCreate={async () => {
|
||||
const newSliders = await mutate();
|
||||
|
||||
if (newSliders) {
|
||||
setSliders(newSliders);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="media"
|
||||
isLoading={!media}
|
||||
items={(media?.results ?? []).map((item) => (
|
||||
<TmdbTitleCard
|
||||
key={`media-slider-item-${item.id}`}
|
||||
id={item.id}
|
||||
tmdbId={item.tmdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(!requests || !!requests.results.length) && !requestError && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/requests?filter=all">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
isLoading={!requests}
|
||||
items={(requests?.results ?? []).map((request) => (
|
||||
<RequestCard
|
||||
key={`request-slider-item-${request.id}`}
|
||||
request={request}
|
||||
/>
|
||||
))}
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
/>
|
||||
)}
|
||||
<Transition
|
||||
show={!isEditing}
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="discover-start-editing"
|
||||
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
|
||||
>
|
||||
<PencilIcon className="h-full w-full" />
|
||||
</button>
|
||||
</Transition>
|
||||
<Transition
|
||||
show={isEditing}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 translate-y-6"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
buttonType="default"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
<span>{intl.formatMessage(messages.stopediting)}</span>
|
||||
</Button>
|
||||
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
|
||||
<ConfirmButton
|
||||
onClick={() => resetSliders()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
<span>{intl.formatMessage(messages.resettodefault)}</span>
|
||||
</ConfirmButton>
|
||||
</Tooltip>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={!hasChanged()}
|
||||
onClick={() => updateSliders()}
|
||||
data-testid="discover-customize-submit"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<ArrowDownOnSquareIcon />
|
||||
<span>{intl.formatMessage(globalMessages.save)}</span>
|
||||
</Button>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
{user?.userType === UserType.PLEX &&
|
||||
(!watchlistItems ||
|
||||
!!watchlistItems.results.length ||
|
||||
user.settings?.watchlistSyncMovies ||
|
||||
user.settings?.watchlistSyncTv) &&
|
||||
!watchlistError && (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/watchlist">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="watchlist"
|
||||
isLoading={!watchlistItems}
|
||||
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
|
||||
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
|
||||
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
|
||||
<a
|
||||
href="https://support.plex.tv/articles/universal-watchlist/"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
items={watchlistItems?.results.map((item) => (
|
||||
<TmdbTitleCard
|
||||
id={item.tmdbId}
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MediaSlider
|
||||
sliderKey="trending"
|
||||
title={intl.formatMessage(messages.trending)}
|
||||
url="/api/v1/discover/trending"
|
||||
linkUrl="/discover/trending"
|
||||
/>
|
||||
<MediaSlider
|
||||
sliderKey="popular-movies"
|
||||
title={intl.formatMessage(messages.popularmovies)}
|
||||
url="/api/v1/discover/movies"
|
||||
linkUrl="/discover/movies"
|
||||
/>
|
||||
<MovieGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(messages.upcoming)}
|
||||
linkUrl="/discover/movies/upcoming"
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
/>
|
||||
<StudioSlider />
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(messages.populartv)}
|
||||
url="/api/v1/discover/tv"
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
<TvGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
title={intl.formatMessage(messages.upcomingtv)}
|
||||
url="/api/v1/discover/tv/upcoming"
|
||||
linkUrl="/discover/tv/upcoming"
|
||||
/>
|
||||
<NetworkSlider />
|
||||
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
|
||||
let sliderComponent: React.ReactNode;
|
||||
|
||||
switch (slider.type) {
|
||||
case DiscoverSliderType.RECENTLY_ADDED:
|
||||
sliderComponent = <RecentlyAddedSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
sliderComponent = <RecentRequestsSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||
sliderComponent = <PlexWatchlistSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.TRENDING:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="trending"
|
||||
title={intl.formatMessage(sliderTitles.trending)}
|
||||
url="/api/v1/discover/trending"
|
||||
linkUrl="/discover/trending"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="popular-movies"
|
||||
title={intl.formatMessage(sliderTitles.popularmovies)}
|
||||
url="/api/v1/discover/movies"
|
||||
linkUrl="/discover/movies"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.MOVIE_GENRES:
|
||||
sliderComponent = <MovieGenreSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(sliderTitles.upcoming)}
|
||||
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
sliderComponent = <StudioSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(sliderTitles.populartv)}
|
||||
url="/api/v1/discover/tv"
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TV_GENRES:
|
||||
sliderComponent = <TvGenreSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
title={intl.formatMessage(sliderTitles.upcomingtv)}
|
||||
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={`firstAirDateGte=${upcomingDate}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
sliderComponent = <NetworkSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={
|
||||
slider.data
|
||||
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||
: ''
|
||||
}
|
||||
linkUrl={`/discover/movies?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={
|
||||
slider.data
|
||||
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||
: ''
|
||||
}
|
||||
linkUrl={`/discover/tv?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/movies`}
|
||||
extraParams={`genre=${slider.data}`}
|
||||
linkUrl={`/discover/movies?genre=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/tv`}
|
||||
extraParams={`genre=${slider.data}`}
|
||||
linkUrl={`/discover/tv?genre=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/movies/studio/${slider.data}`}
|
||||
linkUrl={`/discover/movies/studio/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_NETWORK:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/tv/network/${slider.data}`}
|
||||
linkUrl={`/discover/tv/network/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/search"
|
||||
extraParams={`query=${slider.data}`}
|
||||
linkUrl={`/search?query=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<DiscoverSliderEdit
|
||||
key={`discover-slider-${slider.id}-edit`}
|
||||
slider={slider}
|
||||
onDelete={async () => {
|
||||
const newSliders = await mutate();
|
||||
|
||||
if (newSliders) {
|
||||
setSliders(newSliders);
|
||||
}
|
||||
}}
|
||||
onEnable={() => {
|
||||
const tempSliders = sliders.slice();
|
||||
tempSliders[index].enabled = !tempSliders[index].enabled;
|
||||
setSliders(tempSliders);
|
||||
}}
|
||||
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
|
||||
const originalPosition = sliders.findIndex(
|
||||
(item) => item.id === updatedItemId
|
||||
);
|
||||
const originalItem = sliders[originalPosition];
|
||||
|
||||
const tempSliders = sliders.slice();
|
||||
|
||||
tempSliders.splice(originalPosition, 1);
|
||||
hasClickedArrows
|
||||
? tempSliders.splice(
|
||||
position === 'Above' ? index - 1 : index + 1,
|
||||
0,
|
||||
originalItem
|
||||
)
|
||||
: tempSliders.splice(
|
||||
position === 'Above' && index > originalPosition
|
||||
? Math.max(index - 1, 0)
|
||||
: index,
|
||||
0,
|
||||
originalItem
|
||||
);
|
||||
|
||||
setSliders(tempSliders);
|
||||
}}
|
||||
disableUpButton={index === 0}
|
||||
disableDownButton={index === sliders.length - 1}
|
||||
>
|
||||
{sliderComponent}
|
||||
</DiscoverSliderEdit>
|
||||
);
|
||||
}
|
||||
|
||||
if (!slider.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
estimatedtime: 'Estimated {time}',
|
||||
formattedTitle: '{title}: Season {seasonNumber} Episode {episodeNumber}',
|
||||
});
|
||||
|
||||
interface DownloadBlockProps {
|
||||
downloadItem: DownloadingItem;
|
||||
is4k?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => {
|
||||
const DownloadBlock = ({
|
||||
downloadItem,
|
||||
is4k = false,
|
||||
title,
|
||||
}: DownloadBlockProps) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
|
||||
{downloadItem.title}
|
||||
{hasPermission(Permission.ADMIN)
|
||||
? downloadItem.title
|
||||
: downloadItem.episode
|
||||
? intl.formatMessage(messages.formattedTitle, {
|
||||
title,
|
||||
seasonNumber: downloadItem?.episode?.seasonNumber,
|
||||
episodeNumber: downloadItem?.episode?.episodeNumber,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
|
||||
<div
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user