Compare commits

..

4 Commits

Author SHA1 Message Date
semantic-release-bot
57e7d68092 chore(release): 1.4.1 2023-01-31 00:20:50 +00:00
Fallenbagel
d3622f7bb3 Merge pull request #316 from Fallenbagel/develop
Merge develop into main
2023-01-31 05:15:41 +05:00
semantic-release-bot
20c821e2eb chore(release): 1.4.0 2023-01-29 20:33:10 +00:00
Fallenbagel
7b82ced5e6 Merge pull request #312 from Fallenbagel/develop
Merge 'origin/develop' into main
2023-01-30 01:31:00 +05:00
142 changed files with 3247 additions and 7524 deletions

View File

@@ -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
View File

@@ -1,2 +1,7 @@
# Global code ownership
* @Fallenbagel
- @Fallenbagel
# i18n locale files
src/i18n/locale/ @Fallenbagel

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -16,8 +16,5 @@
}
],
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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(

View File

@@ -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)

View File

@@ -1,6 +1,6 @@
version: '3'
services:
jellyseerr:
overseerr:
build:
context: .
dockerfile: Dockerfile.local

View File

@@ -23,6 +23,5 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 256000,
},
};

View File

@@ -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

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
},

View File

@@ -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 {

View File

@@ -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>[] = [

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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(),
});

View File

@@ -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

View File

@@ -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', {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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)
);

View File

@@ -1,6 +0,0 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

View File

@@ -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"`);
}
}

View File

@@ -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);
}

View File

@@ -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()
// },
});

View File

@@ -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',

View File

@@ -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)
);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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),

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -34,7 +34,6 @@ searchRoutes.get('/', async (req, res, next) => {
}
const media = await Media.getRelatedMedia(
req.user,
results.results.map((result) => result.id)
);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 => {

View File

@@ -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)

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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');
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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()}
>

View File

@@ -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>

View File

@@ -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 ${

View File

@@ -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',
});

View File

@@ -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>

View File

@@ -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}
/>
))}
/>

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 />
)}

View File

@@ -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'
}`}
/>

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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"

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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">

View File

@@ -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';

View File

@@ -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">

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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 = (

View File

@@ -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}

View 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;

View File

@@ -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"

View File

@@ -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({

View File

@@ -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
),
}
);

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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