Compare commits
4 Commits
feat-anime
...
v1.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e7d68092 | ||
|
|
d3622f7bb3 | ||
|
|
20c821e2eb | ||
|
|
7b82ced5e6 |
@@ -773,132 +773,6 @@
|
||||
"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>",
|
||||
@@ -908,6 +782,5 @@
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": false,
|
||||
"commitConvention": "angular",
|
||||
"commitType": "docs"
|
||||
"commitConvention": "angular"
|
||||
}
|
||||
|
||||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -1,2 +1,7 @@
|
||||
# Global code ownership
|
||||
* @Fallenbagel
|
||||
|
||||
- @Fallenbagel
|
||||
|
||||
# i18n locale files
|
||||
|
||||
src/i18n/locale/ @Fallenbagel
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -39,6 +39,13 @@ 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:
|
||||
@@ -61,6 +68,15 @@ jobs:
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
tags: |
|
||||
fallenbagel/jellyseerr:develop
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
if: always()
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
|
||||
2
.github/workflows/snap.yaml
vendored
2
.github/workflows/snap.yaml
vendored
@@ -41,8 +41,6 @@ jobs:
|
||||
fi
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Configure Git
|
||||
run: git config --add safe.directory /data/parts/jellyseerr/src
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -16,8 +16,5 @@
|
||||
}
|
||||
],
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"files.associations": {
|
||||
"globals.css": "tailwindcss"
|
||||
}
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -1,3 +1,68 @@
|
||||
## [1.4.1](https://github.com/fallenbagel/jellyseerr/compare/v1.4.0...v1.4.1) (2023-01-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pass in library type when scanning recently added items ([#3287](https://github.com/fallenbagel/jellyseerr/issues/3287)) ([8942eb8](https://github.com/fallenbagel/jellyseerr/commit/8942eb8b7c4fa1d16aa2e72e8ba7120a653c9aa2))
|
||||
* **ui:** air date will use UTC for timezone ([#3297](https://github.com/fallenbagel/jellyseerr/issues/3297)) ([3e43586](https://github.com/fallenbagel/jellyseerr/commit/3e43586acc0804c3fff524509caa890a104e132b))
|
||||
* **ui:** correct range slider styling in chrome ([#3299](https://github.com/fallenbagel/jellyseerr/issues/3299)) ([d954328](https://github.com/fallenbagel/jellyseerr/commit/d9543289111d72245564d25d300a71b0ea3954ba))
|
||||
* **ui:** show 5 icons when possible on mobile menu ([#3298](https://github.com/fallenbagel/jellyseerr/issues/3298)) ([7040da1](https://github.com/fallenbagel/jellyseerr/commit/7040da1334f6d18e19a494c73caa17f7df552dfe))
|
||||
* **ui:** style range thumbs correctly for firefox ([#3294](https://github.com/fallenbagel/jellyseerr/issues/3294)) ([9d10e6a](https://github.com/fallenbagel/jellyseerr/commit/9d10e6a88c0996671f1d9d20792e1930dbc82329))
|
||||
|
||||
# [1.4.0](https://github.com/fallenbagel/jellyseerr/compare/v1.3.0...v1.4.0) (2023-01-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bg-opacity to in-progress status badges ([#3190](https://github.com/fallenbagel/jellyseerr/issues/3190)) ([68223f4](https://github.com/fallenbagel/jellyseerr/commit/68223f4b1e98b01825516dcba39cbb2d3df31a70))
|
||||
* added download status and title to request card/item error components ([#3186](https://github.com/fallenbagel/jellyseerr/issues/3186)) ([3309f77](https://github.com/fallenbagel/jellyseerr/commit/3309f77aa4be1d70b27693531c119a8e26822518))
|
||||
* arrow icons were misplaced on mobile in slider edit ([#3260](https://github.com/fallenbagel/jellyseerr/issues/3260)) ([d328485](https://github.com/fallenbagel/jellyseerr/commit/d328485161b9cae6a70ef0713b4878207bc6015e))
|
||||
* **build:** update usage of publish snap action ([#3272](https://github.com/fallenbagel/jellyseerr/issues/3272)) ([51b05cd](https://github.com/fallenbagel/jellyseerr/commit/51b05cd8fbb5d332807d8c00b2ffb7b10c3d0179))
|
||||
* changed overflow scroll to only if necessary ([#3184](https://github.com/fallenbagel/jellyseerr/issues/3184)) ([27feeea](https://github.com/fallenbagel/jellyseerr/commit/27feeea69121336557deda1f32b65a5daa146f82))
|
||||
* convert genre/studio to string in create slider ([#3201](https://github.com/fallenbagel/jellyseerr/issues/3201)) ([93afead](https://github.com/fallenbagel/jellyseerr/commit/93afead92e497f2e5bce67a34fffdaa08d20c7f2))
|
||||
* correct checkbox position (again) for slider edits ([#3227](https://github.com/fallenbagel/jellyseerr/issues/3227)) ([3ba6df1](https://github.com/fallenbagel/jellyseerr/commit/3ba6df1a41c084c4a6a90354338047623abef521))
|
||||
* correct grid sizing for webkit on streaming services ([#3248](https://github.com/fallenbagel/jellyseerr/issues/3248)) ([6fd11cf](https://github.com/fallenbagel/jellyseerr/commit/6fd11cf4254e1a19310592bec78a6de52bc073a8))
|
||||
* correct issue detail bottom padding on mobile displays ([#3268](https://github.com/fallenbagel/jellyseerr/issues/3268)) ([3db010b](https://github.com/fallenbagel/jellyseerr/commit/3db010b9eaec62aa08d973a61caf1801471bbf3e))
|
||||
* correct link to correct keyword results for series ([#3208](https://github.com/fallenbagel/jellyseerr/issues/3208)) ([4e9be7a](https://github.com/fallenbagel/jellyseerr/commit/4e9be7a3f7304ee7be5ee6fd34b1ea8f6c0cf399))
|
||||
* correct spacing between sliders ([#3225](https://github.com/fallenbagel/jellyseerr/issues/3225)) ([62e2de7](https://github.com/fallenbagel/jellyseerr/commit/62e2de70bf37b72d5f63370b662d4103a642775b))
|
||||
* correctly check mobile menu permissions ([#3271](https://github.com/fallenbagel/jellyseerr/issues/3271)) ([f4a22dc](https://github.com/fallenbagel/jellyseerr/commit/f4a22dc437404558f301ccfc195cf0a300dd1ff2))
|
||||
* correctly restore selected streaming service filters ([#3249](https://github.com/fallenbagel/jellyseerr/issues/3249)) ([154f3e7](https://github.com/fallenbagel/jellyseerr/commit/154f3e72efbf0b663358b3029156f54516f01a2f))
|
||||
* create shared class to add bottom spacing ([#3269](https://github.com/fallenbagel/jellyseerr/issues/3269)) ([5d1c6f7](https://github.com/fallenbagel/jellyseerr/commit/5d1c6f706555613d97ed9e61d8b665543c2f239b))
|
||||
* **deps:** pin dependency @headlessui/react to 1.7.7 ([#3194](https://github.com/fallenbagel/jellyseerr/issues/3194)) [skip ci] ([c4b16ab](https://github.com/fallenbagel/jellyseerr/commit/c4b16abc62647c74215155942a4230a31a238677))
|
||||
* **deps:** update dependency @heroicons/react to v2 ([#2970](https://github.com/fallenbagel/jellyseerr/issues/2970)) ([dd48d59](https://github.com/fallenbagel/jellyseerr/commit/dd48d59b20e2d1800ea30912116f4a4f1bb7928f))
|
||||
* **deps:** update dependency axios to v1 ([#3202](https://github.com/fallenbagel/jellyseerr/issues/3202)) ([421029e](https://github.com/fallenbagel/jellyseerr/commit/421029ebab66c9a6622ba47e56d7f6473524cce4))
|
||||
* **deps:** update dependency swr to v2 ([#3212](https://github.com/fallenbagel/jellyseerr/issues/3212)) ([7b6db50](https://github.com/fallenbagel/jellyseerr/commit/7b6db50ae55b1fc60d19a5cff62dd46bb989fa51))
|
||||
* **experimental:** use new RT API (sorta) ([#3179](https://github.com/fallenbagel/jellyseerr/issues/3179)) ([357cab8](https://github.com/fallenbagel/jellyseerr/commit/357cab87ac7752b8e119b51c938b343c661d83c2))
|
||||
* improve small screen layout for discover editing ([#3221](https://github.com/fallenbagel/jellyseerr/issues/3221)) ([d23b213](https://github.com/fallenbagel/jellyseerr/commit/d23b2132de05f072f7f9daad83d81421d747cf99))
|
||||
* include new package calendar css in build ([#3235](https://github.com/fallenbagel/jellyseerr/issues/3235)) ([c2a1a20](https://github.com/fallenbagel/jellyseerr/commit/c2a1a20a3bb20039a1936c7fe0ecb9e8311a0aea))
|
||||
* issues with issues ([#3267](https://github.com/fallenbagel/jellyseerr/issues/3267)) ([fd21971](https://github.com/fallenbagel/jellyseerr/commit/fd219717c01c558814d7a80de6304272b5a7944e))
|
||||
* multiple genre filtering now works ([#3282](https://github.com/fallenbagel/jellyseerr/issues/3282)) ([5076938](https://github.com/fallenbagel/jellyseerr/commit/507693881b939819413f0959df5ef6b7a357eb5c))
|
||||
* prevent double encode if we are on /search endpoint ([#3238](https://github.com/fallenbagel/jellyseerr/issues/3238)) ([a343f8a](https://github.com/fallenbagel/jellyseerr/commit/a343f8ad915491a9c81512c7e541a1dac8906025))
|
||||
* **request:** approve request when retrying request ([#3234](https://github.com/fallenbagel/jellyseerr/issues/3234)) ([b515701](https://github.com/fallenbagel/jellyseerr/commit/b5157010c46cd9083993d5ee0172007b83d631da))
|
||||
* **request:** mark request as approved if media is already available when retrying failed request ([#3244](https://github.com/fallenbagel/jellyseerr/issues/3244)) ([cb65074](https://github.com/fallenbagel/jellyseerr/commit/cb650745f6a33e69391a633e6d272831f314e098))
|
||||
* restore border to ghost button and fix discover slider visibility toggle position ([#3226](https://github.com/fallenbagel/jellyseerr/issues/3226)) ([2eebb7f](https://github.com/fallenbagel/jellyseerr/commit/2eebb7fd3941b34fe9472aaf9d28265df8cce311))
|
||||
* restore status badges on titles on actors page when hide available media enabled ([#3206](https://github.com/fallenbagel/jellyseerr/issues/3206)) ([9d3446d](https://github.com/fallenbagel/jellyseerr/commit/9d3446d370499c3251159393e5c791b01225e05c))
|
||||
* screen would zoom on mobile if date picker input was selected ([#3241](https://github.com/fallenbagel/jellyseerr/issues/3241)) ([3aefddd](https://github.com/fallenbagel/jellyseerr/commit/3aefddd48834d86150d5f5cceb2d08af3a78847b))
|
||||
* series displayed an empty season with series list/request modal ([#3147](https://github.com/fallenbagel/jellyseerr/issues/3147)) ([2179637](https://github.com/fallenbagel/jellyseerr/commit/2179637d437999290eaa4152f6f37c71fc3d8ba3))
|
||||
* tooltip shows properly if not in progress ([#3185](https://github.com/fallenbagel/jellyseerr/issues/3185)) ([6face8c](https://github.com/fallenbagel/jellyseerr/commit/6face8cc4564b978fb98af32659b326d8c5cede8))
|
||||
* **ui:** series first air date sorting ([#3283](https://github.com/fallenbagel/jellyseerr/issues/3283)) ([374c78c](https://github.com/fallenbagel/jellyseerr/commit/374c78c989cc86bb144a954a91d5d183c4b591c0))
|
||||
* update StatusBadgeMini to shrink on title cards (and remove ring) ([#3210](https://github.com/fallenbagel/jellyseerr/issues/3210)) ([042a1a9](https://github.com/fallenbagel/jellyseerr/commit/042a1a950fdd4d4a61edf4bc19657f9b7a526da8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add discover customization ([#3182](https://github.com/fallenbagel/jellyseerr/issues/3182)) ([cd35748](https://github.com/fallenbagel/jellyseerr/commit/cd3574851a12517cbfadc109e6412a7a9e44c114))
|
||||
* add keywords to movie/series detail pages ([#3204](https://github.com/fallenbagel/jellyseerr/issues/3204)) ([e084649](https://github.com/fallenbagel/jellyseerr/commit/e084649878a58c296786141d12dd69a69a27ee85))
|
||||
* add streaming services filter ([#3247](https://github.com/fallenbagel/jellyseerr/issues/3247)) ([1154156](https://github.com/fallenbagel/jellyseerr/commit/1154156459403494e8daf0c89a3ba356aeea1d97))
|
||||
* discover inline customization ([#3220](https://github.com/fallenbagel/jellyseerr/issues/3220)) ([8bd10b5](https://github.com/fallenbagel/jellyseerr/commit/8bd10b5bf3d1b8069872b616c7c8596caeb4937e))
|
||||
* discover overhaul (filters!) ([#3232](https://github.com/fallenbagel/jellyseerr/issues/3232)) ([dd00e48](https://github.com/fallenbagel/jellyseerr/commit/dd00e48f59054b44bef6b32a2c169e59f6175051))
|
||||
* discover slider edit arrow buttons for reordering ([#3259](https://github.com/fallenbagel/jellyseerr/issues/3259)) ([da00d45](https://github.com/fallenbagel/jellyseerr/commit/da00d454e17e8b00d04f6e26f6dd5153ed6ced81))
|
||||
* **lang:** translations update from Hosted Weblate ([#3030](https://github.com/fallenbagel/jellyseerr/issues/3030)) ([0d8b390](https://github.com/fallenbagel/jellyseerr/commit/0d8b390b678731e76bd1f0f8a0a4952c11e77f4d))
|
||||
* new mobile menu ([#3251](https://github.com/fallenbagel/jellyseerr/issues/3251)) ([fcbca17](https://github.com/fallenbagel/jellyseerr/commit/fcbca1722f31f32633a57bc5048f46c9da057d87))
|
||||
* translations update from Hosted Weblate ([#3218](https://github.com/fallenbagel/jellyseerr/issues/3218)) ([5940ff7](https://github.com/fallenbagel/jellyseerr/commit/5940ff7f5f62eed9ac5aa6f02803418aaa09813a))
|
||||
* **ui:** add episode number to front of episode name in season details ([#3086](https://github.com/fallenbagel/jellyseerr/issues/3086)) ([a672b32](https://github.com/fallenbagel/jellyseerr/commit/a672b324ec391a20f6f3a1daed82a8d276a52c2c))
|
||||
* **ui:** request card progress bar ([#3123](https://github.com/fallenbagel/jellyseerr/issues/3123)) ([03853a1](https://github.com/fallenbagel/jellyseerr/commit/03853a1b9155c8a2153c8885022a74619af1bc15))
|
||||
|
||||
# [1.3.0](https://github.com/fallenbagel/jellyseerr/compare/v1.2.1...v1.3.0) (2023-01-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- 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-98-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
|
||||
|
||||
@@ -143,7 +141,3 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
|
||||
## Contributing
|
||||
|
||||
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Contributors ✨
|
||||
|
||||
Thanks goes to all wonderful people who contributed directly to Jellyseerr and Overseerr.
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('Discover', () => {
|
||||
|
||||
cy.wait('@getWatchlist');
|
||||
|
||||
const sliderHeader = cy.contains('.slider-header', 'Watchlist');
|
||||
const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist');
|
||||
|
||||
sliderHeader.scrollIntoView();
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('Discover', () => {
|
||||
.find('[data-testid=title-card-title]')
|
||||
.invoke('text')
|
||||
.then((text) => {
|
||||
cy.contains('.slider-header', 'Watchlist')
|
||||
cy.contains('.slider-header', 'Plex Watchlist')
|
||||
.next('[data-testid=media-slider]')
|
||||
.find('[data-testid=title-card]')
|
||||
.first()
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
|
||||
url: '/api/v1/*',
|
||||
}).as('apiCall');
|
||||
|
||||
cy.get('.searchbar').swipe('bottom', [190, 500]);
|
||||
cy.get('.searchbar').swipe('bottom', [190, 400]);
|
||||
|
||||
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('christmas{enter}', { delay: 100 });
|
||||
cy.get('#data').type('time travel{enter}', { delay: 100 });
|
||||
|
||||
// Confirming we have some results
|
||||
cy.contains('.slider-header', sliderTitle)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
version: '3'
|
||||
services:
|
||||
jellyseerr:
|
||||
overseerr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
|
||||
@@ -23,6 +23,5 @@ module.exports = {
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
largePageDataBytes: 256000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,8 +36,6 @@ tags:
|
||||
description: Endpoints related to retrieving collection details.
|
||||
- name: service
|
||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||
- name: watchlist
|
||||
description: Collection of media to watch later
|
||||
servers:
|
||||
- url: '{server}/api/v1'
|
||||
variables:
|
||||
@@ -46,34 +44,6 @@ servers:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Watchlist:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
readOnly: true
|
||||
tmdbId:
|
||||
type: number
|
||||
example: 1
|
||||
ratingKey:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
media:
|
||||
$ref: '#/components/schemas/MediaInfo'
|
||||
createdAt:
|
||||
type: string
|
||||
example: '2020-09-12T10:00:27.000Z'
|
||||
readOnly: true
|
||||
updatedAt:
|
||||
type: string
|
||||
example: '2020-09-12T10:00:27.000Z'
|
||||
readOnly: true
|
||||
requestedBy:
|
||||
$ref: '#/components/schemas/User'
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3898,7 +3868,7 @@ paths:
|
||||
$ref: '#/components/schemas/User'
|
||||
/user/{userId}/requests:
|
||||
get:
|
||||
summary: Get requests for a specific user
|
||||
summary: Get user by ID
|
||||
description: |
|
||||
Retrieves a user's requests in a JSON object.
|
||||
tags:
|
||||
@@ -3992,49 +3962,13 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
example: false
|
||||
/watchlist:
|
||||
post:
|
||||
summary: Add media to watchlist
|
||||
tags:
|
||||
- watchlist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Watchlist'
|
||||
responses:
|
||||
'200':
|
||||
description: Watchlist data returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Watchlist'
|
||||
/watchlist/{tmdbId}:
|
||||
delete:
|
||||
summary: Delete watchlist item
|
||||
description: Removes a watchlist item.
|
||||
tags:
|
||||
- watchlist
|
||||
parameters:
|
||||
- in: path
|
||||
name: tmdbId
|
||||
description: tmdbId ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed watchlist item
|
||||
/user/{userId}/watchlist:
|
||||
get:
|
||||
summary: Get the Plex watchlist for a specific user
|
||||
summary: Get user by ID
|
||||
description: |
|
||||
Retrieves a user's Plex Watchlist in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
- watchlist
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
@@ -4505,16 +4439,6 @@ 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:
|
||||
@@ -4794,16 +4718,6 @@ 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:
|
||||
@@ -5657,63 +5571,6 @@ 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
|
||||
@@ -6019,23 +5876,6 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/file:
|
||||
delete:
|
||||
summary: Delete media file
|
||||
description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action.
|
||||
tags:
|
||||
- media
|
||||
parameters:
|
||||
- in: path
|
||||
name: mediaId
|
||||
description: Media ID
|
||||
required: true
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/{status}:
|
||||
post:
|
||||
summary: Update media status
|
||||
|
||||
98
package.json
98
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellyseerr",
|
||||
"version": "0.1.0",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -8,7 +8,6 @@
|
||||
"build:next": "next build",
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
@@ -30,17 +29,17 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
"@formatjs/intl-displaynames": "6.2.3",
|
||||
"@formatjs/intl-locale": "3.0.11",
|
||||
"@formatjs/intl-pluralrules": "5.1.8",
|
||||
"@formatjs/intl-utils": "3.8.4",
|
||||
"@headlessui/react": "1.7.12",
|
||||
"@heroicons/react": "2.0.16",
|
||||
"@headlessui/react": "1.7.7",
|
||||
"@heroicons/react": "2.0.13",
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.3.4",
|
||||
"@tanem/react-nprogress": "5.0.22",
|
||||
"ace-builds": "1.14.0",
|
||||
"axios": "1.2.2",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
@@ -48,7 +47,7 @@
|
||||
"cookie-parser": "1.4.6",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.23.0",
|
||||
"cronstrue": "2.21.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
@@ -65,22 +64,23 @@
|
||||
"next": "12.3.4",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"openpgp": "5.7.0",
|
||||
"node-schedule": "2.1.0",
|
||||
"nodemailer": "6.8.0",
|
||||
"openpgp": "5.5.0",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.2",
|
||||
"pulltorefreshjs": "0.1.22",
|
||||
"react": "18.2.0",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-aria": "3.23.0",
|
||||
"react-aria": "3.22.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-intersection-observer": "9.4.3",
|
||||
"react-intl": "6.2.10",
|
||||
"react-markdown": "8.0.5",
|
||||
"react-intersection-observer": "9.4.1",
|
||||
"react-intl": "6.2.5",
|
||||
"react-markdown": "8.0.4",
|
||||
"react-popper-tooltip": "4.4.2",
|
||||
"react-select": "5.7.0",
|
||||
"react-spring": "9.7.1",
|
||||
"react-spring": "9.6.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
@@ -89,41 +89,42 @@
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.8",
|
||||
"sqlite3": "5.1.4",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.0.4",
|
||||
"typeorm": "0.3.12",
|
||||
"swagger-ui-express": "4.6.0",
|
||||
"swr": "2.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"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.6"
|
||||
"zod": "3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.21.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@babel/cli": "7.20.7",
|
||||
"@commitlint/cli": "17.4.0",
|
||||
"@commitlint/config-conventional": "17.4.0",
|
||||
"@semantic-release/changelog": "6.0.2",
|
||||
"@semantic-release/commit-analyzer": "9.0.2",
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"@tailwindcss/typography": "0.5.8",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
"@types/csurf": "1.11.2",
|
||||
"@types/email-templates": "8.0.4",
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/express": "4.17.15",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/pulltorefreshjs": "0.1.5",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react-transition-group": "4.4.5",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.3.13",
|
||||
@@ -132,46 +133,45 @@
|
||||
"@types/xml2js": "0.4.11",
|
||||
"@types/yamljs": "0.2.31",
|
||||
"@types/yup": "0.29.14",
|
||||
"@typescript-eslint/eslint-plugin": "5.54.0",
|
||||
"@typescript-eslint/parser": "5.54.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.48.0",
|
||||
"@typescript-eslint/parser": "5.48.0",
|
||||
"autoprefixer": "10.4.13",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"babel-plugin-react-intl-auto": "3.3.0",
|
||||
"commitizen": "4.3.0",
|
||||
"commitizen": "4.2.6",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "12.7.0",
|
||||
"cypress": "12.3.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "12.3.4",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"eslint-plugin-formatjs": "4.9.0",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-formatjs": "4.3.9",
|
||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
||||
"eslint-plugin-no-relative-import-paths": "1.5.2",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react": "7.31.11",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"extract-react-intl-messages": "4.1.1",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"lint-staged": "13.1.0",
|
||||
"nodemon": "2.0.20",
|
||||
"postcss": "8.4.21",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"postcss": "8.4.20",
|
||||
"prettier": "2.8.1",
|
||||
"prettier-plugin-organize-imports": "3.2.1",
|
||||
"prettier-plugin-tailwindcss": "0.2.1",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.2.7",
|
||||
"tailwindcss": "3.2.4",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.2",
|
||||
"typescript": "4.9.5"
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "8.4.1",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/express-session": "1.17.6"
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@@ -171,25 +171,28 @@ class JellyfinAPI {
|
||||
|
||||
public async getLibraries(): Promise<JellyfinLibrary[]> {
|
||||
try {
|
||||
const libraries = await this.axios.get<any>('/Library/VirtualFolders');
|
||||
const account = await this.axios.get<any>(
|
||||
`/Users/${this.userId ?? 'Me'}/Views`
|
||||
);
|
||||
|
||||
const response: JellyfinLibrary[] = libraries.data
|
||||
.filter((Item: any) => {
|
||||
const response: JellyfinLibrary[] = account.data.Items.filter(
|
||||
(Item: any) => {
|
||||
return (
|
||||
Item.Type === 'CollectionFolder' &&
|
||||
Item.CollectionType !== 'music' &&
|
||||
Item.CollectionType !== 'books' &&
|
||||
Item.CollectionType !== 'musicvideos' &&
|
||||
Item.CollectionType !== 'homevideos'
|
||||
);
|
||||
})
|
||||
.map((Item: any) => {
|
||||
return <JellyfinLibrary>{
|
||||
key: Item.ItemId,
|
||||
title: Item.Name,
|
||||
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
});
|
||||
}
|
||||
).map((Item: any) => {
|
||||
return <JellyfinLibrary>{
|
||||
key: Item.Id,
|
||||
title: Item.Name,
|
||||
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
||||
@@ -82,6 +82,21 @@ interface ServerResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface FriendResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
$: {
|
||||
id: string;
|
||||
title: string;
|
||||
username: string;
|
||||
email: string;
|
||||
thumb: string;
|
||||
};
|
||||
Server?: ServerResponse[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
MediaContainer: {
|
||||
User: {
|
||||
@@ -219,6 +234,19 @@ 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();
|
||||
|
||||
@@ -227,9 +255,9 @@ class PlexTvAPI extends ExternalAPI {
|
||||
throw new Error('Plex is not configured!');
|
||||
}
|
||||
|
||||
const usersResponse = await this.getUsers();
|
||||
const friends = await this.getFriends();
|
||||
|
||||
const users = usersResponse.MediaContainer.User;
|
||||
const users = friends.MediaContainer.User;
|
||||
|
||||
const user = users.find((u) => parseInt(u.$.id) === userId);
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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,7 +0,0 @@
|
||||
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
|
||||
import { type RTRating } from '@server/api/rating/rottentomatoes';
|
||||
|
||||
export interface RatingResponse {
|
||||
rt?: RTRating;
|
||||
imdb?: IMDBRating;
|
||||
}
|
||||
@@ -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: number;
|
||||
releaseYear: string;
|
||||
rating: string;
|
||||
genres: string[];
|
||||
updateDate: string;
|
||||
@@ -111,19 +111,22 @@ class RottenTomatoes extends ExternalAPI {
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year && movie.title === name
|
||||
(movie) => movie.releaseYear === year.toString() && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||
(movie) =>
|
||||
movie.releaseYear === year.toString() && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
@@ -144,9 +147,6 @@ 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
|
||||
(series) => series.releaseYear === year.toString()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,9 +195,6 @@ 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) {
|
||||
@@ -213,20 +213,6 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
);
|
||||
}
|
||||
}
|
||||
public removeMovie = async (movieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||
await this.axios.delete(`/movie/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
});
|
||||
logger.info(`[Radarr] Removed movie ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default RadarrAPI;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logger from '@server/logger';
|
||||
import ServarrBase from './base';
|
||||
|
||||
export interface SonarrSeason {
|
||||
interface SonarrSeason {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: {
|
||||
@@ -76,15 +76,6 @@ 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 {
|
||||
@@ -125,16 +116,6 @@ 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', {
|
||||
@@ -340,20 +321,6 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
public removeSerie = async (serieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||
await this.axios.delete(`/series/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
});
|
||||
logger.info(`[Radarr] Removed serie ${title}`);
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default SonarrAPI;
|
||||
|
||||
@@ -65,8 +65,6 @@ interface DiscoverMovieOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: string;
|
||||
studio?: string;
|
||||
@@ -85,8 +83,6 @@ interface DiscoverTvOptions {
|
||||
withRuntimeLte?: string;
|
||||
voteAverageGte?: string;
|
||||
voteAverageLte?: string;
|
||||
voteCountGte?: string;
|
||||
voteCountLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: string;
|
||||
@@ -464,8 +460,6 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
@@ -510,8 +504,6 @@ 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,
|
||||
},
|
||||
@@ -538,8 +530,6 @@ class TheMovieDb extends ExternalAPI {
|
||||
withRuntimeLte,
|
||||
voteAverageGte,
|
||||
voteAverageLte,
|
||||
voteCountGte,
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
@@ -584,8 +574,6 @@ 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,18 +28,6 @@ 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;
|
||||
@@ -57,12 +45,7 @@ interface TmdbPaginatedResponse {
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[];
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
|
||||
|
||||
@@ -20,8 +20,6 @@ export enum DiscoverSliderType {
|
||||
TMDB_SEARCH,
|
||||
TMDB_STUDIO,
|
||||
TMDB_NETWORK,
|
||||
TMDB_MOVIE_STREAMING_SERVICES,
|
||||
TMDB_TV_STREAMING_SERVICES,
|
||||
}
|
||||
|
||||
export const defaultSliders: Partial<DiscoverSlider>[] = [
|
||||
|
||||
@@ -3,8 +3,6 @@ import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import type { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import downloadTracker from '@server/lib/downloadtracker';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
In,
|
||||
Index,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -26,7 +25,6 @@ import Season from './Season';
|
||||
@Entity()
|
||||
class Media {
|
||||
public static async getRelatedMedia(
|
||||
user: User | undefined,
|
||||
tmdbIds: number | number[]
|
||||
): Promise<Media[]> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
@@ -39,16 +37,9 @@ class Media {
|
||||
finalIds = tmdbIds;
|
||||
}
|
||||
|
||||
const media = await mediaRepository
|
||||
.createQueryBuilder('media')
|
||||
.leftJoinAndSelect(
|
||||
'media.watchlists',
|
||||
'watchlist',
|
||||
'media.id= watchlist.media and watchlist.requestedBy = :userId',
|
||||
{ userId: user?.id }
|
||||
) //,
|
||||
.where(' media.tmdbId in (:...finalIds)', { finalIds })
|
||||
.getMany();
|
||||
const media = await mediaRepository.find({
|
||||
where: { tmdbId: In(finalIds) },
|
||||
});
|
||||
|
||||
return media;
|
||||
} catch (e) {
|
||||
@@ -103,9 +94,6 @@ class Media {
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||
public watchlists: null | Watchlist[];
|
||||
|
||||
@OneToMany(() => Season, (season) => season.media, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
@@ -127,29 +115,29 @@ class Media {
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
public mediaAddedAt: Date;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId?: number | null;
|
||||
@Column({ nullable: true })
|
||||
public serviceId?: number;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public serviceId4k?: number | null;
|
||||
@Column({ nullable: true })
|
||||
public serviceId4k?: number;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId?: number | null;
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId?: number;
|
||||
|
||||
@Column({ nullable: true, type: 'int' })
|
||||
public externalServiceId4k?: number | null;
|
||||
@Column({ nullable: true })
|
||||
public externalServiceId4k?: number;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug?: string | null;
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug?: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public externalServiceSlug4k?: string | null;
|
||||
@Column({ nullable: true })
|
||||
public externalServiceSlug4k?: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey?: string | null;
|
||||
@Column({ nullable: true })
|
||||
public ratingKey?: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public ratingKey4k?: string | null;
|
||||
@Column({ nullable: true })
|
||||
public ratingKey4k?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinMediaId?: string;
|
||||
@@ -300,9 +288,7 @@ class Media {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
this.serviceId !== undefined
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getMovieProgress(
|
||||
this.serviceId,
|
||||
@@ -312,9 +298,7 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
this.serviceId4k !== undefined
|
||||
) {
|
||||
this.downloadStatus4k = downloadTracker.getMovieProgress(
|
||||
this.serviceId4k,
|
||||
@@ -326,9 +310,7 @@ class Media {
|
||||
if (this.mediaType === MediaType.TV) {
|
||||
if (
|
||||
this.externalServiceId !== undefined &&
|
||||
this.externalServiceId !== null &&
|
||||
this.serviceId !== undefined &&
|
||||
this.serviceId !== null
|
||||
this.serviceId !== undefined
|
||||
) {
|
||||
this.downloadStatus = downloadTracker.getSeriesProgress(
|
||||
this.serviceId,
|
||||
@@ -338,9 +320,7 @@ class Media {
|
||||
|
||||
if (
|
||||
this.externalServiceId4k !== undefined &&
|
||||
this.externalServiceId4k !== null &&
|
||||
this.serviceId4k !== undefined &&
|
||||
this.serviceId4k !== null
|
||||
this.serviceId4k !== undefined
|
||||
) {
|
||||
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 ? [...radarrSettings.tags] : [];
|
||||
let tags = radarrSettings.tags;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -764,38 +764,6 @@ 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
|
||||
) {
|
||||
@@ -984,7 +952,7 @@ export class MediaRequest {
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
) {
|
||||
seriesType = sonarrSettings.seriesType;
|
||||
seriesType = 'anime';
|
||||
}
|
||||
|
||||
let rootFolder =
|
||||
@@ -1002,11 +970,7 @@ export class MediaRequest {
|
||||
let tags =
|
||||
seriesType === 'anime'
|
||||
? sonarrSettings.animeTags
|
||||
? [...sonarrSettings.animeTags]
|
||||
: []
|
||||
: sonarrSettings.tags
|
||||
? [...sonarrSettings.tags]
|
||||
: [];
|
||||
: sonarrSettings.tags;
|
||||
|
||||
if (
|
||||
this.rootFolder &&
|
||||
@@ -1058,38 +1022,6 @@ 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,
|
||||
@@ -1255,5 +1187,3 @@ export class MediaRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaRequest;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import {
|
||||
AfterRemove,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
@@ -36,18 +34,6 @@ class SeasonRequest {
|
||||
constructor(init?: Partial<SeasonRequest>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@AfterRemove()
|
||||
public async handleRemoveParent(): Promise<void> {
|
||||
const mediaRequestRepository = getRepository(MediaRequest);
|
||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
||||
where: { id: this.request.id },
|
||||
});
|
||||
|
||||
if (requestToBeDeleted.seasons.length === 0) {
|
||||
await mediaRequestRepository.delete({ id: this.request.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SeasonRequest;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
@@ -104,9 +103,6 @@ export class User {
|
||||
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy)
|
||||
public watchlists: Watchlist[];
|
||||
|
||||
@Column({ nullable: true })
|
||||
public movieQuotaLimit?: number;
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import logger from '@server/logger';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||
|
||||
export class DuplicateWatchlistRequestError extends Error {}
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message = 'Not found') {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
@Entity()
|
||||
@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy'])
|
||||
export class Watchlist implements WatchlistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public ratingKey = '';
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
title = '';
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Watchlist>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public static async createWatchlist({
|
||||
watchlistRequest,
|
||||
user,
|
||||
}: {
|
||||
watchlistRequest: {
|
||||
mediaType: MediaType;
|
||||
ratingKey?: ZodOptional<ZodString>['_output'];
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
};
|
||||
user: User;
|
||||
}): Promise<Watchlist> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
const mediaRepository = getRepository(Media);
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdbMedia =
|
||||
watchlistRequest.mediaType === MediaType.MOVIE
|
||||
? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId })
|
||||
: await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId });
|
||||
|
||||
const existing = await watchlistRepository
|
||||
.createQueryBuilder('watchlist')
|
||||
.leftJoinAndSelect('watchlist.requestedBy', 'user')
|
||||
.where('user.id = :userId', { userId: user.id })
|
||||
.andWhere('watchlist.tmdbId = :tmdbId', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
})
|
||||
.andWhere('watchlist.mediaType = :mediaType', {
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
logger.warn('Duplicate request for watchlist blocked', {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
|
||||
throw new DuplicateWatchlistRequestError();
|
||||
}
|
||||
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: watchlistRequest.tmdbId,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
},
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: tmdbMedia.id,
|
||||
tvdbId: tmdbMedia.external_ids.tvdb_id,
|
||||
mediaType: watchlistRequest.mediaType,
|
||||
});
|
||||
}
|
||||
|
||||
const watchlist = new this({
|
||||
...watchlistRequest,
|
||||
requestedBy: user,
|
||||
media,
|
||||
});
|
||||
|
||||
await mediaRepository.save(media);
|
||||
await watchlistRepository.save(watchlist);
|
||||
return watchlist;
|
||||
}
|
||||
|
||||
public static async deleteWatchlist(
|
||||
tmdbId: Watchlist['tmdbId'],
|
||||
user: User
|
||||
): Promise<Watchlist | null> {
|
||||
const watchlistRepository = getRepository(this);
|
||||
const watchlist = await watchlistRepository.findOneBy({
|
||||
tmdbId,
|
||||
requestedBy: { id: user.id },
|
||||
});
|
||||
if (!watchlist) {
|
||||
throw new NotFoundError('not Found');
|
||||
}
|
||||
|
||||
if (watchlist) {
|
||||
await watchlistRepository.delete(watchlist.id);
|
||||
}
|
||||
|
||||
return watchlist;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ 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';
|
||||
@@ -193,8 +192,7 @@ app
|
||||
});
|
||||
server.use('/api/v1', routes);
|
||||
|
||||
// Do not set cookies so CDNs can cache them
|
||||
server.use('/imageproxy', clearCookies, imageproxy);
|
||||
server.use('/imageproxy', imageproxy);
|
||||
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const watchlistCreate = z.object({
|
||||
ratingKey: z.coerce.string().optional(),
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
});
|
||||
@@ -278,11 +278,11 @@ class JobJellyfinSync {
|
||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
total4k += episodeCount;
|
||||
} else {
|
||||
if (MediaStream.Width ?? 0 < 2000) {
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
} else {
|
||||
total4k += episodeCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -311,15 +311,13 @@ class JobJellyfinSync {
|
||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||
// and then not modifying the status if there are 0 items
|
||||
existingSeason.status =
|
||||
totalStandard >= season.episode_count ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
(this.enable4kShow && total4k >= season.episode_count) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
this.enable4kShow && total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
@@ -331,13 +329,13 @@ class JobJellyfinSync {
|
||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||
// if we dont have any items for the season
|
||||
status:
|
||||
totalStandard >= season.episode_count
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow && total4k >= season.episode_count
|
||||
this.enable4kShow && total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { JobId } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import watchlistSync from '@server/lib/watchlistsync';
|
||||
import logger from '@server/logger';
|
||||
import random from 'lodash/random';
|
||||
import schedule from 'node-schedule';
|
||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
||||
|
||||
@@ -17,7 +16,7 @@ interface ScheduledJob {
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
type: 'process' | 'command';
|
||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
||||
interval: 'short' | 'long' | 'fixed';
|
||||
cronSchedule: string;
|
||||
running?: () => boolean;
|
||||
cancelFn?: () => void;
|
||||
@@ -35,7 +34,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
interval: 'minutes',
|
||||
interval: 'short',
|
||||
cronSchedule: jobs['plex-recently-added-scan'].schedule,
|
||||
job: schedule.scheduleJob(
|
||||
jobs['plex-recently-added-scan'].schedule,
|
||||
@@ -55,7 +54,7 @@ export const startJobs = (): void => {
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['plex-full-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
@@ -72,13 +71,13 @@ export const startJobs = (): void => {
|
||||
) {
|
||||
// Run recently added jellyfin sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'jellyfin-recently-added-scan',
|
||||
id: 'jellyfin-recently-added-sync',
|
||||
name: 'Jellyfin Recently Added Sync',
|
||||
type: 'process',
|
||||
interval: 'minutes',
|
||||
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['jellyfin-recently-added-sync'].schedule,
|
||||
job: schedule.scheduleJob(
|
||||
jobs['jellyfin-recently-added-scan'].schedule,
|
||||
jobs['jellyfin-recently-added-sync'].schedule,
|
||||
() => {
|
||||
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
|
||||
label: 'Jobs',
|
||||
@@ -92,12 +91,12 @@ export const startJobs = (): void => {
|
||||
|
||||
// Run full jellyfin sync every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'jellyfin-full-scan',
|
||||
id: 'jellyfin-full-sync',
|
||||
name: 'Jellyfin Full Library Sync',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
cronSchedule: jobs['jellyfin-full-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['jellyfin-full-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
@@ -108,37 +107,27 @@ export const startJobs = (): void => {
|
||||
});
|
||||
}
|
||||
|
||||
// Watchlist Sync
|
||||
const watchlistSyncJob: ScheduledJob = {
|
||||
// Run watchlist sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'fixed',
|
||||
interval: 'short',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
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: 'hours',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['radarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
@@ -153,7 +142,7 @@ export const startJobs = (): void => {
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['sonarr-scan'].schedule,
|
||||
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
@@ -163,30 +152,12 @@ 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: 'seconds',
|
||||
interval: 'fixed',
|
||||
cronSchedule: jobs['download-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
|
||||
logger.debug('Starting scheduled job: Download Sync', {
|
||||
@@ -201,7 +172,7 @@ export const startJobs = (): void => {
|
||||
id: 'download-sync-reset',
|
||||
name: 'Download Sync Reset',
|
||||
type: 'command',
|
||||
interval: 'hours',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['download-sync-reset'].schedule,
|
||||
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Download Sync Reset', {
|
||||
@@ -211,12 +182,12 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Run image cache cleanup every 24 hours
|
||||
// Run image cache cleanup every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'image-cache-cleanup',
|
||||
name: 'Image Cache Cleanup',
|
||||
type: 'process',
|
||||
interval: 'hours',
|
||||
interval: 'long',
|
||||
cronSchedule: jobs['image-cache-cleanup'].schedule,
|
||||
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Image Cache Cleanup', {
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
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,7 +5,6 @@ export type AvailableCacheIds =
|
||||
| 'radarr'
|
||||
| 'sonarr'
|
||||
| 'rt'
|
||||
| 'imdb'
|
||||
| 'github'
|
||||
| 'plexguid'
|
||||
| 'plextv';
|
||||
@@ -52,10 +51,6 @@ 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(baseCacheDirectory, key);
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
@@ -57,7 +57,11 @@ class ImageProxy {
|
||||
public static async getImageStats(
|
||||
key: string
|
||||
): Promise<{ size: number; imageCount: number }> {
|
||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||
const cacheDirectory = path.join(
|
||||
__dirname,
|
||||
'../../config/cache/images/',
|
||||
key
|
||||
);
|
||||
|
||||
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
|
||||
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
|
||||
@@ -259,7 +263,7 @@ class ImageProxy {
|
||||
}
|
||||
|
||||
private getCacheDirectory() {
|
||||
return path.join(baseCacheDirectory, this.key);
|
||||
return path.join(__dirname, '../../config/cache/images/', this.key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface DVRSettings {
|
||||
externalUrl?: string;
|
||||
syncEnabled: boolean;
|
||||
preventSearch: boolean;
|
||||
tagRequests: boolean;
|
||||
}
|
||||
|
||||
export interface RadarrSettings extends DVRSettings {
|
||||
@@ -82,7 +81,6 @@ export interface SonarrSettings extends DVRSettings {
|
||||
activeAnimeDirectory?: string;
|
||||
activeAnimeLanguageProfileId?: number;
|
||||
activeLanguageProfileId?: number;
|
||||
seriesType: 'standard' | 'daily' | 'anime';
|
||||
animeTags?: number[];
|
||||
enableSeasonFolders: boolean;
|
||||
}
|
||||
@@ -264,10 +262,9 @@ export type JobId =
|
||||
| 'sonarr-scan'
|
||||
| 'download-sync'
|
||||
| 'download-sync-reset'
|
||||
| 'jellyfin-recently-added-scan'
|
||||
| 'jellyfin-full-scan'
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync';
|
||||
| 'jellyfin-recently-added-sync'
|
||||
| 'jellyfin-full-sync'
|
||||
| 'image-cache-cleanup';
|
||||
|
||||
interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -405,7 +402,7 @@ class Settings {
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
jsonPayload:
|
||||
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
|
||||
},
|
||||
},
|
||||
webpush: {
|
||||
@@ -438,19 +435,16 @@ class Settings {
|
||||
'sonarr-scan': {
|
||||
schedule: '0 30 4 * * *',
|
||||
},
|
||||
'availability-sync': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'download-sync': {
|
||||
schedule: '0 * * * * *',
|
||||
},
|
||||
'download-sync-reset': {
|
||||
schedule: '0 0 1 * * *',
|
||||
},
|
||||
'jellyfin-recently-added-scan': {
|
||||
'jellyfin-recently-added-sync': {
|
||||
schedule: '0 */5 * * * *',
|
||||
},
|
||||
'jellyfin-full-scan': {
|
||||
'jellyfin-full-sync': {
|
||||
schedule: '0 0 3 * * *',
|
||||
},
|
||||
'image-cache-cleanup': {
|
||||
@@ -596,7 +590,7 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
|
||||
}
|
||||
|
||||
private generateVapidKeys(force = false): void {
|
||||
|
||||
@@ -65,7 +65,6 @@ class WatchlistSync {
|
||||
const response = await plexTvApi.getWatchlist({ size: 200 });
|
||||
|
||||
const mediaItems = await Media.getRelatedMedia(
|
||||
user,
|
||||
response.items.map((i) => i.tmdbId)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const clearCookies: Middleware = (_req, res, next) => {
|
||||
res.removeHeader('Set-Cookie');
|
||||
next();
|
||||
};
|
||||
|
||||
export default clearCookies;
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWatchlists1682608634546 implements MigrationInterface {
|
||||
name = 'AddWatchlists1682608634546';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -10,7 +9,7 @@ import type {
|
||||
import { MediaType as MainMediaType } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
|
||||
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
|
||||
export type MediaType = 'tv' | 'movie' | 'person';
|
||||
|
||||
interface SearchResult {
|
||||
id: number;
|
||||
@@ -44,18 +43,6 @@ 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;
|
||||
@@ -66,7 +53,7 @@ export interface PersonResult {
|
||||
knownFor: (MovieResult | TvResult)[];
|
||||
}
|
||||
|
||||
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
|
||||
export type Results = MovieResult | TvResult | PersonResult;
|
||||
|
||||
export const mapMovieResult = (
|
||||
movieResult: TmdbMovieResult,
|
||||
@@ -112,20 +99,6 @@ 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 => ({
|
||||
@@ -145,12 +118,7 @@ export const mapPersonResult = (
|
||||
});
|
||||
|
||||
export const mapSearchResults = (
|
||||
results: (
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
)[],
|
||||
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
|
||||
media?: Media[]
|
||||
): Results[] =>
|
||||
results.map((result) => {
|
||||
@@ -171,8 +139,6 @@ export const mapSearchResults = (
|
||||
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
|
||||
)
|
||||
);
|
||||
case 'collection':
|
||||
return mapCollectionResult(result);
|
||||
default:
|
||||
return mapPersonResult(result);
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
|
||||
export const UserRepository = getRepository(Watchlist).extend({
|
||||
// findByName(firstName: string, lastName: string) {
|
||||
// return this.createQueryBuilder("user")
|
||||
// .where("user.firstName = :firstName", { firstName })
|
||||
// .andWhere("user.lastName = :lastName", { lastName })
|
||||
// .getMany()
|
||||
// },
|
||||
});
|
||||
@@ -380,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
if (e.message === 'Unauthorized') {
|
||||
logger.warn(
|
||||
logger.info(
|
||||
'Failed login attempt from user with incorrect Jellyfin credentials',
|
||||
{
|
||||
label: 'Auth',
|
||||
|
||||
@@ -16,7 +16,6 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
collection.parts.map((part) => part.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type {
|
||||
GenreSliderItem,
|
||||
WatchlistResponse,
|
||||
@@ -15,13 +14,12 @@ 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 { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { isMovie, isPerson } from '@server/utils/typeHelpers';
|
||||
import { Router } from 'express';
|
||||
import { sortBy } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
@@ -66,8 +64,6 @@ 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(),
|
||||
@@ -99,14 +95,11 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -171,7 +164,6 @@ discoverRoutes.get<{ language: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -229,7 +221,6 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -277,7 +268,6 @@ discoverRoutes.get<{ studioId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -327,7 +317,6 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -381,14 +370,11 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
withRuntimeLte: query.withRuntimeLte,
|
||||
voteAverageGte: query.voteAverageGte,
|
||||
voteAverageLte: query.voteAverageLte,
|
||||
voteCountGte: query.voteCountGte,
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -452,7 +438,6 @@ discoverRoutes.get<{ language: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -510,7 +495,6 @@ discoverRoutes.get<{ genreId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -558,7 +542,6 @@ discoverRoutes.get<{ networkId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -608,7 +591,6 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -647,7 +629,6 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -666,8 +647,6 @@ discoverRoutes.get('/trending', async (req, res, next) => {
|
||||
)
|
||||
: isPerson(result)
|
||||
? mapPersonResult(result)
|
||||
: isCollection(result)
|
||||
? mapCollectionResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
@@ -702,7 +681,6 @@ discoverRoutes.get<{ keywordId: string }>(
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -822,12 +800,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
|
||||
'/watchlist',
|
||||
async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
const itemsPerPage = 20;
|
||||
const page = Number(req.query.page) ?? 1;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const activeUser = await userRepository.findOne({
|
||||
@@ -835,25 +813,6 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (activeUser) {
|
||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||
where: { requestedBy: { id: activeUser?.id } },
|
||||
relations: {
|
||||
/*requestedBy: true,media:true*/
|
||||
},
|
||||
// loadRelationIds: true,
|
||||
take: itemsPerPage,
|
||||
skip: offset,
|
||||
});
|
||||
if (total) {
|
||||
return res.json({
|
||||
page: page,
|
||||
totalPages: total / itemsPerPage,
|
||||
totalResults: total,
|
||||
results: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!activeUser?.plexToken) {
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
@@ -870,8 +829,8 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||
totalResults: watchlist.totalSize,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { mapWatchProviderDetails } from '@server/models/common';
|
||||
import { mapProductionCompany } from '@server/models/Movie';
|
||||
import { mapNetwork } from '@server/models/Tv';
|
||||
import settingsRoutes from '@server/routes/settings';
|
||||
import watchlistRoutes from '@server/routes/watchlist';
|
||||
import { appDataPath, appDataStatus } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion, getCommitTag } from '@server/utils/appVersion';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
@@ -117,7 +116,6 @@ router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
|
||||
router.use('/search', isAuthenticated(), searchRoutes);
|
||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||
router.use('/request', isAuthenticated(), requestRoutes);
|
||||
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -171,100 +168,6 @@ mediaRoutes.delete(
|
||||
}
|
||||
);
|
||||
|
||||
mediaRoutes.delete(
|
||||
'/:id/file',
|
||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const mediaRepository = getRepository(Media);
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
const is4k = media.serviceUrl4k !== undefined;
|
||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||
let serviceSettings;
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.isDefault && radarr.is4k === is4k
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.isDefault && sonarr.is4k === is4k
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
media.serviceId &&
|
||||
media.serviceId >= 0 &&
|
||||
serviceSettings?.id !== media.serviceId
|
||||
) {
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === media.serviceId
|
||||
);
|
||||
} else {
|
||||
serviceSettings = settings.sonarr.find(
|
||||
(sonarr) => sonarr.id === media.serviceId
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!serviceSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
}/ server configured. Did you set any of your ${
|
||||
is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr'
|
||||
} servers as default?`,
|
||||
{
|
||||
label: 'Media Request',
|
||||
mediaId: media.id,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
let service;
|
||||
if (isMovie) {
|
||||
service = new RadarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
} else {
|
||||
service = new SonarrAPI({
|
||||
apiKey: serviceSettings?.apiKey,
|
||||
url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMovie) {
|
||||
await (service as RadarrAPI).removeMovie(
|
||||
parseInt(
|
||||
is4k
|
||||
? (media.externalServiceSlug4k as string)
|
||||
: (media.externalServiceSlug as string)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||
if (!tvdbId) {
|
||||
throw new Error('TVDB ID not found');
|
||||
}
|
||||
await (service as SonarrAPI).removeSerie(tvdbId);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching media in delete request', {
|
||||
label: 'Media',
|
||||
message: e.message,
|
||||
});
|
||||
next({ status: 404, message: 'Media not found' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
mediaRoutes.get<{ id: string }, MediaWatchDataResponse>(
|
||||
'/:id/watch_data',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -47,7 +45,6 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -89,7 +86,6 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -120,9 +116,6 @@ 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();
|
||||
@@ -158,53 +151,4 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Endpoint combining RottenTomatoes and IMDB
|
||||
*/
|
||||
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
const rtapi = new RottenTomatoes();
|
||||
const imdbApi = new IMDBRadarrProxy();
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: Number(req.params.id),
|
||||
});
|
||||
|
||||
const rtratings = await rtapi.getMovieRatings(
|
||||
movie.title,
|
||||
Number(movie.release_date.slice(0, 4))
|
||||
);
|
||||
|
||||
let imdbRatings;
|
||||
if (movie.imdb_id) {
|
||||
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
|
||||
}
|
||||
|
||||
if (!rtratings && !imdbRatings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'No ratings found.',
|
||||
});
|
||||
}
|
||||
|
||||
const ratings: RatingResponse = {
|
||||
...(rtratings ? { rt: rtratings } : {}),
|
||||
...(imdbRatings ? { imdb: imdbRatings } : {}),
|
||||
};
|
||||
|
||||
return res.status(200).json(ratings);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie ratings', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
movieId: req.params.id,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve movie ratings.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default movieRoutes;
|
||||
|
||||
@@ -42,12 +42,10 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const castMedia = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
combinedCredits.cast.map((result) => result.id)
|
||||
);
|
||||
|
||||
const crewMedia = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
combinedCredits.crew.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ searchRoutes.get('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -183,7 +183,9 @@ serviceRoutes.get<{ tmdbId: string }>(
|
||||
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -367,27 +367,25 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
|
||||
|
||||
Object.assign(settings.tautulli, req.body);
|
||||
|
||||
if (settings.tautulli.hostname) {
|
||||
try {
|
||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||
try {
|
||||
const tautulliClient = new TautulliAPI(settings.tautulli);
|
||||
|
||||
const result = await tautulliClient.getInfo();
|
||||
const result = await tautulliClient.getInfo();
|
||||
|
||||
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
|
||||
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.',
|
||||
});
|
||||
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.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json(settings.tautulli);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import RottenTomatoes from '@server/api/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -69,7 +69,6 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
@@ -110,7 +109,6 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
req.user,
|
||||
results.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
QuotaResponse,
|
||||
@@ -383,14 +382,7 @@ 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, {
|
||||
/**
|
||||
* 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 requestRepository.remove(user.requests);
|
||||
|
||||
await userRepository.delete(user.id);
|
||||
return res.status(200).json(user.filter());
|
||||
@@ -693,7 +685,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string }, WatchlistResponse>(
|
||||
router.get<{ id: string; page?: number }, WatchlistResponse>(
|
||||
'/:id/watchlist',
|
||||
async (req, res, next) => {
|
||||
if (
|
||||
@@ -707,12 +699,13 @@ router.get<{ id: string }, WatchlistResponse>(
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's Watchlist.",
|
||||
message:
|
||||
"You do not have permission to view this user's Plex Watchlist.",
|
||||
});
|
||||
}
|
||||
|
||||
const itemsPerPage = 20;
|
||||
const page = Number(req.query.page) ?? 1;
|
||||
const page = req.params.page ?? 1;
|
||||
const offset = (page - 1) * itemsPerPage;
|
||||
|
||||
const user = await getRepository(User).findOneOrFail({
|
||||
@@ -721,24 +714,6 @@ router.get<{ id: string }, WatchlistResponse>(
|
||||
});
|
||||
|
||||
if (!user?.plexToken) {
|
||||
if (user) {
|
||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||
where: { requestedBy: { id: user?.id } },
|
||||
relations: { requestedBy: true },
|
||||
// loadRelationIds: true,
|
||||
take: itemsPerPage,
|
||||
skip: offset,
|
||||
});
|
||||
if (total) {
|
||||
return res.json({
|
||||
page: page,
|
||||
totalPages: total / itemsPerPage,
|
||||
totalResults: total,
|
||||
results: result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We will just return an empty array if the user has no Plex token
|
||||
return res.json({
|
||||
page: 1,
|
||||
@@ -754,8 +729,8 @@ router.get<{ id: string }, WatchlistResponse>(
|
||||
|
||||
return res.json({
|
||||
page,
|
||||
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
|
||||
totalResults: watchlist.totalSize,
|
||||
totalPages: Math.ceil(watchlist.size / itemsPerPage),
|
||||
totalResults: watchlist.size,
|
||||
results: watchlist.items.map((item) => ({
|
||||
ratingKey: item.ratingKey,
|
||||
title: item.title,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
DuplicateWatchlistRequestError,
|
||||
NotFoundError,
|
||||
Watchlist,
|
||||
} from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
import { watchlistCreate } from '@server/interfaces/api/watchlistCreate';
|
||||
|
||||
const watchlistRoutes = Router();
|
||||
|
||||
watchlistRoutes.post<never, Watchlist, Watchlist>(
|
||||
'/',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You must be logged in to add watchlist.',
|
||||
});
|
||||
}
|
||||
const values = watchlistCreate.parse(req.body);
|
||||
|
||||
const request = await Watchlist.createWatchlist({
|
||||
watchlistRequest: values,
|
||||
user: req.user,
|
||||
});
|
||||
return res.status(201).json(request);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
switch (error.constructor) {
|
||||
case QueryFailedError:
|
||||
logger.warn('Something wrong with data watchlist', {
|
||||
tmdbId: req.body.tmdbId,
|
||||
mediaType: req.body.mediaType,
|
||||
label: 'Watchlist',
|
||||
});
|
||||
return next({ status: 409, message: 'Something wrong' });
|
||||
case DuplicateWatchlistRequestError:
|
||||
return next({ status: 409, message: error.message });
|
||||
default:
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watchlistRoutes.delete('/:tmdbId', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'You must be logged in to delete watchlist data.',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user);
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default watchlistRoutes;
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
TmdbCollectionResult,
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonDetails,
|
||||
@@ -9,35 +8,17 @@ import type {
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
|
||||
export const isMovie = (
|
||||
movie:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
): movie is TmdbMovieResult => {
|
||||
return (movie as TmdbMovieResult).title !== undefined;
|
||||
};
|
||||
|
||||
export const isPerson = (
|
||||
person:
|
||||
| TmdbMovieResult
|
||||
| TmdbTvResult
|
||||
| TmdbPersonResult
|
||||
| TmdbCollectionResult
|
||||
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
|
||||
): 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,7 +37,7 @@ parts:
|
||||
override-pull: |
|
||||
snapcraftctl pull
|
||||
# Get information to determine snap grade and version
|
||||
git config --global --add safe.directory /data/parts/jellyseerr/src
|
||||
git config --global --add safe.directory /data/parts/jellyyseerr/src
|
||||
#setup yarn.rc
|
||||
echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
viewBox="0 0 712.60077 712.5481"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<rect
|
||||
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||
id="rect249"
|
||||
width="712.60077"
|
||||
height="712.5481"
|
||||
x="-0.00071160076"
|
||||
y="2.0223413e-11" />
|
||||
<rect
|
||||
style="fill:#ffffff"
|
||||
id="rect289"
|
||||
width="230.18982"
|
||||
height="229.82355"
|
||||
x="241.20476"
|
||||
y="241.36227" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||
<path
|
||||
id="path3427"
|
||||
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
|
||||
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -40,19 +39,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||
const [downloadStatus, downloadStatus4k] = [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
|
||||
return { downloadStatus, downloadStatus4k };
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
@@ -60,19 +46,21 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
|
||||
fallbackData: collection,
|
||||
revalidateOnMount: true,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
returnCollectionDownloadItems(collection),
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const { data: genres } =
|
||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||
|
||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||
const downloadItems = returnCollectionDownloadItems(data);
|
||||
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
||||
}, [data]);
|
||||
return [
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
|
||||
),
|
||||
data?.parts.flatMap((item) =>
|
||||
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
|
||||
),
|
||||
];
|
||||
}, [data?.parts]);
|
||||
|
||||
const [titles, titles4k] = useMemo(() => {
|
||||
return [
|
||||
@@ -338,7 +326,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
<TitleCard
|
||||
key={`collection-movie-${title.id}`}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -349,7 +336,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="extra-bottom-space relative" />
|
||||
<div className="pb-8" />
|
||||
</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 hover:bg-opacity-100');
|
||||
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isOpen}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -13,7 +12,7 @@ import type {
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
type ListViewProps = {
|
||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||
items?: (TvResult | MovieResult | PersonResult)[];
|
||||
plexItems?: WatchlistItem[];
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -58,9 +57,7 @@ const ListView = ({
|
||||
case 'movie':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -78,9 +75,7 @@ const ListView = ({
|
||||
case 'tv':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -95,18 +90,6 @@ 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 duration-300"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 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 duration-300"
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 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 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 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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
}}
|
||||
appear
|
||||
as="div"
|
||||
enter="transition duration-300"
|
||||
enter="transition opacity-0 duration-300 transform scale-75"
|
||||
enterFrom="opacity-0 scale-75"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 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 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 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`}
|
||||
></span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -37,10 +37,10 @@ const SlideOver = ({
|
||||
as={Fragment}
|
||||
show={show}
|
||||
appear
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enter="opacity-0 transition ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leave="opacity-100 transition 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="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform ease-in-out duration-500 sm:duration-700"
|
||||
leave="transform transition 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-3"
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
||||
ref={slideoverRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -56,7 +55,7 @@ type CreateOption = {
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText?: string;
|
||||
dataPlaceholderText: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
@@ -277,20 +276,6 @@ 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 (
|
||||
@@ -432,40 +417,6 @@ 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
|
||||
@@ -537,25 +488,10 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
'$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)
|
||||
)
|
||||
}
|
||||
extraParams={activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
onNewTitles={updateResultCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -164,10 +164,6 @@ 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';
|
||||
}
|
||||
@@ -199,9 +195,7 @@ const DiscoverSliderEdit = ({
|
||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
<div className="w-7/12 truncate md:w-full">
|
||||
{getSliderTitle(slider)}
|
||||
</div>
|
||||
<div>{getSliderTitle(slider)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discoverwatchlist: 'Your Watchlist',
|
||||
discoverwatchlist: 'Your Plex Watchlist',
|
||||
watchlist: 'Plex Watchlist',
|
||||
});
|
||||
|
||||
|
||||
@@ -35,10 +35,8 @@ 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 = {
|
||||
@@ -248,45 +246,6 @@ 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Slider from '@app/components/Slider';
|
||||
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { UserType, useUser } from '@app/hooks/useUser';
|
||||
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import Link from 'next/link';
|
||||
@@ -8,7 +8,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages({
|
||||
plexwatchlist: 'Your Watchlist',
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
emptywatchlist:
|
||||
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
||||
});
|
||||
@@ -22,11 +22,12 @@ const PlexWatchlistSlider = () => {
|
||||
totalPages: number;
|
||||
totalResults: number;
|
||||
results: WatchlistItem[];
|
||||
}>('/api/v1/discover/watchlist', {
|
||||
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
|
||||
revalidateOnMount: true,
|
||||
});
|
||||
|
||||
if (
|
||||
user?.userType !== UserType.PLEX ||
|
||||
(watchlistItems &&
|
||||
watchlistItems.results.length === 0 &&
|
||||
!user?.settings?.watchlistSyncMovies &&
|
||||
@@ -68,7 +69,6 @@ const PlexWatchlistSlider = () => {
|
||||
key={`watchlist-slider-item-${item.ratingKey}`}
|
||||
tmdbId={item.tmdbId}
|
||||
type={item.mediaType}
|
||||
isAddedToWatchlist={true}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
|
||||
@@ -74,7 +74,7 @@ export const sliderTitles = defineMessages({
|
||||
recentlyAdded: 'Recently Added',
|
||||
upcoming: 'Upcoming Movies',
|
||||
trending: 'Trending',
|
||||
plexwatchlist: 'Your Watchlist',
|
||||
plexwatchlist: 'Your Plex Watchlist',
|
||||
moviegenres: 'Movie Genres',
|
||||
tvgenres: 'Series Genres',
|
||||
studios: 'Studios',
|
||||
@@ -86,8 +86,6 @@ 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({
|
||||
@@ -104,8 +102,6 @@ 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(),
|
||||
});
|
||||
@@ -171,14 +167,6 @@ 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;
|
||||
}
|
||||
@@ -200,12 +188,6 @@ 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 duration-300"
|
||||
enter="transition transform duration-300"
|
||||
enterFrom="opacity-0 translate-y-6"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition duration-300"
|
||||
leave="transition duration-300 transform"
|
||||
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,36 +365,6 @@ const Discover = () => {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/movies?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/tv?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import EmbyLogo from '@app/assets/services/emby.svg';
|
||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
@@ -10,7 +9,6 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -30,7 +28,6 @@ const ExternalLinkBlock = ({
|
||||
mediaUrl,
|
||||
}: ExternalLinkBlockProps) => {
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -44,8 +41,6 @@ const ExternalLinkBlock = ({
|
||||
>
|
||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLogo />
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
<EmbyLogo />
|
||||
) : (
|
||||
<JellyfinLogo />
|
||||
)}
|
||||
|
||||
@@ -65,10 +65,10 @@ const IssueComment = ({
|
||||
>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition-opacity duration-300"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 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="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform 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 bg-gray-800 shadow ring-1 ring-gray-500 ${
|
||||
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform 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="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
|
||||
@@ -187,10 +187,10 @@ const IssueDetails = () => {
|
||||
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition-opacity duration-300"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={showDeleteModal}
|
||||
|
||||
@@ -12,10 +12,10 @@ interface IssueModalProps {
|
||||
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition-opacity duration-300"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 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"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<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 duration-500"
|
||||
enter="transition transform duration-500"
|
||||
enterFrom="opacity-0 translate-y-0"
|
||||
enterTo="opacity-100 -translate-y-full"
|
||||
leave="transition duration-500"
|
||||
leave="transition duration-500 transform"
|
||||
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 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 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"
|
||||
>
|
||||
{filteredLinks.map((link) => {
|
||||
const isActive = router.pathname.match(link.activeRegExp);
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const PullToRefresh = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [pullStartPoint, setPullStartPoint] = useState(0);
|
||||
const [pullChange, setPullChange] = useState(0);
|
||||
const refreshDiv = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Various pull down thresholds that determine icon location
|
||||
const pullDownInitThreshold = pullChange > 20;
|
||||
const pullDownStopThreshold = 120;
|
||||
const pullDownReloadThreshold = pullChange > 340;
|
||||
const pullDownIconLocation = pullChange / 3;
|
||||
|
||||
useEffect(() => {
|
||||
// Reload function that is called when reload threshold has been hit
|
||||
// Add loading class to determine when to add spin animation
|
||||
const forceReload = () => {
|
||||
refreshDiv.current?.classList.add('loading');
|
||||
setTimeout(() => {
|
||||
router.reload();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const html = document.querySelector('html');
|
||||
|
||||
// Determines if we are at the top of the page
|
||||
// Locks or unlocks page when pulling down to refresh
|
||||
const pullStart = (e: TouchEvent) => {
|
||||
setPullStartPoint(e.targetTouches[0].screenY);
|
||||
|
||||
if (window.scrollY === 0 && window.scrollX === 0) {
|
||||
refreshDiv.current?.classList.add('block');
|
||||
refreshDiv.current?.classList.remove('hidden');
|
||||
document.body.style.touchAction = 'none';
|
||||
document.body.style.overscrollBehavior = 'none';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'none';
|
||||
}
|
||||
} else {
|
||||
refreshDiv.current?.classList.remove('block');
|
||||
refreshDiv.current?.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how far we have pulled down the refresh icon
|
||||
const pullDown = async (e: TouchEvent) => {
|
||||
const screenY = e.targetTouches[0].screenY;
|
||||
|
||||
const pullLength =
|
||||
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
|
||||
|
||||
setPullChange(pullLength);
|
||||
};
|
||||
|
||||
// Will reload the page if we are past the threshold
|
||||
// Otherwise, we reset the pull
|
||||
const pullFinish = () => {
|
||||
setPullStartPoint(0);
|
||||
|
||||
if (pullDownReloadThreshold) {
|
||||
forceReload();
|
||||
} else {
|
||||
setPullChange(0);
|
||||
}
|
||||
|
||||
document.body.style.touchAction = 'auto';
|
||||
document.body.style.overscrollBehaviorY = 'auto';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', pullStart, { passive: false });
|
||||
window.addEventListener('touchmove', pullDown, { passive: false });
|
||||
window.addEventListener('touchend', pullFinish, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', pullStart);
|
||||
window.removeEventListener('touchmove', pullDown);
|
||||
window.removeEventListener('touchend', pullFinish);
|
||||
};
|
||||
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refreshDiv}
|
||||
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
|
||||
id="refreshIcon"
|
||||
style={{
|
||||
top:
|
||||
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
|
||||
? pullDownIconLocation
|
||||
: pullDownInitThreshold
|
||||
? pullDownStopThreshold
|
||||
: '',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
|
||||
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`rounded-full ${
|
||||
pullDownReloadThreshold && 'rotate-180'
|
||||
} text-indigo-500 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
||||
@@ -72,7 +72,9 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
{
|
||||
href: '/issues',
|
||||
messagesKey: 'issues',
|
||||
svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
|
||||
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" />
|
||||
),
|
||||
activeRegExp: /^\/issues/,
|
||||
requiredPermission: [
|
||||
Permission.MANAGE_ISSUES,
|
||||
@@ -126,10 +128,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as="div"
|
||||
enter="transition-transform ease-in-out duration-300"
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform ease-in-out duration-300"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
|
||||
@@ -63,11 +63,11 @@ const UserDropdown = () => {
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform 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';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
@@ -15,8 +13,6 @@ const messages = defineMessages({
|
||||
password: 'Password',
|
||||
host: '{mediaServerName} URL',
|
||||
email: 'Email',
|
||||
emailtooltip:
|
||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||
validationhostrequired: '{mediaServerName} URL required',
|
||||
validationhostformat: 'Valid URL required',
|
||||
validationemailrequired: 'Email required',
|
||||
@@ -67,10 +63,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
};
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -109,7 +101,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label htmlFor="host" className="text-label">
|
||||
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
||||
{intl.formatMessage(messages.host, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
@@ -117,34 +114,20 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
id="host"
|
||||
name="host"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.host,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
placeholder={intl.formatMessage(messages.host, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{errors.host && touched.host && (
|
||||
<div className="error">{errors.host}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-label"
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<label htmlFor="email" className="text-label">
|
||||
{intl.formatMessage(messages.email)}
|
||||
<span className="label-tip">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.emailtooltip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
>
|
||||
<span className="tooltip-trigger">
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
|
||||
@@ -100,10 +100,10 @@ const Login = () => {
|
||||
<Transition
|
||||
as="div"
|
||||
show={!!error}
|
||||
enter="transition-opacity duration-300"
|
||||
enter="opacity-0 transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="opacity-100 transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -9,20 +8,11 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DocumentMinusIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
@@ -42,12 +32,8 @@ const messages = defineMessages({
|
||||
manageModalClearMedia: 'Clear Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your {mediaServerName} library, the media information will be recreated during the next scan.',
|
||||
manageModalRemoveMediaWarning:
|
||||
'* This will irreversibly remove this {mediaType} from {arr}, including all files.',
|
||||
openarr: 'Open in {arr}',
|
||||
removearr: 'Remove from {arr}',
|
||||
openarr4k: 'Open in 4K {arr}',
|
||||
removearr4k: 'Remove from 4K {arr}',
|
||||
downloadstatus: 'Downloads',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
@@ -102,12 +88,6 @@ const ManageSlideOver = ({
|
||||
? `/api/v1/media/${data.mediaInfo.id}/watch_data`
|
||||
: null
|
||||
);
|
||||
const { data: radarrData } = useSWR<RadarrSettings[]>(
|
||||
hasPermission(Permission.ADMIN) ? '/api/v1/settings/radarr' : null
|
||||
);
|
||||
const { data: sonarrData } = useSWR<SonarrSettings[]>(
|
||||
hasPermission(Permission.ADMIN) ? '/api/v1/settings/sonarr' : null
|
||||
);
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data.mediaInfo) {
|
||||
@@ -116,35 +96,6 @@ const ManageSlideOver = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultService = () => {
|
||||
if (data.mediaInfo) {
|
||||
if (data.mediaInfo.mediaType === MediaType.MOVIE) {
|
||||
return (
|
||||
radarrData?.find(
|
||||
(radarr) =>
|
||||
radarr.isDefault && radarr.id === data.mediaInfo?.serviceId
|
||||
) !== undefined
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
sonarrData?.find(
|
||||
(sonarr) =>
|
||||
sonarr.isDefault && sonarr.id === data.mediaInfo?.serviceId
|
||||
) !== undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||
@@ -198,24 +149,20 @@ const ManageSlideOver = ({
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<Tooltip
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<Tooltip
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -381,40 +328,6 @@ const ManageSlideOver = ({
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{hasPermission(Permission.ADMIN) &&
|
||||
data?.mediaInfo?.serviceUrl &&
|
||||
isDefaultService() && (
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(
|
||||
globalMessages.areyousure
|
||||
)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.removearr, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{intl.formatMessage(
|
||||
messages.manageModalRemoveMediaWarning,
|
||||
{
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.movie
|
||||
: messages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -520,54 +433,21 @@ const ManageSlideOver = ({
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<>
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr4k, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
{isDefaultService() && (
|
||||
<div>
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMediaFile()}
|
||||
confirmText={intl.formatMessage(
|
||||
globalMessages.areyousure
|
||||
)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.removearr4k, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{intl.formatMessage(
|
||||
messages.manageModalRemoveMediaWarning,
|
||||
{
|
||||
mediaType: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.movie
|
||||
: messages.tvshow
|
||||
),
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ServerIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.openarr4k, {
|
||||
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,9 +95,7 @@ const MediaSlider = ({
|
||||
case 'movie':
|
||||
return (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
@@ -111,9 +109,7 @@ const MediaSlider = ({
|
||||
case 'tv':
|
||||
return (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
id={title.id}
|
||||
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -27,7 +26,6 @@ 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,
|
||||
@@ -41,7 +39,7 @@ import {
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import type { RTRating } from '@server/api/rottentomatoes';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
@@ -92,7 +90,6 @@ const messages = defineMessages({
|
||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
imdbuserscore: 'IMDB User Score',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -119,17 +116,10 @@ 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<RatingResponse>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratingscombined`
|
||||
const { data: ratingData } = useSWR<RTRating>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
||||
);
|
||||
|
||||
const sortedCrew = useMemo(
|
||||
@@ -543,62 +533,44 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
)}
|
||||
<div className="media-facts">
|
||||
{(!!data.voteCount ||
|
||||
(ratingData?.rt?.criticsRating &&
|
||||
!!ratingData?.rt?.criticsScore) ||
|
||||
(ratingData?.rt?.audienceRating &&
|
||||
!!ratingData?.rt?.audienceScore) ||
|
||||
ratingData?.imdb?.criticsScore) && (
|
||||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
||||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
||||
<div className="media-ratings">
|
||||
{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)}>
|
||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.imdb.url}
|
||||
href={ratingData.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ImdbLogo className="mr-1 w-6" />
|
||||
<span>{ratingData.imdb.criticsScore}</span>
|
||||
{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>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -847,7 +819,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
tmdbId={data.id}
|
||||
tvdbId={data.externalIds.tvdbId}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.rt?.url}
|
||||
rtUrl={ratingData?.url}
|
||||
mediaUrl={
|
||||
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const messages = defineMessages({
|
||||
'Get notified when issues are reopened by other users.',
|
||||
mediaautorequested: 'Request Automatically Submitted',
|
||||
mediaautorequestedDescription:
|
||||
'Get notified when new media requests are automatically submitted for items on Your Watchlist.',
|
||||
'Get notified when new media requests are automatically submitted for items on your Plex Watchlist.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
|
||||
@@ -133,7 +133,6 @@ const PersonDetails = () => {
|
||||
return (
|
||||
<li key={`list-cast-item-${media.id}-${index}`}>
|
||||
<TitleCard
|
||||
key={media.id}
|
||||
id={media.id}
|
||||
title={media.mediaType === 'movie' ? media.title : media.name}
|
||||
userScore={media.voteAverage}
|
||||
@@ -174,7 +173,6 @@ const PersonDetails = () => {
|
||||
return (
|
||||
<li key={`list-crew-item-${media.id}-${index}`}>
|
||||
<TitleCard
|
||||
key={media.id}
|
||||
id={media.id}
|
||||
title={media.mediaType === 'movie' ? media.title : media.name}
|
||||
userScore={media.voteAverage}
|
||||
|
||||
45
src/components/PullToRefresh/index.tsx
Normal file
45
src/components/PullToRefresh/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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-opacity ease-in duration-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -221,7 +220,6 @@ 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
|
||||
);
|
||||
@@ -231,13 +229,6 @@ 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 { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -294,13 +293,6 @@ 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-opacity ease-in duration-300"
|
||||
enter="transition ease-in duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in duration-100"
|
||||
leave="transition 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 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 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`}
|
||||
></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 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 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`}
|
||||
></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 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 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`}
|
||||
></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 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 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`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -29,10 +29,10 @@ const RequestModal = ({
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
enter="transition-opacity duration-300"
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
|
||||
@@ -169,19 +169,15 @@ export const GenreSelector = ({
|
||||
loadDefaultGenre();
|
||||
}, [defaultValue, type]);
|
||||
|
||||
const loadGenreOptions = async (inputValue: string) => {
|
||||
const loadGenreOptions = async () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/${type}`
|
||||
);
|
||||
|
||||
return results.data
|
||||
.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}))
|
||||
.filter(({ label }) =>
|
||||
label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -309,9 +305,7 @@ export const WatchProviderSelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
onChange(watchRegion, activeProvider);
|
||||
// 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]);
|
||||
}, [activeProvider, watchRegion, onChange]);
|
||||
|
||||
const orderedData = useMemo(() => {
|
||||
if (!data) {
|
||||
@@ -350,7 +344,7 @@ export const WatchProviderSelector = ({
|
||||
<SmallLoadingSpinner />
|
||||
) : (
|
||||
<div className="grid">
|
||||
<div className="provider-icons grid gap-2">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{initialProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -359,7 +353,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
className={`provider-container relative h-full 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'
|
||||
@@ -392,7 +386,7 @@ export const WatchProviderSelector = ({
|
||||
})}
|
||||
</div>
|
||||
{showMore && otherProviders.length > 0 && (
|
||||
<div className="provider-icons relative top-2 grid gap-2">
|
||||
<div className="relative top-2 grid grid-cols-6 gap-2">
|
||||
{otherProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -401,7 +395,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
className={`provider-container relative h-full 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'
|
||||
@@ -437,7 +431,6 @@ 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" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user