Compare commits
72 Commits
v1.32.1
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3369adfb9a | ||
|
|
76f57550fa | ||
|
|
7bdd25e5a4 | ||
|
|
f6286359cf | ||
|
|
92507359b4 | ||
|
|
c484810f96 | ||
|
|
0c39057ca5 | ||
|
|
e62a078298 | ||
|
|
317110855e | ||
|
|
f7b4dfcac4 | ||
|
|
a686d31e4d | ||
|
|
cb63bf217b | ||
|
|
46e21c4e3e | ||
|
|
b4191f9c65 | ||
|
|
83b008c839 | ||
|
|
68c7b3650e | ||
|
|
2816c66300 | ||
|
|
01de972a8f | ||
|
|
a761b7dd35 | ||
|
|
d0836ce0ef | ||
|
|
2c3f533076 | ||
|
|
c1a47bd9de | ||
|
|
24d94ef6fd | ||
|
|
04fbd00d4a | ||
|
|
21231186d1 | ||
|
|
48f76662d5 | ||
|
|
df332cec84 | ||
|
|
d7fa35e066 | ||
|
|
f33eb862fd | ||
|
|
0a007ca805 | ||
|
|
24f268b6cb | ||
|
|
b8e3c07c47 | ||
|
|
aa84977680 | ||
|
|
e051b1dfea | ||
|
|
c27f96096a | ||
|
|
4bd87647d0 | ||
|
|
c1e10338c1 | ||
|
|
cd1cacad55 | ||
|
|
ac77b037d5 | ||
|
|
10eb69a7dc | ||
|
|
70b1540ae2 | ||
|
|
7522aa3174 | ||
|
|
77a33cb74d | ||
|
|
a4d07f5afa | ||
|
|
f5191aded6 | ||
|
|
2520d8f739 | ||
|
|
04980f93ab | ||
|
|
2a3213d706 | ||
|
|
c36a4ba2b8 | ||
|
|
ae3818304b | ||
|
|
b3882de893 | ||
|
|
af880a6c83 | ||
|
|
eb5502a16f | ||
|
|
50f06dabbf | ||
|
|
1e2c6f46ab | ||
|
|
dd1378cef5 | ||
|
|
e684456bba | ||
|
|
6bd3f015d6 | ||
|
|
2d97be0d6c | ||
|
|
966639df43 | ||
|
|
33e7691b94 | ||
|
|
d7b83d22ce | ||
|
|
b6eac0f364 | ||
|
|
c93467b3ac | ||
|
|
d954328911 | ||
|
|
3e43586acc | ||
|
|
7040da1334 | ||
|
|
9d10e6a88c | ||
|
|
8942eb8b7c | ||
|
|
51b05cd8fb | ||
|
|
374c78c989 | ||
|
|
507693881b |
@@ -773,6 +773,132 @@
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Alexays",
|
||||
"name": "Alex",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
|
||||
"profile": "https://arouillard.fr",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Zebebles",
|
||||
"name": "Zeb Muller",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
|
||||
"profile": "https://github.com/Zebebles",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "SMores",
|
||||
"name": "Shane Friedman",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
|
||||
"profile": "http://smoores.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "IzaacJ",
|
||||
"name": "Izaac Brånn",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
|
||||
"profile": "https://izaacj.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "SalmanTariq",
|
||||
"name": "Salman Tariq",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
|
||||
"profile": "https://github.com/SalmanTariq",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "andrew-kennedy",
|
||||
"name": "Andrew Kennedy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
|
||||
"profile": "https://github.com/andrew-kennedy",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Fallenbagel",
|
||||
"name": "Fallenbagel",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
|
||||
"profile": "https://github.com/Fallenbagel",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "scorp200",
|
||||
"name": "Anton K. (ai Doge)",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
|
||||
"profile": "http://aidoge.xyz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "marcofaggian",
|
||||
"name": "Marco Faggian",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
|
||||
"profile": "https://marcofaggian.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "nemchik",
|
||||
"name": "Eric Nemchik",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
|
||||
"profile": "http://nemchik.com/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
@@ -782,5 +908,6 @@
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": false,
|
||||
"commitConvention": "angular"
|
||||
"commitConvention": "angular",
|
||||
"commitType": "docs"
|
||||
}
|
||||
|
||||
10
.github/CODEOWNERS
vendored
10
.github/CODEOWNERS
vendored
@@ -1,10 +1,10 @@
|
||||
# Global code ownership
|
||||
* @sct @TheCatLady @danshilm
|
||||
* @sct @TheCatLady @danshilm @OwsleyJr
|
||||
|
||||
# Documentation
|
||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
|
||||
/*.md @TheCatLady @samwiseg0 @danshilm
|
||||
/docs/ @TheCatLady @samwiseg0 @danshilm
|
||||
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
|
||||
|
||||
# Snap-related files
|
||||
/.github/workflows/snap.yaml @samwiseg0
|
||||
@@ -12,4 +12,4 @@
|
||||
|
||||
# i18n locale files
|
||||
/src/i18n/locale/ @sct @TheCatLady
|
||||
/src/i18n/locale/en.json @sct @TheCatLady @danshilm
|
||||
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -39,13 +39,6 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -71,15 +64,6 @@ jobs:
|
||||
sctx/overseerr:${{ github.sha }}
|
||||
ghcr.io/sct/overseerr:develop
|
||||
ghcr.io/sct/overseerr:${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
@@ -94,9 +78,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
|
||||
|
||||
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
|
||||
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -9,8 +9,6 @@ jobs:
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
runs-on: ubuntu-20.04
|
||||
env:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -69,9 +67,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@v2
|
||||
@@ -93,8 +91,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 }}
|
||||
|
||||
@@ -111,9 +110,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
|
||||
|
||||
11
.github/workflows/snap.yaml
vendored
11
.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
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
1435
CHANGELOG.md
1435
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
20
README.md
20
README.md
@@ -11,7 +11,7 @@
|
||||
<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-84-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-98-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -182,6 +182,24 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
|
||||
url: '/api/v1/*',
|
||||
}).as('apiCall');
|
||||
|
||||
cy.get('.searchbar').swipe('bottom', [190, 400]);
|
||||
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||
|
||||
cy.wait('@apiCall').then((interception) => {
|
||||
assert.isNotNull(
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Discover Customization', () => {
|
||||
.should('be.disabled');
|
||||
|
||||
cy.get('#data').clear();
|
||||
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
||||
cy.get('#data').type('christmas{enter}', { delay: 100 });
|
||||
|
||||
// Confirming we have some results
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
|
||||
@@ -19,5 +19,6 @@ module.exports = {
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
largePageDataBytes: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3615,7 +3615,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:
|
||||
@@ -3711,7 +3711,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:
|
||||
@@ -4186,6 +4186,16 @@ paths:
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: voteCountGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteCountLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
@@ -4418,7 +4428,7 @@ paths:
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
type: string
|
||||
example: 18
|
||||
- in: query
|
||||
name: network
|
||||
@@ -4465,6 +4475,16 @@ paths:
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: voteCountGte
|
||||
schema:
|
||||
type: number
|
||||
example: 7
|
||||
- in: query
|
||||
name: voteCountLte
|
||||
schema:
|
||||
type: number
|
||||
example: 10
|
||||
- in: query
|
||||
name: watchRegion
|
||||
schema:
|
||||
@@ -5318,6 +5338,63 @@ paths:
|
||||
audienceRating:
|
||||
type: string
|
||||
enum: ['Spilled', 'Upright']
|
||||
/movie/{movieId}/ratingscombined:
|
||||
get:
|
||||
summary: Get RT and IMDB movie ratings combined
|
||||
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
|
||||
tags:
|
||||
- movies
|
||||
parameters:
|
||||
- in: path
|
||||
name: movieId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 337401
|
||||
responses:
|
||||
'200':
|
||||
description: Ratings returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
rt:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: Mulan
|
||||
year:
|
||||
type: number
|
||||
example: 2020
|
||||
url:
|
||||
type: string
|
||||
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
|
||||
criticsScore:
|
||||
type: number
|
||||
example: 85
|
||||
criticsRating:
|
||||
type: string
|
||||
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||
audienceScore:
|
||||
type: number
|
||||
example: 65
|
||||
audienceRating:
|
||||
type: string
|
||||
enum: ['Spilled', 'Upright']
|
||||
imdb:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: I am Legend
|
||||
url:
|
||||
type: string
|
||||
example: 'https://www.imdb.com/title/tt0480249'
|
||||
criticsScore:
|
||||
type: number
|
||||
example: 6.5
|
||||
/tv/{tvId}:
|
||||
get:
|
||||
summary: Get TV details
|
||||
|
||||
97
package.json
97
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "1.32.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,17 +29,17 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-displaynames": "6.2.3",
|
||||
"@formatjs/intl-locale": "3.0.11",
|
||||
"@formatjs/intl-pluralrules": "5.1.8",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "1.7.7",
|
||||
"@heroicons/react": "2.0.13",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.22",
|
||||
"ace-builds": "1.14.0",
|
||||
"axios": "1.2.2",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.3.4",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"cookie-parser": "1.4.6",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.21.0",
|
||||
"cronstrue": "2.23.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
@@ -63,23 +63,22 @@
|
||||
"next": "12.3.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.0",
|
||||
"nodemailer": "6.8.0",
|
||||
"openpgp": "5.5.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"openpgp": "5.7.0",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.2",
|
||||
"pulltorefreshjs": "0.1.22",
|
||||
"react": "18.2.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.22.0",
|
||||
"react-aria": "3.23.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-intersection-observer": "9.4.1",
|
||||
"react-intl": "6.2.5",
|
||||
"react-markdown": "8.0.4",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "6.2.10",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-spring": "9.6.1",
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
@@ -88,42 +87,41 @@
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.8",
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.0",
|
||||
"swr": "2.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.0.4",
|
||||
"typeorm": "0.3.12",
|
||||
"web-push": "3.5.0",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.20.2"
|
||||
"zod": "3.20.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.20.7",
|
||||
"@commitlint/cli": "17.4.0",
|
||||
"@commitlint/config-conventional": "17.4.0",
|
||||
"@babel/cli": "7.21.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@semantic-release/changelog": "6.0.2",
|
||||
"@semantic-release/commit-analyzer": "9.0.2",
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.8",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/pulltorefreshjs": "0.1.5",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.13",
|
||||
@@ -132,45 +130,46 @@
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.48.0",
|
||||
"@typescript-eslint/parser": "5.48.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"babel-plugin-react-intl-auto": "3.3.0",
|
||||
"commitizen": "4.2.6",
|
||||
"commitizen": "4.3.0",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "12.3.0",
|
||||
"cypress": "12.7.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "12.3.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.3.9",
|
||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.31.11",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"extract-react-intl-messages": "4.1.1",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.0",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "2.0.20",
|
||||
"postcss": "8.4.20",
|
||||
"prettier": "2.8.1",
|
||||
"prettier-plugin-organize-imports": "3.2.1",
|
||||
"prettier-plugin-tailwindcss": "0.2.1",
|
||||
"postcss": "8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.2.4",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "4.9.4"
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10"
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/express-session": "1.17.6"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -82,21 +82,6 @@ interface ServerResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface FriendResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
$: {
|
||||
id: string;
|
||||
title: string;
|
||||
username: string;
|
||||
email: string;
|
||||
thumb: string;
|
||||
};
|
||||
Server?: ServerResponse[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
@@ -234,19 +219,6 @@ class PlexTvAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async getFriends(): Promise<FriendResponse> {
|
||||
const response = await this.axios.get('/pms/friends/all', {
|
||||
transformResponse: [],
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const parsedXml = (await xml2js.parseStringPromise(
|
||||
response.data
|
||||
)) as FriendResponse;
|
||||
|
||||
return parsedXml;
|
||||
}
|
||||
|
||||
public async checkUserAccess(userId: number): Promise<boolean> {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI {
|
||||
throw new Error('Plex is not configured!');
|
||||
}
|
||||
|
||||
const friends = await this.getFriends();
|
||||
const usersResponse = await this.getUsers();
|
||||
|
||||
const users = friends.MediaContainer.User;
|
||||
const users = usersResponse.MediaContainer.User;
|
||||
|
||||
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||
|
||||
|
||||
195
server/api/rating/imdbRadarrProxy.ts
Normal file
195
server/api/rating/imdbRadarrProxy.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
|
||||
type IMDBRadarrProxyResponse = IMDBMovie[];
|
||||
|
||||
interface IMDBMovie {
|
||||
ImdbId: string;
|
||||
Overview: string;
|
||||
Title: string;
|
||||
OriginalTitle: string;
|
||||
TitleSlug: string;
|
||||
Ratings: Rating[];
|
||||
MovieRatings: MovieRatings;
|
||||
Runtime: number;
|
||||
Images: Image[];
|
||||
Genres: string[];
|
||||
Popularity: number;
|
||||
Premier: string;
|
||||
InCinema: string;
|
||||
PhysicalRelease: any;
|
||||
DigitalRelease: string;
|
||||
Year: number;
|
||||
AlternativeTitles: AlternativeTitle[];
|
||||
Translations: Translation[];
|
||||
Recommendations: Recommendation[];
|
||||
Credits: Credits;
|
||||
Studio: string;
|
||||
YoutubeTrailerId: string;
|
||||
Certifications: Certification[];
|
||||
Status: any;
|
||||
Collection: Collection;
|
||||
OriginalLanguage: string;
|
||||
Homepage: string;
|
||||
TmdbId: number;
|
||||
}
|
||||
|
||||
interface Rating {
|
||||
Count: number;
|
||||
Value: number;
|
||||
Origin: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface MovieRatings {
|
||||
Tmdb: Tmdb;
|
||||
Imdb: Imdb;
|
||||
Metacritic: Metacritic;
|
||||
RottenTomatoes: RottenTomatoes;
|
||||
}
|
||||
|
||||
interface Tmdb {
|
||||
Count: number;
|
||||
Value: number;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface Imdb {
|
||||
Count: number;
|
||||
Value: number;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface Metacritic {
|
||||
Count: number;
|
||||
Value: number;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface RottenTomatoes {
|
||||
Count: number;
|
||||
Value: number;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface Image {
|
||||
CoverType: string;
|
||||
Url: string;
|
||||
}
|
||||
|
||||
interface AlternativeTitle {
|
||||
Title: string;
|
||||
Type: string;
|
||||
Language: string;
|
||||
}
|
||||
|
||||
interface Translation {
|
||||
Title: string;
|
||||
Overview: string;
|
||||
Language: string;
|
||||
}
|
||||
|
||||
interface Recommendation {
|
||||
TmdbId: number;
|
||||
Title: string;
|
||||
}
|
||||
|
||||
interface Credits {
|
||||
Cast: Cast[];
|
||||
Crew: Crew[];
|
||||
}
|
||||
|
||||
interface Cast {
|
||||
Name: string;
|
||||
Order: number;
|
||||
Character: string;
|
||||
TmdbId: number;
|
||||
CreditId: string;
|
||||
Images: Image2[];
|
||||
}
|
||||
|
||||
interface Image2 {
|
||||
CoverType: string;
|
||||
Url: string;
|
||||
}
|
||||
|
||||
interface Crew {
|
||||
Name: string;
|
||||
Job: string;
|
||||
Department: string;
|
||||
TmdbId: number;
|
||||
CreditId: string;
|
||||
Images: Image3[];
|
||||
}
|
||||
|
||||
interface Image3 {
|
||||
CoverType: string;
|
||||
Url: string;
|
||||
}
|
||||
|
||||
interface Certification {
|
||||
Country: string;
|
||||
Certification: string;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
Name: string;
|
||||
Images: any;
|
||||
Overview: any;
|
||||
Translations: any;
|
||||
Parts: any;
|
||||
TmdbId: number;
|
||||
}
|
||||
|
||||
export interface IMDBRating {
|
||||
title: string;
|
||||
url: string;
|
||||
criticsScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a best-effort API. The IMDB API is technically
|
||||
* private and getting access costs money/requires approval.
|
||||
*
|
||||
* Radarr hosts a public proxy that's in use by all Radarr instances.
|
||||
*/
|
||||
class IMDBRadarrProxy extends ExternalAPI {
|
||||
constructor() {
|
||||
super('https://api.radarr.video/v1', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('imdb').data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Radarr IMDB Proxy for the movie
|
||||
*
|
||||
* @param IMDBid Id of IMDB movie
|
||||
*/
|
||||
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
|
||||
try {
|
||||
const data = await this.get<IMDBRadarrProxyResponse>(
|
||||
`/movie/imdb/${IMDBid}`
|
||||
);
|
||||
|
||||
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: data[0].Title,
|
||||
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default IMDBRadarrProxy;
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
@@ -17,7 +17,7 @@ interface RTAlgoliaHit {
|
||||
title: string;
|
||||
titles: string[];
|
||||
description: string;
|
||||
releaseYear: string;
|
||||
releaseYear: number;
|
||||
rating: string;
|
||||
genres: string[];
|
||||
updateDate: string;
|
||||
@@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
||||
(movie) => movie.releaseYear === year && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) =>
|
||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString()
|
||||
);
|
||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
@@ -147,6 +144,9 @@ class RottenTomatoes extends ExternalAPI {
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: movie.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
audienceScore: movie.rottenTomatoes.audienceScore,
|
||||
year: Number(movie.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -181,7 +181,7 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
if (year) {
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year.toString()
|
||||
(series) => series.releaseYear === year
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,9 @@ class RottenTomatoes extends ExternalAPI {
|
||||
criticsRating:
|
||||
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
|
||||
criticsScore: tvshow.rottenTomatoes.criticsScore,
|
||||
audienceRating:
|
||||
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
audienceScore: tvshow.rottenTomatoes.audienceScore,
|
||||
year: Number(tvshow.releaseYear),
|
||||
};
|
||||
} catch (e) {
|
||||
7
server/api/ratings.ts
Normal file
7
server/api/ratings.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
|
||||
import { type RTRating } from '@server/api/rating/rottentomatoes';
|
||||
|
||||
export interface RatingResponse {
|
||||
rt?: RTRating;
|
||||
imdb?: IMDBRating;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
interface SonarrSeason {
|
||||
export interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: {
|
||||
@@ -76,6 +76,15 @@ export interface SonarrSeries {
|
||||
ignoreEpisodesWithoutFiles?: boolean;
|
||||
searchForMissingEpisodes?: boolean;
|
||||
};
|
||||
statistics: {
|
||||
seasonCount: number;
|
||||
episodeFileCount: number;
|
||||
episodeCount: number;
|
||||
totalEpisodeCount: number;
|
||||
sizeOnDisk: number;
|
||||
releaseGroups: string[];
|
||||
percentOfEpisodes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
@@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{
|
||||
}
|
||||
}
|
||||
|
||||
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
|
||||
@@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
@@ -83,9 +85,11 @@ interface DiscoverTvOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
genre?: string;
|
||||
network?: number;
|
||||
keywords?: string;
|
||||
sortBy?: SortOptions;
|
||||
@@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
@@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
},
|
||||
@@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
@@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
|
||||
first_air_date: string;
|
||||
}
|
||||
|
||||
export interface TmdbCollectionResult {
|
||||
id: number;
|
||||
media_type: 'collection';
|
||||
title: string;
|
||||
original_title: string;
|
||||
adult: boolean;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
overview: string;
|
||||
original_language: string;
|
||||
}
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
|
||||
@@ -20,6 +20,8 @@ export enum DiscoverSliderType {
|
||||
TMDB_SEARCH,
|
||||
TMDB_STUDIO,
|
||||
TMDB_NETWORK,
|
||||
TMDB_MOVIE_STREAMING_SERVICES,
|
||||
TMDB_TV_STREAMING_SERVICES,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
|
||||
@@ -114,29 +114,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;
|
||||
|
||||
public serviceUrl?: string;
|
||||
public serviceUrl4k?: string;
|
||||
@@ -260,7 +260,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,
|
||||
@@ -270,7 +272,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,
|
||||
@@ -282,7 +286,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,
|
||||
@@ -292,7 +298,9 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.serviceId4k !== undefined
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getSeriesProgress(
|
||||
this.serviceId4k,
|
||||
|
||||
@@ -704,7 +704,7 @@ export class MediaRequest {
|
||||
|
||||
let rootFolder = radarrSettings.activeDirectory;
|
||||
let qualityProfile = radarrSettings.activeProfileId;
|
||||
let tags = radarrSettings.tags;
|
||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -764,6 +764,38 @@ export class MediaRequest {
|
||||
return;
|
||||
}
|
||||
|
||||
if (radarrSettings.tagRequests) {
|
||||
let userTag = (await radarr.getTags()).find((v) =>
|
||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
newTag:
|
||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
) {
|
||||
@@ -952,7 +984,7 @@ export class MediaRequest {
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = 'anime';
|
||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
@@ -970,7 +1002,11 @@ export class MediaRequest {
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
: sonarrSettings.tags;
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -1022,6 +1058,38 @@ export class MediaRequest {
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrSettings.tagRequests) {
|
||||
let userTag = (await sonarr.getTags()).find((v) =>
|
||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||
);
|
||||
if (!userTag) {
|
||||
logger.info(`Requester has no active tag. Creating new`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
newTag:
|
||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
if (!tags?.find((v) => v === userTag?.id)) {
|
||||
tags?.push(userTag.id);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Requester has no tag and failed to add one`, {
|
||||
label: 'Media Request',
|
||||
requestId: this.id,
|
||||
mediaId: this.media.id,
|
||||
userId: this.requestedBy.id,
|
||||
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||
profileId: qualityProfile,
|
||||
languageProfileId: languageProfile,
|
||||
@@ -1187,3 +1255,5 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaRequest;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
AfterRemove,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -34,6 +36,18 @@ class SeasonRequest {
|
||||
constructor(init?: Partial<SeasonRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
public async handleRemoveParent(): Promise<void> {
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||
where: { id: this.request.id },
|
||||
});
|
||||
|
||||
if (requestToBeDeleted.seasons.length === 0) {
|
||||
await mediaRequestRepository.delete({ id: this.request.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SeasonRequest;
|
||||
|
||||
@@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
|
||||
import WebPushAgent from '@server/lib/notifications/agents/webpush';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import clearCookies from '@server/middleware/clearcookies';
|
||||
import routes from '@server/routes';
|
||||
import imageproxy from '@server/routes/imageproxy';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
@@ -182,7 +183,8 @@ app
|
||||
});
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
server.use('/imageproxy', imageproxy);
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PublicSettingsResponse {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
movieEnabled: boolean;
|
||||
seriesEnabled: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -7,6 +8,7 @@ import type { JobId } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import watchlistSync from '@server/lib/watchlistsync';
|
||||
import logger from '@server/logger';
|
||||
import random from 'lodash/random';
|
||||
import schedule from 'node-schedule';
|
||||
|
||||
interface ScheduledJob {
|
||||
@@ -14,7 +16,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;
|
||||
@@ -30,7 +32,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, () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||
@@ -47,7 +49,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', {
|
||||
@@ -59,27 +61,37 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run watchlist sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
// Watchlist Sync
|
||||
const watchlistSyncJob: ScheduledJob = {
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'short',
|
||||
interval: 'fixed',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
};
|
||||
|
||||
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||
// after each run
|
||||
watchlistSyncJob.job.on('run', () => {
|
||||
watchlistSyncJob.job.schedule(
|
||||
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||
);
|
||||
});
|
||||
|
||||
scheduledJobs.push(watchlistSyncJob);
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['radarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
@@ -94,7 +106,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' });
|
||||
@@ -104,12 +116,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', {
|
||||
@@ -124,7 +153,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', {
|
||||
@@ -134,12 +163,12 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Run image cache cleanup every 5 minutes
|
||||
// Run image cache cleanup every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'image-cache-cleanup',
|
||||
name: 'Image Cache Cleanup',
|
||||
type: 'process',
|
||||
interval: 'long',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||
|
||||
725
server/lib/availabilitySync.ts
Normal file
725
server/lib/availabilitySync.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import type { PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
||||
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import MediaRequest from '@server/entity/MediaRequest';
|
||||
import type 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);
|
||||
|
||||
try {
|
||||
logger.info(`Starting availability sync...`, {
|
||||
label: 'AvailabilitySync',
|
||||
});
|
||||
const pageSize = 50;
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: { id: true, plexToken: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (admin) {
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
} else {
|
||||
logger.error('An admin is not configured.');
|
||||
}
|
||||
|
||||
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
||||
if (!this.running) {
|
||||
throw new Error('Job aborted');
|
||||
}
|
||||
|
||||
// Check plex, radarr, and sonarr for that specific media and
|
||||
// if unavailable, then we change the status accordingly.
|
||||
// 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') {
|
||||
let movieExists = false;
|
||||
let movieExists4k = false;
|
||||
|
||||
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
||||
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
|
||||
media,
|
||||
true
|
||||
);
|
||||
|
||||
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
||||
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
||||
|
||||
if (existsInPlex || existsInRadarr) {
|
||||
movieExists = true;
|
||||
logger.info(
|
||||
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (existsInPlex4k || existsInRadarr4k) {
|
||||
movieExists4k = true;
|
||||
logger.info(
|
||||
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
||||
await this.mediaUpdater(media, false);
|
||||
}
|
||||
|
||||
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
||||
await this.mediaUpdater(media, true);
|
||||
}
|
||||
}
|
||||
|
||||
// If both versions still exist in plex, we still need
|
||||
// to check through sonarr to verify season availability
|
||||
if (media.mediaType === 'tv') {
|
||||
let showExists = false;
|
||||
let showExists4k = false;
|
||||
|
||||
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
||||
await this.mediaExistsInPlex(media, false);
|
||||
const {
|
||||
existsInPlex: existsInPlex4k,
|
||||
seasonsMap: plexSeasonsMap4k = new Map(),
|
||||
} = await this.mediaExistsInPlex(media, true);
|
||||
|
||||
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
||||
await this.mediaExistsInSonarr(media, false);
|
||||
const {
|
||||
existsInSonarr: existsInSonarr4k,
|
||||
seasonsMap: sonarrSeasonsMap4k,
|
||||
} = await this.mediaExistsInSonarr(media, true);
|
||||
|
||||
if (existsInPlex || existsInSonarr) {
|
||||
showExists = true;
|
||||
logger.info(
|
||||
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (existsInPlex4k || existsInSonarr4k) {
|
||||
showExists4k = true;
|
||||
logger.info(
|
||||
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
{
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Here we will create a final map that will cross compare
|
||||
// with plex and sonarr. Filtered seasons will go through
|
||||
// each season and assume the season does not exist. If Plex or
|
||||
// Sonarr finds that season, we will change the final seasons value
|
||||
// to true.
|
||||
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||
)
|
||||
.forEach((season) =>
|
||||
filteredSeasonsMap.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
const finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...plexSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
|
||||
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.AVAILABLE ||
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||
)
|
||||
.forEach((season) =>
|
||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
const finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...plexSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
|
||||
if ([...finalSeasons.values()].includes(false)) {
|
||||
await this.seasonUpdater(media, finalSeasons, false);
|
||||
}
|
||||
|
||||
if ([...finalSeasons4k.values()].includes(false)) {
|
||||
await this.seasonUpdater(media, finalSeasons4k, true);
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists &&
|
||||
(media.status === MediaStatus.AVAILABLE ||
|
||||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
await this.mediaUpdater(media, false);
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists4k &&
|
||||
(media.status4k === MediaStatus.AVAILABLE ||
|
||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
await this.mediaUpdater(media, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 findMediaStatus(
|
||||
requests: MediaRequest[],
|
||||
is4k: boolean
|
||||
): MediaStatus {
|
||||
const filteredRequests = requests.filter(
|
||||
(request) => request.is4k === is4k
|
||||
);
|
||||
|
||||
let mediaStatus: MediaStatus;
|
||||
|
||||
if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.APPROVED
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PROCESSING;
|
||||
} else if (
|
||||
filteredRequests.some(
|
||||
(request) => request.status === MediaRequestStatus.PENDING
|
||||
)
|
||||
) {
|
||||
mediaStatus = MediaStatus.PENDING;
|
||||
} else {
|
||||
mediaStatus = MediaStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
return mediaStatus;
|
||||
}
|
||||
|
||||
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
try {
|
||||
// Find all related requests only if
|
||||
// the related media has an available status
|
||||
const requests = await requestRepository
|
||||
.createQueryBuilder('request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', {
|
||||
id: media.id,
|
||||
})
|
||||
.andWhere(
|
||||
`(request.is4k = :is4k AND media.${
|
||||
is4k ? 'status4k' : 'status'
|
||||
} IN (:...mediaStatus))`,
|
||||
{
|
||||
mediaStatus: [
|
||||
MediaStatus.AVAILABLE,
|
||||
MediaStatus.PARTIALLY_AVAILABLE,
|
||||
],
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
// Check if a season is processing or pending to
|
||||
// make sure we set the media to the correct status
|
||||
let mediaStatus = MediaStatus.UNKNOWN;
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
mediaStatus = this.findMediaStatus(requests, is4k);
|
||||
}
|
||||
|
||||
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||
: null;
|
||||
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||
: null;
|
||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
||||
mediaStatus === MediaStatus.PROCESSING
|
||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||
: null;
|
||||
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'movie' ? 'movie' : 'show'
|
||||
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
||||
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
||||
} and Plex instance. Status will be changed to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
// Only delete media request if type is movie.
|
||||
// Type tv request deletion is handled
|
||||
// in the season request entity
|
||||
if (requests.length > 0 && media.mediaType === 'movie') {
|
||||
await requestRepository.remove(requests);
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}].`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async seasonUpdater(
|
||||
media: Media,
|
||||
seasons: Map<number, boolean>,
|
||||
is4k: boolean
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||
|
||||
const seasonsPendingRemoval = new Map(
|
||||
// Disabled linter as only the value is needed from the filter
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[...seasons].filter(([_, exists]) => !exists)
|
||||
);
|
||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||
|
||||
try {
|
||||
// Need to check and see if there are any related season
|
||||
// requests. If they are, we will need to delete them.
|
||||
const seasonRequests = await seasonRequestRepository
|
||||
.createQueryBuilder('seasonRequest')
|
||||
.leftJoinAndSelect('seasonRequest.request', 'request')
|
||||
.leftJoinAndSelect('request.media', 'media')
|
||||
.where('(media.id = :id)', { id: media.id })
|
||||
.andWhere(
|
||||
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
||||
{
|
||||
seasonNumbers: seasonKeys,
|
||||
is4k: is4k,
|
||||
}
|
||||
)
|
||||
.getMany();
|
||||
|
||||
for (const mediaSeason of media.seasons) {
|
||||
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
logger.info(
|
||||
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
}
|
||||
|
||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
||||
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
logger.info(
|
||||
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
}
|
||||
|
||||
await mediaRepository.save({ media, ...media });
|
||||
|
||||
if (seasonRequests.length > 0) {
|
||||
await seasonRequestRepository.remove(seasonRequests);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] was not found in any ${
|
||||
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
||||
} and Plex instance. Status will be changed to unknown.`,
|
||||
{ label: 'AvailabilitySync' }
|
||||
);
|
||||
} catch (ex) {
|
||||
logger.debug(
|
||||
`Failure updating the ${
|
||||
is4k ? '4K' : 'non-4K'
|
||||
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async mediaExistsInRadarr(
|
||||
media: Media,
|
||||
is4k: boolean
|
||||
): Promise<boolean> {
|
||||
let existsInRadarr = false;
|
||||
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.radarrServers) {
|
||||
const radarrAPI = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
let radarr: RadarrMovie | undefined;
|
||||
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
radarr = await radarrAPI.getMovie({
|
||||
id: media.externalServiceId4k,
|
||||
});
|
||||
}
|
||||
|
||||
if (radarr && radarr.hasFile) {
|
||||
existsInRadarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInRadarr = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Radarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return existsInRadarr;
|
||||
}
|
||||
|
||||
private async mediaExistsInSonarr(
|
||||
media: Media,
|
||||
is4k: boolean
|
||||
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
|
||||
let existsInSonarr = false;
|
||||
let preventSeasonSearch = false;
|
||||
|
||||
// Check for availability in all of the available sonarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
for (const server of this.sonarrServers) {
|
||||
const sonarrAPI = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
let sonarr: SonarrSeries | undefined;
|
||||
|
||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
|
||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||
sonarr.seasons;
|
||||
}
|
||||
|
||||
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
||||
existsInSonarr = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInSonarr = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Sonarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Here we check each season for availability
|
||||
// If the API returns an error other than a 404,
|
||||
// we will have to prevent the season check from happening
|
||||
const seasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
if (!preventSeasonSearch) {
|
||||
const filteredSeasons = media.seasons.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||
season[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const seasonExists = await this.seasonExistsInSonarr(
|
||||
media,
|
||||
season,
|
||||
is4k
|
||||
);
|
||||
|
||||
if (seasonExists) {
|
||||
seasonsMap.set(season.seasonNumber, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { existsInSonarr, seasonsMap };
|
||||
}
|
||||
|
||||
private async seasonExistsInSonarr(
|
||||
media: Media,
|
||||
season: Season,
|
||||
is4k: boolean
|
||||
): Promise<boolean> {
|
||||
let seasonExists = false;
|
||||
|
||||
// Check each sonarr instance to see if the media still exists
|
||||
// If found, we will assume the media exists and prevent removal
|
||||
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||
for (const server of this.sonarrServers) {
|
||||
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||
|
||||
if (media.externalServiceId && !is4k) {
|
||||
sonarrSeasons =
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
|
||||
}
|
||||
|
||||
if (media.externalServiceId4k && is4k) {
|
||||
sonarrSeasons =
|
||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
|
||||
}
|
||||
|
||||
const seasonIsAvailable = sonarrSeasons?.find(
|
||||
({ seasonNumber, statistics }) =>
|
||||
season.seasonNumber === seasonNumber &&
|
||||
statistics?.episodeFileCount &&
|
||||
statistics?.episodeFileCount > 0
|
||||
);
|
||||
|
||||
if (seasonIsAvailable && sonarrSeasons) {
|
||||
seasonExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
return seasonExists;
|
||||
}
|
||||
|
||||
private async mediaExistsInPlex(
|
||||
media: Media,
|
||||
is4k: boolean
|
||||
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
let existsInPlex = false;
|
||||
let preventSeasonSearch = false;
|
||||
|
||||
// Check each plex instance to see if the media still exists
|
||||
// If found, we will assume the media exists and prevent removal
|
||||
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
||||
try {
|
||||
let plexMedia: PlexMetadata | undefined;
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
this.plexSeasonsCache[ratingKey] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingKey4k && is4k) {
|
||||
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
|
||||
|
||||
if (media.mediaType === 'tv') {
|
||||
this.plexSeasonsCache[ratingKey4k] =
|
||||
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||
}
|
||||
}
|
||||
|
||||
if (plexMedia) {
|
||||
existsInPlex = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInPlex = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
label: 'AvailabilitySync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Here we check each season in plex for availability
|
||||
// If the API returns an error other than a 404,
|
||||
// we will have to prevent the season check from happening
|
||||
if (media.mediaType === 'tv') {
|
||||
const seasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
if (!preventSeasonSearch) {
|
||||
const filteredSeasons = media.seasons.filter(
|
||||
(season) =>
|
||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
||||
season[is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const seasonExists = await this.seasonExistsInPlex(
|
||||
media,
|
||||
season,
|
||||
is4k
|
||||
);
|
||||
|
||||
if (seasonExists) {
|
||||
seasonsMap.set(season.seasonNumber, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { existsInPlex, seasonsMap };
|
||||
}
|
||||
|
||||
return { existsInPlex };
|
||||
}
|
||||
|
||||
private async seasonExistsInPlex(
|
||||
media: Media,
|
||||
season: Season,
|
||||
is4k: boolean
|
||||
): Promise<boolean> {
|
||||
const ratingKey = media.ratingKey;
|
||||
const ratingKey4k = media.ratingKey4k;
|
||||
let seasonExistsInPlex = false;
|
||||
|
||||
// Check each plex instance to see if the season exists
|
||||
let plexSeasons: PlexMetadata[] | undefined;
|
||||
|
||||
if (ratingKey && !is4k) {
|
||||
plexSeasons = this.plexSeasonsCache[ratingKey];
|
||||
}
|
||||
|
||||
if (ratingKey4k && is4k) {
|
||||
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
||||
}
|
||||
|
||||
const seasonIsAvailable = plexSeasons?.find(
|
||||
(plexSeason) => plexSeason.index === season.seasonNumber
|
||||
);
|
||||
|
||||
if (seasonIsAvailable) {
|
||||
seasonExistsInPlex = true;
|
||||
}
|
||||
|
||||
return seasonExistsInPlex;
|
||||
}
|
||||
}
|
||||
|
||||
const availabilitySync = new AvailabilitySync();
|
||||
export default availabilitySync;
|
||||
@@ -5,6 +5,7 @@ export type AvailableCacheIds =
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'imdb'
|
||||
| 'github'
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
@@ -51,6 +52,10 @@ class CacheManager {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
|
||||
stdTtl: 43200,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
github: new Cache('github', 'GitHub API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
|
||||
@@ -18,14 +18,14 @@ type ImageResponse = {
|
||||
imageBuffer: Buffer;
|
||||
};
|
||||
|
||||
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/cache/images`
|
||||
: path.join(__dirname, '../../config/cache/images');
|
||||
|
||||
class ImageProxy {
|
||||
public static async clearCache(key: string) {
|
||||
let deletedImages = 0;
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
@@ -57,11 +57,7 @@ class ImageProxy {
|
||||
public static async getImageStats(
|
||||
key: string
|
||||
): Promise<{ size: number; imageCount: number }> {
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
|
||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||
@@ -263,7 +259,7 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private getCacheDirectory() {
|
||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
||||
return path.join(baseCacheDirectory, this.key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface DVRSettings {
|
||||
externalUrl?: string;
|
||||
syncEnabled: boolean;
|
||||
preventSearch: boolean;
|
||||
tagRequests: boolean;
|
||||
}
|
||||
|
||||
export interface RadarrSettings extends DVRSettings {
|
||||
@@ -68,6 +69,8 @@ export interface RadarrSettings extends DVRSettings {
|
||||
}
|
||||
|
||||
export interface SonarrSettings extends DVRSettings {
|
||||
seriesType: 'standard' | 'daily' | 'anime';
|
||||
animeSeriesType: 'standard' | 'daily' | 'anime';
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeProfileName?: string;
|
||||
activeAnimeDirectory?: string;
|
||||
@@ -112,6 +115,8 @@ interface FullPublicSettings extends PublicSettings {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
movieEnabled: boolean;
|
||||
seriesEnabled: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
@@ -248,7 +253,8 @@ export type JobId =
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'image-cache-cleanup';
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -376,7 +382,7 @@ class Settings {
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
webpush: {
|
||||
@@ -409,6 +415,9 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'download-sync': {
|
||||
schedule: '0 * * * * *',
|
||||
},
|
||||
@@ -484,6 +493,12 @@ class Settings {
|
||||
applicationUrl: this.data.main.applicationUrl,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
localLogin: this.data.main.localLogin,
|
||||
movieEnabled: this.data.radarr.some(
|
||||
(radarr) => !radarr.is4k && radarr.isDefault
|
||||
),
|
||||
seriesEnabled: this.data.sonarr.some(
|
||||
(sonarr) => !sonarr.is4k && sonarr.isDefault
|
||||
),
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
),
|
||||
@@ -546,7 +561,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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -9,7 +10,7 @@ import type {
|
||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person';
|
||||
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
@@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
|
||||
firstAirDate: string;
|
||||
}
|
||||
|
||||
export interface CollectionResult {
|
||||
id: number;
|
||||
mediaType: 'collection';
|
||||
title: string;
|
||||
originalTitle: string;
|
||||
adult: boolean;
|
||||
posterPath?: string;
|
||||
backdropPath?: string;
|
||||
overview: string;
|
||||
originalLanguage: string;
|
||||
}
|
||||
|
||||
export interface PersonResult {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -53,7 +66,7 @@ export interface PersonResult {
|
||||
knownFor: (MovieResult | TvResult)[];
|
||||
}
|
||||
|
||||
export type Results = MovieResult | TvResult | PersonResult;
|
||||
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
||||
|
||||
export const mapMovieResult = (
|
||||
movieResult: TmdbMovieResult,
|
||||
@@ -99,6 +112,20 @@ export const mapTvResult = (
|
||||
mediaInfo: media,
|
||||
});
|
||||
|
||||
export const mapCollectionResult = (
|
||||
collectionResult: TmdbCollectionResult
|
||||
): CollectionResult => ({
|
||||
id: collectionResult.id,
|
||||
mediaType: collectionResult.media_type || 'collection',
|
||||
adult: collectionResult.adult,
|
||||
originalLanguage: collectionResult.original_language,
|
||||
originalTitle: collectionResult.original_title,
|
||||
title: collectionResult.title,
|
||||
overview: collectionResult.overview,
|
||||
backdropPath: collectionResult.backdrop_path,
|
||||
posterPath: collectionResult.poster_path,
|
||||
});
|
||||
|
||||
export const mapPersonResult = (
|
||||
personResult: TmdbPersonResult
|
||||
): PersonResult => ({
|
||||
@@ -118,7 +145,12 @@ export const mapPersonResult = (
|
||||
});
|
||||
|
||||
export const mapSearchResults = (
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[],
|
||||
media?: Media[]
|
||||
): Results[] =>
|
||||
results.map((result) => {
|
||||
@@ -139,6 +171,8 @@ export const mapSearchResults = (
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||
)
|
||||
);
|
||||
case 'collection':
|
||||
return mapCollectionResult(result);
|
||||
default:
|
||||
return mapPersonResult(result);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import {
|
||||
mapCollectionResult,
|
||||
mapMovieResult,
|
||||
mapPersonResult,
|
||||
mapTvResult,
|
||||
} from '@server/models/Search';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
@@ -64,6 +65,8 @@ const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.coerce.string().optional(),
|
||||
voteAverageGte: z.coerce.string().optional(),
|
||||
voteAverageLte: z.coerce.string().optional(),
|
||||
voteCountGte: z.coerce.string().optional(),
|
||||
voteCountLte: z.coerce.string().optional(),
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
@@ -95,6 +98,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
@@ -356,7 +361,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
page: Number(query.page),
|
||||
sortBy: query.sortBy as SortOptions,
|
||||
language: req.locale ?? query.language,
|
||||
genre: query.genre ? Number(query.genre) : undefined,
|
||||
genre: query.genre,
|
||||
network: query.network ? Number(query.network) : undefined,
|
||||
firstAirDateLte: query.firstAirDateLte
|
||||
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
|
||||
@@ -370,6 +375,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
@@ -491,7 +498,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(
|
||||
@@ -647,6 +654,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
)
|
||||
: isPerson(result)
|
||||
? mapPersonResult(result)
|
||||
: isCollection(result)
|
||||
? mapCollectionResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
@@ -770,7 +779,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,
|
||||
@@ -798,12 +809,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({
|
||||
@@ -827,8 +838,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||
totalResults: watchlist.totalSize,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -116,6 +118,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint backed by RottenTomatoes
|
||||
*/
|
||||
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const rtapi = new RottenTomatoes();
|
||||
@@ -151,4 +156,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint combining RottenTomatoes and IMDB
|
||||
*/
|
||||
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const rtapi = new RottenTomatoes();
|
||||
const imdbApi = new IMDBRadarrProxy();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: Number(req.params.id),
|
||||
});
|
||||
|
||||
const rtratings = await rtapi.getMovieRatings(
|
||||
movie.title,
|
||||
Number(movie.release_date.slice(0, 4))
|
||||
);
|
||||
|
||||
let imdbRatings;
|
||||
if (movie.imdb_id) {
|
||||
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
|
||||
}
|
||||
|
||||
if (!rtratings && !imdbRatings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'No ratings found.',
|
||||
});
|
||||
}
|
||||
|
||||
const ratings: RatingResponse = {
|
||||
...(rtratings ? { rt: rtratings } : {}),
|
||||
...(imdbRatings ? { imdb: imdbRatings } : {}),
|
||||
};
|
||||
|
||||
return res.status(200).json(ratings);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie ratings', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
movieId: req.params.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve movie ratings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default movieRoutes;
|
||||
|
||||
@@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
|
||||
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -254,25 +254,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
||||
|
||||
Object.assign(settings.tautulli, req.body);
|
||||
|
||||
try {
|
||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||
if (settings.tautulli.hostname) {
|
||||
try {
|
||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||
|
||||
const result = await tautulliClient.getInfo();
|
||||
const result = await tautulliClient.getInfo();
|
||||
|
||||
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
||||
throw new Error('Tautulli version not supported');
|
||||
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
||||
throw new Error('Tautulli version not supported');
|
||||
}
|
||||
|
||||
settings.save();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong testing Tautulli connection', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to connect to Tautulli.',
|
||||
});
|
||||
}
|
||||
|
||||
settings.save();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong testing Tautulli connection', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to connect to Tautulli.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(settings.tautulli);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
|
||||
@@ -381,7 +381,14 @@ router.delete<{ id: string }>(
|
||||
* we manually remove all requests from the user here so the parent media's
|
||||
* properly reflect the change.
|
||||
*/
|
||||
await requestRepository.remove(user.requests);
|
||||
await requestRepository.remove(user.requests, {
|
||||
/**
|
||||
* Break-up into groups of 1000 requests to be removed at a time.
|
||||
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
|
||||
* https://typeorm.io/repository-api#additional-options
|
||||
*/
|
||||
chunk: user.requests.length / 1000,
|
||||
});
|
||||
|
||||
await userRepository.delete(user.id);
|
||||
return res.status(200).json(user.filter());
|
||||
@@ -607,7 +614,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 (
|
||||
@@ -627,7 +634,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({
|
||||
@@ -651,8 +658,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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -8,17 +9,35 @@ import type {
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
|
||||
export const isMovie = (
|
||||
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
movie:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
): movie is TmdbMovieResult => {
|
||||
return (movie as TmdbMovieResult).title !== undefined;
|
||||
};
|
||||
|
||||
export const isPerson = (
|
||||
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
person:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
): person is TmdbPersonResult => {
|
||||
return (person as TmdbPersonResult).known_for !== undefined;
|
||||
};
|
||||
|
||||
export const isCollection = (
|
||||
collection:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
): collection is TmdbCollectionResult => {
|
||||
return (collection as TmdbCollectionResult).media_type === 'collection';
|
||||
};
|
||||
|
||||
export const isMovieDetails = (
|
||||
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
|
||||
): movie is TmdbMovieDetails => {
|
||||
|
||||
@@ -37,6 +37,7 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</Badge>
|
||||
{showRelative && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { Collection } from '@server/models/Collection';
|
||||
@@ -39,20 +40,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||
fallbackData: collection,
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
return [
|
||||
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||
const [downloadStatus, downloadStatus4k] = [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
@@ -60,7 +49,30 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
return { downloadStatus, downloadStatus4k };
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||
fallbackData: collection,
|
||||
revalidateOnMount: true,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
returnCollectionDownloadItems(collection),
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
const downloadItems = returnCollectionDownloadItems(data);
|
||||
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
||||
}, [data]);
|
||||
|
||||
const [titles, titles4k] = useMemo(() => {
|
||||
return [
|
||||
@@ -117,6 +129,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
}
|
||||
|
||||
const hasRequestable =
|
||||
settings.currentSettings.movieEnabled &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
@@ -225,14 +238,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
</div>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
downloadItem={downloadStatus}
|
||||
title={titles}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
{settings.currentSettings.movieEnabled &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) && (
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
downloadItem={downloadStatus}
|
||||
title={titles}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
@@ -328,6 +346,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
@@ -336,7 +355,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
<div className="extra-bottom-space relative" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ const Badge = (
|
||||
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
|
||||
);
|
||||
if (href) {
|
||||
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
|
||||
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isOpen}
|
||||
enter="transition ease-out duration-100 opacity-0"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75 opacity-100"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
CollectionResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
@@ -12,7 +13,7 @@ import type {
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
type ListViewProps = {
|
||||
items?: (TvResult | MovieResult | PersonResult)[];
|
||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||
plexItems?: WatchlistItem[];
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -60,6 +61,7 @@ const ListView = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
@@ -78,6 +80,7 @@ const ListView = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
@@ -90,6 +93,18 @@ const ListView = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'collection':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
mediaType={title.mediaType}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'person':
|
||||
titleCard = (
|
||||
<PersonCard
|
||||
|
||||
@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
appear
|
||||
as="div"
|
||||
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
ref={parentRef}
|
||||
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
<Transition
|
||||
appear
|
||||
as={Fragment}
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={loading}
|
||||
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
}}
|
||||
appear
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={!loading}
|
||||
|
||||
@@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -37,10 +37,10 @@ const SlideOver = ({
|
||||
as={Fragment}
|
||||
show={show}
|
||||
appear
|
||||
enter="opacity-0 transition ease-in-out duration-300"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition ease-in-out duration-300"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
@@ -58,16 +58,16 @@ const SlideOver = ({
|
||||
<section className="absolute inset-y-0 right-0 flex max-w-full">
|
||||
<Transition.Child
|
||||
appear
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enter="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leave="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
|
||||
ref={slideoverRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import MediaSlider from '@app/components/MediaSlider';
|
||||
import { WatchProviderSelector } from '@app/components/Selector';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
@@ -55,7 +56,7 @@ type CreateOption = {
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText: string;
|
||||
dataPlaceholderText?: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
|
||||
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
|
||||
dataUrl: '/api/v1/discover/movies',
|
||||
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
|
||||
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
|
||||
dataUrl: '/api/v1/discover/tv',
|
||||
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
dataInput = (
|
||||
<WatchProviderSelector
|
||||
type={'movie'}
|
||||
region={slider?.data?.split(',')[0]}
|
||||
activeProviders={
|
||||
slider?.data
|
||||
?.split(',')[1]
|
||||
.split('|')
|
||||
.map((v) => Number(v)) ?? []
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
dataInput = (
|
||||
<WatchProviderSelector
|
||||
type={'tv'}
|
||||
region={slider?.data?.split(',')[0]}
|
||||
activeProviders={
|
||||
slider?.data
|
||||
?.split(',')[1]
|
||||
.split('|')
|
||||
.map((v) => Number(v)) ?? []
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
dataInput = (
|
||||
<Field
|
||||
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={
|
||||
activeOption.type ===
|
||||
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
|
||||
activeOption.type ===
|
||||
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
|
||||
? activeOption.params
|
||||
?.replace(
|
||||
'$regionValue',
|
||||
encodeURIExtraParams(values?.data.split(',')[0])
|
||||
)
|
||||
.replace(
|
||||
'$providersValue',
|
||||
encodeURIExtraParams(values?.data.split(',')[1])
|
||||
)
|
||||
: activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)
|
||||
}
|
||||
onNewTitles={updateResultCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
|
||||
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
||||
default:
|
||||
return 'Unknown Slider';
|
||||
}
|
||||
@@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
|
||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
<div>{getSliderTitle(slider)}</div>
|
||||
<div className="w-7/12 truncate md:w-full">
|
||||
{getSliderTitle(slider)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
|
||||
@@ -91,10 +91,10 @@ const DiscoverTv = () => {
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateDesc}>
|
||||
<option value={SortOptions.FirstAirDateDesc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
<option value={SortOptions.FirstAirDateAsc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
|
||||
@@ -35,8 +35,10 @@ const messages = defineMessages({
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
tmdbuservotecount: 'TMDB User Vote Count',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -246,6 +248,45 @@ const FilterSlideover = ({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.tmdbuservotecount)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={0}
|
||||
max={1000}
|
||||
defaultMaxValue={
|
||||
currentFilters.voteCountLte
|
||||
? Number(currentFilters.voteCountLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.voteCountGte
|
||||
? Number(currentFilters.voteCountGte)
|
||||
: undefined
|
||||
}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'voteCountGte',
|
||||
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'voteCountLte',
|
||||
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
subText={intl.formatMessage(messages.voteCount, {
|
||||
minValue: currentFilters.voteCountGte ?? 0,
|
||||
maxValue: currentFilters.voteCountLte ?? 1000,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
|
||||
@@ -139,6 +139,12 @@ const networks: Network[] = [
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||
url: '/discover/tv/network/13',
|
||||
},
|
||||
{
|
||||
name: 'Peacock',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gIAcGTjKKr0KOHL5s4O36roJ8p7.png',
|
||||
url: '/discover/tv/network/3353',
|
||||
},
|
||||
];
|
||||
|
||||
const NetworkSlider = () => {
|
||||
|
||||
@@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
|
||||
tmdbnetwork: 'TMDB Network',
|
||||
tmdbstudio: 'TMDB Studio',
|
||||
tmdbsearch: 'TMDB Search',
|
||||
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
@@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
voteCountLte: z.string().optional(),
|
||||
voteCountGte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
});
|
||||
@@ -167,6 +171,14 @@ export const prepareFilterValues = (
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
if (values.voteCountGte) {
|
||||
filterValues.voteCountGte = values.voteCountGte;
|
||||
}
|
||||
|
||||
if (values.voteCountLte) {
|
||||
filterValues.voteCountLte = values.voteCountLte;
|
||||
}
|
||||
|
||||
if (values.watchProviders) {
|
||||
filterValues.watchProviders = values.watchProviders;
|
||||
}
|
||||
@@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.voteAverageLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.voteCountGte;
|
||||
delete clonedFilters.voteCountLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.withRuntimeGte;
|
||||
|
||||
@@ -165,10 +165,10 @@ const Discover = () => {
|
||||
</Transition>
|
||||
<Transition
|
||||
show={isEditing}
|
||||
enter="transition transform duration-300"
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0 translate-y-6"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition duration-300 transform"
|
||||
leave="transition duration-300"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-6"
|
||||
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
||||
@@ -365,6 +365,36 @@ const Discover = () => {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/movies?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/tv?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
|
||||
@@ -65,10 +65,10 @@ const IssueComment = ({
|
||||
>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
@@ -115,11 +115,11 @@ const IssueComment = ({
|
||||
as={Fragment}
|
||||
show={open}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
@@ -164,7 +164,7 @@ const IssueComment = ({
|
||||
</Menu>
|
||||
)}
|
||||
<div
|
||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||
isReversed ? '-left-1' : '-right-1'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -57,11 +57,11 @@ const IssueDescription = ({
|
||||
show={open}
|
||||
as="div"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
|
||||
@@ -182,10 +182,10 @@ const IssueDetails = () => {
|
||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
|
||||
@@ -72,8 +72,12 @@ const CreateIssueModal = ({
|
||||
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
|
||||
.filter(
|
||||
(season) =>
|
||||
season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
(settings.currentSettings.seriesEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
(season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE)) ||
|
||||
(settings.currentSettings.series4kEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
|
||||
@@ -12,10 +12,10 @@ interface IssueModalProps {
|
||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
|
||||
@@ -34,12 +34,12 @@ const LanguagePicker = () => {
|
||||
<Transition
|
||||
as="div"
|
||||
show={isDropdownOpen}
|
||||
enter="transition ease-out duration-100 opacity-0"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75 opacity-100"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
|
||||
|
||||
@@ -131,13 +131,13 @@ const MobileMenu = () => {
|
||||
show={isOpen}
|
||||
as="div"
|
||||
ref={ref}
|
||||
enter="transition transform duration-500"
|
||||
enter="transition duration-500"
|
||||
enterFrom="opacity-0 translate-y-0"
|
||||
enterTo="opacity-100 -translate-y-full"
|
||||
leave="transition duration-500 transform"
|
||||
leave="transition duration-500"
|
||||
leaveFrom="opacity-100 -translate-y-full"
|
||||
leaveTo="opacity-0 translate-y-0"
|
||||
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
|
||||
>
|
||||
{filteredLinks.map((link) => {
|
||||
const isActive = router.pathname.match(link.activeRegExp);
|
||||
@@ -167,27 +167,29 @@ const MobileMenu = () => {
|
||||
</Transition>
|
||||
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
|
||||
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
|
||||
{filteredLinks.slice(0, 4).map((link) => {
|
||||
const isActive =
|
||||
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||
return (
|
||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||
<a
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
>
|
||||
{cloneElement(
|
||||
isActive ? link.svgIconSelected : link.svgIcon,
|
||||
{
|
||||
className: 'h-6 w-6',
|
||||
}
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{filteredLinks.length > 4 && (
|
||||
{filteredLinks
|
||||
.slice(0, filteredLinks.length === 5 ? 5 : 4)
|
||||
.map((link) => {
|
||||
const isActive =
|
||||
router.pathname.match(link.activeRegExp) && !isOpen;
|
||||
return (
|
||||
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
|
||||
<a
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
>
|
||||
{cloneElement(
|
||||
isActive ? link.svgIconSelected : link.svgIcon,
|
||||
{
|
||||
className: 'h-6 w-6',
|
||||
}
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{filteredLinks.length > 4 && filteredLinks.length !== 5 && (
|
||||
<button
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
isOpen ? 'text-indigo-500' : ''
|
||||
|
||||
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const PullToRefresh = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [pullStartPoint, setPullStartPoint] = useState(0);
|
||||
const [pullChange, setPullChange] = useState(0);
|
||||
const refreshDiv = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Various pull down thresholds that determine icon location
|
||||
const pullDownInitThreshold = pullChange > 20;
|
||||
const pullDownStopThreshold = 120;
|
||||
const pullDownReloadThreshold = pullChange > 340;
|
||||
const pullDownIconLocation = pullChange / 3;
|
||||
|
||||
useEffect(() => {
|
||||
// Reload function that is called when reload threshold has been hit
|
||||
// Add loading class to determine when to add spin animation
|
||||
const forceReload = () => {
|
||||
refreshDiv.current?.classList.add('loading');
|
||||
setTimeout(() => {
|
||||
router.reload();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const html = document.querySelector('html');
|
||||
|
||||
// Determines if we are at the top of the page
|
||||
// Locks or unlocks page when pulling down to refresh
|
||||
const pullStart = (e: TouchEvent) => {
|
||||
setPullStartPoint(e.targetTouches[0].screenY);
|
||||
|
||||
if (window.scrollY === 0 && window.scrollX === 0) {
|
||||
refreshDiv.current?.classList.add('block');
|
||||
refreshDiv.current?.classList.remove('hidden');
|
||||
document.body.style.touchAction = 'none';
|
||||
document.body.style.overscrollBehavior = 'none';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'none';
|
||||
}
|
||||
} else {
|
||||
refreshDiv.current?.classList.remove('block');
|
||||
refreshDiv.current?.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how far we have pulled down the refresh icon
|
||||
const pullDown = async (e: TouchEvent) => {
|
||||
const screenY = e.targetTouches[0].screenY;
|
||||
|
||||
const pullLength =
|
||||
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
|
||||
|
||||
setPullChange(pullLength);
|
||||
};
|
||||
|
||||
// Will reload the page if we are past the threshold
|
||||
// Otherwise, we reset the pull
|
||||
const pullFinish = () => {
|
||||
setPullStartPoint(0);
|
||||
|
||||
if (pullDownReloadThreshold) {
|
||||
forceReload();
|
||||
} else {
|
||||
setPullChange(0);
|
||||
}
|
||||
|
||||
document.body.style.touchAction = 'auto';
|
||||
document.body.style.overscrollBehaviorY = 'auto';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', pullStart, { passive: false });
|
||||
window.addEventListener('touchmove', pullDown, { passive: false });
|
||||
window.addEventListener('touchend', pullFinish, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', pullStart);
|
||||
window.removeEventListener('touchmove', pullDown);
|
||||
window.removeEventListener('touchend', pullFinish);
|
||||
};
|
||||
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refreshDiv}
|
||||
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
|
||||
id="refreshIcon"
|
||||
style={{
|
||||
top:
|
||||
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
|
||||
? pullDownIconLocation
|
||||
: pullDownInitThreshold
|
||||
? pullDownStopThreshold
|
||||
: '',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
|
||||
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`rounded-full ${
|
||||
pullDownReloadThreshold && 'rotate-180'
|
||||
} text-indigo-500 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
||||
@@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
{
|
||||
href: '/issues',
|
||||
messagesKey: 'issues',
|
||||
svgIcon: (
|
||||
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
|
||||
),
|
||||
svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/issues/,
|
||||
requiredPermission: [
|
||||
Permission.MANAGE_ISSUES,
|
||||
@@ -127,10 +125,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as="div"
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enter="transition-transform ease-in-out duration-300"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leave="transition-transform ease-in-out duration-300"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
|
||||
@@ -63,11 +63,11 @@ const UserDropdown = () => {
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
appear
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import MobileMenu from '@app/components/Layout/MobileMenu';
|
||||
import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
import PullToRefresh from '@app/components/PullToRefresh';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
|
||||
@@ -95,10 +95,10 @@ const Login = () => {
|
||||
<Transition
|
||||
as="div"
|
||||
show={!!error}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import DownloadBlock from '@app/components/DownloadBlock';
|
||||
import IssueBlock from '@app/components/IssueBlock';
|
||||
import RequestBlock from '@app/components/RequestBlock';
|
||||
@@ -144,20 +145,24 @@ const ManageSlideOver = ({
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
content={status.title}
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
content={status.title}
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -272,11 +277,16 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
>
|
||||
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
/>
|
||||
<Tooltip
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
/>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
@@ -393,11 +403,16 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
>
|
||||
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
/>
|
||||
<Tooltip
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
/>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
@@ -453,22 +468,23 @@ const ManageSlideOver = ({
|
||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.markavailable
|
||||
: messages.markallseasonsavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{data?.mediaInfo.status !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.seriesEnabled && (
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.markavailable
|
||||
: messages.markallseasonsavailable
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<Button
|
||||
|
||||
@@ -98,6 +98,7 @@ const MediaSlider = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
@@ -112,6 +113,7 @@ const MediaSlider = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
|
||||
@@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
|
||||
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
@@ -26,6 +27,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import {
|
||||
ArrowRightCircleIcon,
|
||||
CloudIcon,
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import type { RTRating } from '@server/api/rottentomatoes';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||
@@ -85,6 +87,7 @@ const messages = defineMessages({
|
||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
imdbuserscore: 'IMDB User Score',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -110,10 +113,17 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
mutate: revalidate,
|
||||
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
|
||||
fallbackData: movie,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: movie?.mediaInfo?.downloadStatus,
|
||||
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { data: ratingData } = useSWR<RTRating>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
||||
const { data: ratingData } = useSWR<RatingResponse>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratingscombined`
|
||||
);
|
||||
|
||||
const sortedCrew = useMemo(
|
||||
@@ -144,6 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
const mediaLinks: PlayButtonLink[] = [];
|
||||
|
||||
if (
|
||||
settings.currentSettings.movieEnabled &&
|
||||
plexUrl &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
@@ -304,16 +315,28 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
</div>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.title}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="movie"
|
||||
plexUrl={plexUrl}
|
||||
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||
/>
|
||||
{settings.currentSettings.movieEnabled &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
Permission.REQUEST,
|
||||
Permission.REQUEST_MOVIE,
|
||||
],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.title}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="movie"
|
||||
plexUrl={plexUrl}
|
||||
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||
/>
|
||||
)}
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[
|
||||
@@ -369,7 +392,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
{((settings.currentSettings.movieEnabled &&
|
||||
data.mediaInfo?.status === MediaStatus.AVAILABLE) ||
|
||||
(settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
@@ -503,44 +527,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
)}
|
||||
<div className="media-facts">
|
||||
{(!!data.voteCount ||
|
||||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
||||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
||||
(ratingData?.rt?.criticsRating &&
|
||||
!!ratingData?.rt?.criticsScore) ||
|
||||
(ratingData?.rt?.audienceRating &&
|
||||
!!ratingData?.rt?.audienceScore) ||
|
||||
ratingData?.imdb?.criticsScore) && (
|
||||
<div className="media-ratings">
|
||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||
>
|
||||
{ratingData?.rt?.criticsRating &&
|
||||
!!ratingData?.rt?.criticsScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.rt.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.rt.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6" />
|
||||
) : (
|
||||
<RTFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.rt.criticsScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.rt?.audienceRating &&
|
||||
!!ratingData?.rt?.audienceScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtaudiencescore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.rt.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.rt.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.rt.audienceScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.imdb?.criticsScore && (
|
||||
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
|
||||
<a
|
||||
href={ratingData.url}
|
||||
href={ratingData.imdb.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6" />
|
||||
) : (
|
||||
<RTFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.criticsScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtaudiencescore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.audienceScore}%</span>
|
||||
<ImdbLogo className="mr-1 w-6" />
|
||||
<span>{ratingData.imdb.criticsScore}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -621,6 +663,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
@@ -640,6 +683,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -787,7 +831,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
tmdbId={data.id}
|
||||
tvdbId={data.externalIds.tvdbId}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.url}
|
||||
rtUrl={ratingData?.rt?.url}
|
||||
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -159,17 +159,19 @@ const NotificationTypeSelector = ({
|
||||
{ type: 'or' }
|
||||
) ||
|
||||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
|
||||
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
((!settings.currentSettings.movieEnabled ||
|
||||
!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
|
||||
{ type: 'or' }
|
||||
)) &&
|
||||
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
|
||||
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
(!settings.currentSettings.seriesEnabled ||
|
||||
!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
|
||||
{ type: 'or' }
|
||||
|
||||
@@ -91,11 +91,13 @@ const PersonDetails = () => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
deathdate: intl.formatDate(data.deathday, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -106,6 +108,7 @@ const PersonDetails = () => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -142,6 +145,7 @@ const PersonDetails = () => {
|
||||
summary={media.overview}
|
||||
mediaType={media.mediaType as 'movie' | 'tv'}
|
||||
status={media.mediaInfo?.status}
|
||||
status4k={media.mediaInfo?.status4k}
|
||||
canExpand
|
||||
/>
|
||||
{media.character && (
|
||||
@@ -182,6 +186,7 @@ const PersonDetails = () => {
|
||||
summary={media.overview}
|
||||
mediaType={media.mediaType as 'movie' | 'tv'}
|
||||
status={media.mediaInfo?.status}
|
||||
status4k={media.mediaInfo?.status}
|
||||
canExpand
|
||||
/>
|
||||
{media.job && (
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/router';
|
||||
import PR from 'pulltorefreshjs';
|
||||
import { useEffect } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
const PullToRefresh = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
PR.init({
|
||||
mainElement: '#pull-to-refresh',
|
||||
onRefresh() {
|
||||
router.reload();
|
||||
},
|
||||
iconArrow: ReactDOMServer.renderToString(
|
||||
<div className="p-2">
|
||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||
</div>
|
||||
),
|
||||
iconRefreshing: ReactDOMServer.renderToString(
|
||||
<div
|
||||
className="animate-spin p-2"
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
>
|
||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||
</div>
|
||||
),
|
||||
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
|
||||
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
|
||||
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
|
||||
distReload: 60,
|
||||
distIgnore: 15,
|
||||
shouldPullToRefresh: () =>
|
||||
!window.scrollY && document.body.style.overflow !== 'hidden',
|
||||
});
|
||||
return () => {
|
||||
PR.destroyAll();
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return <div id="pull-to-refresh"></div>;
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
||||
@@ -122,7 +122,7 @@ const RegionSelector = ({
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition-opacity ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
||||
|
||||
@@ -273,7 +273,9 @@ const RequestButton = ({
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) &&
|
||||
((settings.currentSettings.movieEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.seriesEnabled && mediaType === 'tv'))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request',
|
||||
@@ -292,7 +294,8 @@ const RequestButton = ({
|
||||
}) &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
!isShowComplete
|
||||
!isShowComplete &&
|
||||
settings.currentSettings.seriesEnabled
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more',
|
||||
|
||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
@@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
request.type === 'movie'
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
@@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
mutate: revalidate,
|
||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { plexUrl, plexUrl4k } = useDeepLinks({
|
||||
|
||||
@@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
|
||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
@@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
`/api/v1/request/${request.id}`,
|
||||
{
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -582,10 +582,10 @@ const AdvancedRequester = ({
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition ease-in duration-300"
|
||||
enter="transition-opacity ease-in duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition-opacity ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
|
||||
|
||||
@@ -324,7 +324,7 @@ const CollectionRequestModal = ({
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
@@ -389,7 +389,7 @@ const CollectionRequestModal = ({
|
||||
isSelectedPart(part.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -540,7 +540,7 @@ const TvRequestModal = ({
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
@@ -631,7 +631,7 @@ const TvRequestModal = ({
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -29,10 +29,10 @@ const RequestModal = ({
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
|
||||
@@ -169,15 +169,19 @@ export const GenreSelector = ({
|
||||
loadDefaultGenre();
|
||||
}, [defaultValue, type]);
|
||||
|
||||
const loadGenreOptions = async () => {
|
||||
const loadGenreOptions = async (inputValue: string) => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/${type}`
|
||||
);
|
||||
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
return results.data
|
||||
.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}))
|
||||
.filter(({ label }) =>
|
||||
label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
onChange(watchRegion, activeProvider);
|
||||
}, [activeProvider, watchRegion, onChange]);
|
||||
// removed onChange as a dependency as we only need to call it when the value(s) change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeProvider, watchRegion]);
|
||||
|
||||
const orderedData = useMemo(() => {
|
||||
if (!data) {
|
||||
@@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
|
||||
<SmallLoadingSpinner />
|
||||
) : (
|
||||
<div className="grid">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="provider-icons grid gap-2">
|
||||
{initialProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
@@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
|
||||
})}
|
||||
</div>
|
||||
{showMore && otherProviders.length > 0 && (
|
||||
<div className="relative top-2 grid grid-cols-6 gap-2">
|
||||
<div className="provider-icons relative top-2 grid gap-2">
|
||||
{otherProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
@@ -431,6 +437,7 @@ export const WatchProviderSelector = ({
|
||||
{otherProviders.length > 0 && (
|
||||
<button
|
||||
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
|
||||
type="button"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
>
|
||||
<div className="h-0.5 flex-1 bg-gray-600" />
|
||||
|
||||
@@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
|
||||
} relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
|
||||
@@ -39,6 +39,9 @@ const defaultPayload = {
|
||||
requestedBy_email: '{{requestedBy_email}}',
|
||||
requestedBy_username: '{{requestedBy_username}}',
|
||||
requestedBy_avatar: '{{requestedBy_avatar}}',
|
||||
requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}',
|
||||
requestedBy_settings_telegramChatId:
|
||||
'{{requestedBy_settings_telegramChatId}}',
|
||||
},
|
||||
'{{issue}}': {
|
||||
issue_id: '{{issue_id}}',
|
||||
@@ -47,12 +50,18 @@ const defaultPayload = {
|
||||
reportedBy_email: '{{reportedBy_email}}',
|
||||
reportedBy_username: '{{reportedBy_username}}',
|
||||
reportedBy_avatar: '{{reportedBy_avatar}}',
|
||||
reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}',
|
||||
reportedBy_settings_telegramChatId:
|
||||
'{{reportedBy_settings_telegramChatId}}',
|
||||
},
|
||||
'{{comment}}': {
|
||||
comment_message: '{{comment_message}}',
|
||||
commentedBy_email: '{{commentedBy_email}}',
|
||||
commentedBy_username: '{{commentedBy_username}}',
|
||||
commentedBy_avatar: '{{commentedBy_avatar}}',
|
||||
commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}',
|
||||
commentedBy_settings_telegramChatId:
|
||||
'{{commentedBy_settings_telegramChatId}}',
|
||||
},
|
||||
'{{extra}}': [],
|
||||
};
|
||||
|
||||
@@ -57,6 +57,9 @@ const messages = defineMessages({
|
||||
testFirstTags: 'Test connection to load tags',
|
||||
tags: 'Tags',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
tagRequests: 'Tag Requests',
|
||||
tagRequestsInfo:
|
||||
"Automatically add an additional tag with the requester's user ID & display name",
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
||||
@@ -214,10 +217,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacuty-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
@@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
externalUrl: radarr?.externalUrl,
|
||||
syncEnabled: radarr?.syncEnabled ?? false,
|
||||
enableSearch: !radarr?.preventSearch,
|
||||
tagRequests: radarr?.tagRequests ?? false,
|
||||
}}
|
||||
validationSchema={RadarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
if (!radarr) {
|
||||
await axios.post('/api/v1/settings/radarr', submission);
|
||||
@@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tagRequests" className="checkbox-label">
|
||||
{intl.formatMessage(messages.tagRequests)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tagRequests"
|
||||
name="tagRequests"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
|
||||
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={isModalOpen}
|
||||
|
||||
@@ -53,6 +53,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
'plex-full-scan': 'Plex Full Library Scan',
|
||||
'plex-watchlist-sync': 'Plex Watchlist Sync',
|
||||
'availability-sync': 'Media Availability Sync',
|
||||
'radarr-scan': 'Radarr Scan',
|
||||
'sonarr-scan': 'Sonarr Scan',
|
||||
'download-sync': 'Download Sync',
|
||||
@@ -67,6 +68,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
||||
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
||||
editJobScheduleSelectorMinutes:
|
||||
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
|
||||
editJobScheduleSelectorSeconds:
|
||||
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
|
||||
imagecache: 'Image Cache',
|
||||
imagecacheDescription:
|
||||
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||
@@ -78,7 +81,7 @@ interface Job {
|
||||
id: JobId;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
cronSchedule: string;
|
||||
nextExecutionTime: string;
|
||||
running: boolean;
|
||||
@@ -89,10 +92,11 @@ type JobModalState = {
|
||||
job?: Job;
|
||||
scheduleHours: number;
|
||||
scheduleMinutes: number;
|
||||
scheduleSeconds: number;
|
||||
};
|
||||
|
||||
type JobModalAction =
|
||||
| { type: 'set'; hours?: number; minutes?: number }
|
||||
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
|
||||
| {
|
||||
type: 'close';
|
||||
}
|
||||
@@ -115,6 +119,7 @@ const jobModalReducer = (
|
||||
job: action.job,
|
||||
scheduleHours: 1,
|
||||
scheduleMinutes: 5,
|
||||
scheduleSeconds: 30,
|
||||
};
|
||||
|
||||
case 'set':
|
||||
@@ -122,6 +127,7 @@ const jobModalReducer = (
|
||||
...state,
|
||||
scheduleHours: action.hours ?? state.scheduleHours,
|
||||
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
||||
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -149,6 +155,7 @@ const SettingsJobs = () => {
|
||||
isOpen: false,
|
||||
scheduleHours: 1,
|
||||
scheduleMinutes: 5,
|
||||
scheduleSeconds: 30,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -200,9 +207,11 @@ const SettingsJobs = () => {
|
||||
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
||||
|
||||
try {
|
||||
if (jobModalState.job?.interval === 'short') {
|
||||
if (jobModalState.job?.interval === 'seconds') {
|
||||
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
|
||||
} else if (jobModalState.job?.interval === 'minutes') {
|
||||
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
||||
} else if (jobModalState.job?.interval === 'long') {
|
||||
} else if (jobModalState.job?.interval === 'hours') {
|
||||
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
||||
} else {
|
||||
// jobs with interval: fixed should not be editable
|
||||
@@ -244,10 +253,10 @@ const SettingsJobs = () => {
|
||||
/>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={jobModalState.isOpen}
|
||||
@@ -286,7 +295,30 @@ const SettingsJobs = () => {
|
||||
{intl.formatMessage(messages.editJobSchedulePrompt)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
{jobModalState.job?.interval === 'short' ? (
|
||||
{jobModalState.job?.interval === 'seconds' ? (
|
||||
<select
|
||||
name="jobScheduleSeconds"
|
||||
className="inline"
|
||||
value={jobModalState.scheduleSeconds}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'set',
|
||||
seconds: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
{[30, 45, 60].map((v) => (
|
||||
<option value={v} key={`jobScheduleSeconds-${v}`}>
|
||||
{intl.formatMessage(
|
||||
messages.editJobScheduleSelectorSeconds,
|
||||
{
|
||||
jobScheduleSeconds: v,
|
||||
}
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : jobModalState.job?.interval === 'minutes' ? (
|
||||
<select
|
||||
name="jobScheduleMinutes"
|
||||
className="inline"
|
||||
|
||||
@@ -143,10 +143,10 @@ const SettingsLogs = () => {
|
||||
/>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
appear
|
||||
|
||||
@@ -247,10 +247,10 @@ const SettingsServices = () => {
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={deleteServerModal.open}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacuty-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
|
||||
@@ -43,6 +43,8 @@ const messages = defineMessages({
|
||||
qualityprofile: 'Quality Profile',
|
||||
languageprofile: 'Language Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
seriesType: 'Series Type',
|
||||
animeSeriesType: 'Anime Series Type',
|
||||
animequalityprofile: 'Anime Quality Profile',
|
||||
animelanguageprofile: 'Anime Language Profile',
|
||||
animerootfolder: 'Anime Root Folder',
|
||||
@@ -62,6 +64,9 @@ const messages = defineMessages({
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
tagRequests: 'Tag Requests',
|
||||
tagRequestsInfo:
|
||||
"Automatically add an additional tag with the requester's user ID & display name",
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||
@@ -223,10 +228,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacuty-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
@@ -241,6 +246,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
activeProfileId: sonarr?.activeProfileId,
|
||||
activeLanguageProfileId: sonarr?.activeLanguageProfileId,
|
||||
rootFolder: sonarr?.activeDirectory,
|
||||
seriesType: sonarr?.seriesType,
|
||||
animeSeriesType: sonarr?.animeSeriesType,
|
||||
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
|
||||
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
|
||||
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
|
||||
@@ -252,6 +259,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
externalUrl: sonarr?.externalUrl,
|
||||
syncEnabled: sonarr?.syncEnabled ?? false,
|
||||
enableSearch: !sonarr?.preventSearch,
|
||||
tagRequests: sonarr?.tagRequests ?? false,
|
||||
}}
|
||||
validationSchema={SonarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -276,6 +284,8 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
: undefined,
|
||||
activeProfileName: profileName,
|
||||
activeDirectory: values.rootFolder,
|
||||
seriesType: values.seriesType,
|
||||
animeSeriesType: values.animeSeriesType,
|
||||
activeAnimeProfileId: values.activeAnimeProfileId
|
||||
? Number(values.activeAnimeProfileId)
|
||||
: undefined,
|
||||
@@ -292,6 +302,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
if (!sonarr) {
|
||||
await axios.post('/api/v1/settings/sonarr', submission);
|
||||
@@ -532,6 +543,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="seriesType" className="text-label">
|
||||
{intl.formatMessage(messages.seriesType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="seriesType"
|
||||
name="seriesType"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="daily">Daily</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{errors.seriesType && touched.seriesType && (
|
||||
<div className="error">{errors.seriesType}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
@@ -718,6 +750,27 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="animeSeriesType" className="text-label">
|
||||
{intl.formatMessage(messages.animeSeriesType)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="animeSeriesType"
|
||||
name="animeSeriesType"
|
||||
disabled={!isValidated || isTesting}
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="anime">Anime</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{errors.animeSeriesType && touched.animeSeriesType && (
|
||||
<div className="error">{errors.animeSeriesType}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="activeAnimeProfileId" className="text-label">
|
||||
{intl.formatMessage(messages.animequalityprofile)}
|
||||
@@ -960,6 +1013,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tagRequests" className="checkbox-label">
|
||||
{intl.formatMessage(messages.tagRequests)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tagRequests"
|
||||
name="tagRequests"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -156,6 +156,7 @@ const Slider = ({
|
||||
}`}
|
||||
onClick={() => slide(Direction.LEFT)}
|
||||
disabled={scrollPos.isStart}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -165,6 +166,7 @@ const Slider = ({
|
||||
}`}
|
||||
onClick={() => slide(Direction.RIGHT)}
|
||||
disabled={scrollPos.isEnd}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
@@ -73,7 +73,10 @@ const StatusBadge = ({
|
||||
type: 'or',
|
||||
}
|
||||
) &&
|
||||
(!is4k ||
|
||||
((!is4k &&
|
||||
(mediaType === 'movie'
|
||||
? settings.currentSettings.movieEnabled
|
||||
: settings.currentSettings.seriesEnabled)) ||
|
||||
(mediaType === 'movie'
|
||||
? settings.currentSettings.movie4kEnabled
|
||||
: settings.currentSettings.series4kEnabled))
|
||||
@@ -166,11 +169,11 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -219,11 +222,11 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -272,11 +275,11 @@ const StatusBadge = ({
|
||||
</span>
|
||||
{inProgress && (
|
||||
<>
|
||||
{mediaType === 'tv' && (
|
||||
{mediaType === 'tv' && downloadItem[0].episode && (
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage(messages.seasonepisodenumber, {
|
||||
seasonNumber: downloadItem[0].episode?.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode?.episodeNumber,
|
||||
seasonNumber: downloadItem[0].episode.seasonNumber,
|
||||
episodeNumber: downloadItem[0].episode.episodeNumber,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -44,10 +44,10 @@ const StatusChecker = () => {
|
||||
return (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="opacity-0 transition duration-300"
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leave="transition-opacity duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
appear
|
||||
|
||||
@@ -59,6 +59,7 @@ const TmdbTitleCard = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
@@ -71,6 +72,7 @@ const TmdbTitleCard = ({
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
status4k={title.mediaInfo?.status4k}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
|
||||
@@ -6,6 +6,7 @@ import RequestModal from '@app/components/RequestModal';
|
||||
import ErrorCard from '@app/components/TitleCard/ErrorCard';
|
||||
import Placeholder from '@app/components/TitleCard/Placeholder';
|
||||
import { useIsTouch } from '@app/hooks/useIsTouch';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
@@ -23,9 +24,10 @@ interface TitleCardProps {
|
||||
summary?: string;
|
||||
year?: string;
|
||||
title: string;
|
||||
userScore: number;
|
||||
userScore?: number;
|
||||
mediaType: MediaType;
|
||||
status?: MediaStatus;
|
||||
status4k?: MediaStatus;
|
||||
canExpand?: boolean;
|
||||
inProgress?: boolean;
|
||||
}
|
||||
@@ -37,6 +39,7 @@ const TitleCard = ({
|
||||
year,
|
||||
title,
|
||||
status,
|
||||
status4k,
|
||||
mediaType,
|
||||
inProgress = false,
|
||||
canExpand = false,
|
||||
@@ -46,8 +49,11 @@ const TitleCard = ({
|
||||
const { hasPermission } = useUser();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStatus, setCurrentStatus] = useState(status);
|
||||
const [currentStatus4k, setCurrentStatus4k] = useState(status4k);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showRequestModal4k, setShowRequestModal4k] = useState(false);
|
||||
const settings = useSettings();
|
||||
|
||||
// Just to get the year from the date
|
||||
if (year) {
|
||||
@@ -56,7 +62,8 @@ const TitleCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStatus(status);
|
||||
}, [status]);
|
||||
setCurrentStatus4k(status4k);
|
||||
}, [status, status4k]);
|
||||
|
||||
const requestComplete = useCallback((newStatus: MediaStatus) => {
|
||||
setCurrentStatus(newStatus);
|
||||
@@ -70,13 +77,44 @@ const TitleCard = ({
|
||||
|
||||
const closeModal = useCallback(() => setShowRequestModal(false), []);
|
||||
|
||||
const showRequestButton = hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
);
|
||||
const requestComplete4k = useCallback((newStatus: MediaStatus) => {
|
||||
setCurrentStatus4k(newStatus);
|
||||
setShowRequestModal4k(false);
|
||||
}, []);
|
||||
|
||||
const closeModal4k = useCallback(() => setShowRequestModal4k(false), []);
|
||||
|
||||
const showRequestButton =
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
((settings.currentSettings.movieEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.seriesEnabled && mediaType === 'tv'));
|
||||
|
||||
const showRequestButton4k =
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST_4K,
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? Permission.REQUEST_4K_MOVIE
|
||||
: Permission.REQUEST_4K_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'));
|
||||
|
||||
const isRequestable =
|
||||
(showRequestButton &&
|
||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN)) ||
|
||||
(showRequestButton4k &&
|
||||
(!currentStatus4k || currentStatus4k === MediaStatus.UNKNOWN));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,11 +124,32 @@ const TitleCard = ({
|
||||
<RequestModal
|
||||
tmdbId={id}
|
||||
show={showRequestModal}
|
||||
type={mediaType === 'movie' ? 'movie' : 'tv'}
|
||||
type={
|
||||
mediaType === 'movie'
|
||||
? 'movie'
|
||||
: mediaType === 'collection'
|
||||
? 'collection'
|
||||
: 'tv'
|
||||
}
|
||||
onComplete={requestComplete}
|
||||
onUpdating={requestUpdating}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
<RequestModal
|
||||
tmdbId={id}
|
||||
show={showRequestModal4k}
|
||||
type={
|
||||
mediaType === 'movie'
|
||||
? 'movie'
|
||||
: mediaType === 'collection'
|
||||
? 'collection'
|
||||
: 'tv'
|
||||
}
|
||||
onComplete={requestComplete4k}
|
||||
onUpdating={requestUpdating}
|
||||
onCancel={closeModal4k}
|
||||
is4k
|
||||
/>
|
||||
<div
|
||||
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
|
||||
showDetail
|
||||
@@ -130,7 +189,7 @@ const TitleCard = ({
|
||||
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
||||
<div
|
||||
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
|
||||
mediaType === 'movie'
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? 'border-blue-500 bg-blue-600'
|
||||
: 'border-purple-600 bg-purple-600'
|
||||
}`}
|
||||
@@ -138,26 +197,37 @@ const TitleCard = ({
|
||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||
{mediaType === 'movie'
|
||||
? intl.formatMessage(globalMessages.movie)
|
||||
: mediaType === 'collection'
|
||||
? intl.formatMessage(globalMessages.collection)
|
||||
: intl.formatMessage(globalMessages.tvshow)}
|
||||
</div>
|
||||
</div>
|
||||
{currentStatus && (
|
||||
<div className="pointer-events-none z-40 flex items-center">
|
||||
<div className="pointer-events-none z-40 flex items-center">
|
||||
{currentStatus && currentStatus !== MediaStatus.UNKNOWN ? (
|
||||
<StatusBadgeMini
|
||||
status={currentStatus}
|
||||
inProgress={inProgress}
|
||||
shrink
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
currentStatus4k &&
|
||||
currentStatus4k !== MediaStatus.UNKNOWN && (
|
||||
<StatusBadgeMini
|
||||
status={currentStatus4k}
|
||||
inProgress={inProgress}
|
||||
shrink
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isUpdating}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
@@ -168,16 +238,26 @@ const TitleCard = ({
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={!image || showDetail || showRequestModal}
|
||||
enter="transition transform opacity-0"
|
||||
show={
|
||||
!image || showDetail || showRequestModal || showRequestModal4k
|
||||
}
|
||||
enter="transition-opacity"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition transform opacity-100"
|
||||
leave="transition-opacity"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-xl">
|
||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie'
|
||||
? `/movie/${id}`
|
||||
: mediaType === 'collection'
|
||||
? `/collection/${id}`
|
||||
: `/tv/${id}`
|
||||
}
|
||||
>
|
||||
<a
|
||||
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
|
||||
style={{
|
||||
@@ -188,10 +268,7 @@ const TitleCard = ({
|
||||
<div className="flex h-full w-full items-end">
|
||||
<div
|
||||
className={`px-2 text-white ${
|
||||
!showRequestButton ||
|
||||
(currentStatus && currentStatus !== MediaStatus.UNKNOWN)
|
||||
? 'pb-2'
|
||||
: 'pb-11'
|
||||
!isRequestable ? 'pb-2' : 'pb-11'
|
||||
}`}
|
||||
>
|
||||
{year && (
|
||||
@@ -214,12 +291,7 @@ const TitleCard = ({
|
||||
<div
|
||||
className="whitespace-normal text-xs"
|
||||
style={{
|
||||
WebkitLineClamp:
|
||||
!showRequestButton ||
|
||||
(currentStatus &&
|
||||
currentStatus !== MediaStatus.UNKNOWN)
|
||||
? 5
|
||||
: 3,
|
||||
WebkitLineClamp: !isRequestable ? 5 : 3,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
@@ -235,20 +307,39 @@ const TitleCard = ({
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
||||
{showRequestButton &&
|
||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
|
||||
(!currentStatus || currentStatus === MediaStatus.UNKNOWN) ? (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowRequestModal(true);
|
||||
}}
|
||||
className="h-7 w-full"
|
||||
>
|
||||
<ArrowDownTrayIcon />
|
||||
<span>{intl.formatMessage(globalMessages.request)}</span>
|
||||
</Button>
|
||||
) : (
|
||||
showRequestButton4k &&
|
||||
(!currentStatus4k ||
|
||||
currentStatus4k === MediaStatus.UNKNOWN) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowRequestModal(true);
|
||||
setShowRequestModal4k(true);
|
||||
}}
|
||||
className="h-7 w-full"
|
||||
>
|
||||
<ArrowDownTrayIcon />
|
||||
<span>{intl.formatMessage(globalMessages.request)}</span>
|
||||
<span>
|
||||
{intl.formatMessage(globalMessages.request4k)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
import { Disclosure, Transition } from '@headlessui/react';
|
||||
import {
|
||||
ArrowRightCircleIcon,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import type { RTRating } from '@server/api/rottentomatoes';
|
||||
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
@@ -109,6 +110,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
mutate: revalidate,
|
||||
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
|
||||
fallbackData: tv,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: tv?.mediaInfo?.downloadStatus,
|
||||
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { data: ratingData } = useSWR<RTRating>(
|
||||
@@ -141,7 +149,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
|
||||
const mediaLinks: PlayButtonLink[] = [];
|
||||
|
||||
if (plexUrl) {
|
||||
if (
|
||||
settings.currentSettings.seriesEnabled &&
|
||||
plexUrl &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
})
|
||||
) {
|
||||
mediaLinks.push({
|
||||
text: intl.formatMessage(messages.playonplex),
|
||||
url: plexUrl,
|
||||
@@ -329,16 +343,28 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.name}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="tv"
|
||||
plexUrl={plexUrl}
|
||||
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||
/>
|
||||
{settings.currentSettings.seriesEnabled &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
Permission.REQUEST,
|
||||
Permission.REQUEST_TV,
|
||||
],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
downloadItem={data.mediaInfo?.downloadStatus}
|
||||
title={data.name}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
tmdbId={data.mediaInfo?.tmdbId}
|
||||
mediaType="tv"
|
||||
plexUrl={plexUrl}
|
||||
serviceUrl={data.mediaInfo?.serviceUrl}
|
||||
/>
|
||||
)}
|
||||
{settings.currentSettings.series4kEnabled &&
|
||||
hasPermission(
|
||||
[
|
||||
@@ -396,8 +422,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
isShowComplete={isComplete}
|
||||
is4kShowComplete={is4kComplete}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
{((settings.currentSettings.seriesEnabled &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
data?.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE)) ||
|
||||
(settings.currentSettings.series4kEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
@@ -516,6 +546,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
.reverse()
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.map((season) => {
|
||||
const showNon4k =
|
||||
settings.currentSettings.seriesEnabled &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
Permission.REQUEST,
|
||||
Permission.REQUEST_TV,
|
||||
],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
);
|
||||
const show4k =
|
||||
settings.currentSettings.series4kEnabled &&
|
||||
hasPermission(
|
||||
@@ -580,65 +622,75 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
{((!mSeason &&
|
||||
request?.status === MediaRequestStatus.APPROVED) ||
|
||||
mSeason?.status === MediaStatus.PROCESSING) && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(globalMessages.requested)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.PROCESSING}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
mSeason?.status === MediaStatus.PROCESSING) &&
|
||||
showNon4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="primary">
|
||||
{intl.formatMessage(
|
||||
globalMessages.requested
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.PROCESSING}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((!mSeason &&
|
||||
request?.status === MediaRequestStatus.PENDING) ||
|
||||
mSeason?.status === MediaStatus.PENDING) && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini status={MediaStatus.PENDING} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
mSeason?.status === MediaStatus.PENDING) &&
|
||||
showNon4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="warning">
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.PENDING}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason?.status ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(
|
||||
globalMessages.partiallyavailable
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.PARTIALLY_AVAILABLE}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason?.status === MediaStatus.AVAILABLE && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.AVAILABLE}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||
showNon4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(
|
||||
globalMessages.partiallyavailable
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.PARTIALLY_AVAILABLE}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mSeason?.status === MediaStatus.AVAILABLE &&
|
||||
showNon4k && (
|
||||
<>
|
||||
<div className="hidden md:flex">
|
||||
<Badge badgeType="success">
|
||||
{intl.formatMessage(
|
||||
globalMessages.available
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex md:hidden">
|
||||
<StatusBadgeMini
|
||||
status={MediaStatus.AVAILABLE}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((!mSeason4k &&
|
||||
request4k?.status ===
|
||||
MediaRequestStatus.APPROVED) ||
|
||||
@@ -727,18 +779,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={`${
|
||||
open ? 'rotate-180 transform' : ''
|
||||
open ? 'rotate-180' : ''
|
||||
} h-6 w-6 text-gray-500`}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
enter="transition-opacity duration-100 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-75 ease-out"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
// Not sure why this transition is adding a margin without this here
|
||||
style={{ margin: '0px' }}
|
||||
>
|
||||
@@ -844,6 +896,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -858,6 +911,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
@@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
|
||||
isSelectedUser(user.id)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user