Merge branch 'develop'

master
sct 11 months ago
commit 2dc0009675

@ -773,6 +773,105 @@
"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"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@ -782,5 +881,6 @@
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false,
"commitConvention": "angular"
"commitConvention": "angular",
"commitType": "docs"
}

10
.github/CODEOWNERS vendored

@ -1,10 +1,10 @@
# Global code ownership
* @sct @TheCatLady @danshilm
* @sct @TheCatLady @danshilm @OwsleyJr
# Documentation
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
/*.md @TheCatLady @samwiseg0 @danshilm
/docs/ @TheCatLady @samwiseg0 @danshilm
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
# Snap-related files
/.github/workflows/snap.yaml @samwiseg0
@ -12,4 +12,4 @@
# i18n locale files
/src/i18n/locale/ @sct @TheCatLady
/src/i18n/locale/en.json @sct @TheCatLady @danshilm
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr

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

@ -11,7 +11,7 @@
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-84-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-95-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -182,6 +182,21 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
</tr>
</tbody>
</table>

@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
url: '/api/v1/*',
}).as('apiCall');
cy.get('.searchbar').swipe('bottom', [190, 400]);
cy.get('.searchbar').swipe('bottom', [190, 500]);
cy.wait('@apiCall').then((interception) => {
assert.isNotNull(

@ -96,7 +96,7 @@ describe('Discover Customization', () => {
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('time travel{enter}', { delay: 100 });
cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)

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

@ -3615,7 +3615,7 @@ paths:
$ref: '#/components/schemas/User'
/user/{userId}/requests:
get:
summary: Get user by ID
summary: Get requests for a specific user
description: |
Retrieves a user's requests in a JSON object.
tags:
@ -3711,7 +3711,7 @@ paths:
example: false
/user/{userId}/watchlist:
get:
summary: Get user by ID
summary: Get the Plex watchlist for a specific user
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
@ -4186,6 +4186,16 @@ paths:
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
@ -4465,6 +4475,16 @@ paths:
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:

@ -29,17 +29,17 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.2.3",
"@formatjs/intl-locale": "3.0.11",
"@formatjs/intl-pluralrules": "5.1.8",
"@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "1.7.7",
"@heroicons/react": "2.0.13",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.22",
"ace-builds": "1.14.0",
"axios": "1.2.2",
"@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
@ -47,7 +47,7 @@
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5",
"cronstrue": "2.21.0",
"cronstrue": "2.23.0",
"csurf": "1.11.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
@ -63,23 +63,22 @@
"next": "12.3.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.0",
"nodemailer": "6.8.0",
"openpgp": "5.5.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.22.0",
"react-aria": "3.23.0",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.1",
"react-intl": "6.2.5",
"react-markdown": "8.0.4",
"react-intersection-observer": "9.4.3",
"react-intl": "6.2.10",
"react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2",
"react-select": "5.7.0",
"react-spring": "9.6.1",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2",
@ -88,42 +87,41 @@
"secure-random-password": "0.2.3",
"semver": "7.3.8",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.0",
"swr": "2.0.0",
"typeorm": "0.3.11",
"swagger-ui-express": "4.6.2",
"swr": "2.0.4",
"typeorm": "0.3.12",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "3.20.2"
"zod": "3.20.6"
},
"devDependencies": {
"@babel/cli": "7.20.7",
"@commitlint/cli": "17.4.0",
"@commitlint/config-conventional": "17.4.0",
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/commit-analyzer": "9.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.8",
"@tailwindcss/typography": "0.5.9",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.15",
"@types/express-session": "1.17.5",
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/pulltorefreshjs": "0.1.5",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13",
@ -132,45 +130,46 @@
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.48.0",
"@typescript-eslint/parser": "5.48.0",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.2.6",
"commitizen": "4.3.0",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "12.3.0",
"cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.31.0",
"eslint": "8.35.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.3.9",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3",
"lint-staged": "13.1.0",
"lint-staged": "13.1.2",
"nodemon": "2.0.20",
"postcss": "8.4.20",
"prettier": "2.8.1",
"prettier-plugin-organize-imports": "3.2.1",
"prettier-plugin-tailwindcss": "0.2.1",
"postcss": "8.4.21",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "19.0.5",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.4",
"tailwindcss": "3.2.7",
"ts-node": "10.9.1",
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.4"
"typescript": "4.9.5"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10"
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6"
},
"config": {
"commitizen": {

@ -17,7 +17,7 @@ interface RTAlgoliaHit {
title: string;
titles: string[];
description: string;
releaseYear: string;
releaseYear: number;
rating: string;
genres: string[];
updateDate: string;
@ -111,22 +111,19 @@ class RottenTomatoes extends ExternalAPI {
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString() && movie.title === name
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) =>
movie.releaseYear === year.toString() && movie.title.includes(name)
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year.toString()
);
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
@ -181,7 +178,7 @@ class RottenTomatoes extends ExternalAPI {
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year.toString()
(series) => series.releaseYear === year
);
}

@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';
interface SonarrSeason {
export interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
@ -76,6 +76,15 @@ export interface SonarrSeries {
ignoreEpisodesWithoutFiles?: boolean;
searchForMissingEpisodes?: boolean;
};
statistics: {
seasonCount: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
releaseGroups: string[];
percentOfEpisodes: number;
};
}
export interface AddSeriesOptions {
@ -116,6 +125,16 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {

@ -65,6 +65,8 @@ interface DiscoverMovieOptions {
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
originalLanguage?: string;
genre?: string;
studio?: string;
@ -83,6 +85,8 @@ interface DiscoverTvOptions {
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: string;
@ -460,6 +464,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
@ -504,6 +510,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
@ -530,6 +538,8 @@ class TheMovieDb extends ExternalAPI {
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
@ -574,6 +584,8 @@ class TheMovieDb extends ExternalAPI {
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},

@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
first_air_date: string;
}
export interface TmdbCollectionResult {
id: number;
media_type: 'collection';
title: string;
original_title: string;
adult: boolean;
poster_path?: string;
backdrop_path?: string;
overview: string;
original_language: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {

@ -20,6 +20,8 @@ export enum DiscoverSliderType {
TMDB_SEARCH,
TMDB_STUDIO,
TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
}
export const defaultSliders: Partial<DiscoverSlider>[] = [

@ -114,29 +114,29 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true, type: 'int' })
public serviceId?: number | null;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true, type: 'int' })
public serviceId4k?: number | null;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId?: number | null;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number | null;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string | null;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string | null;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey?: string | null;
@Column({ nullable: true })
public ratingKey4k?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
public serviceUrl?: string;
public serviceUrl4k?: string;
@ -260,7 +260,9 @@ class Media {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
@ -270,7 +272,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
@ -282,7 +286,9 @@ class Media {
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
@ -292,7 +298,9 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,

@ -704,7 +704,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if (
this.rootFolder &&
@ -764,6 +764,38 @@ export class MediaRequest {
return;
}
if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await radarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
});
}
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
@ -970,7 +1002,11 @@ export class MediaRequest {
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
: sonarrSettings.tags;
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
if (
this.rootFolder &&
@ -1022,6 +1058,38 @@ export class MediaRequest {
});
}
if (sonarrSettings.tagRequests) {
let userTag = (await sonarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
});
}
}
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
@ -1187,3 +1255,5 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

@ -1,5 +1,7 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
@ -34,6 +36,18 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

@ -17,6 +17,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
@ -182,7 +183,8 @@ app
});
server.use('/api/v1', routes);
server.use('/imageproxy', imageproxy);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

@ -1,3 +1,4 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
@ -7,6 +8,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@ -14,7 +16,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@ -30,7 +32,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'short',
interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
@ -47,7 +49,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
@ -59,27 +61,37 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// Run watchlist sync every 5 minutes
scheduledJobs.push({
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'short',
interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@ -94,7 +106,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@ -104,12 +116,29 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
@ -124,7 +153,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
@ -134,12 +163,12 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 5 minutes
// Run image cache cleanup every 24 hours
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'long',
interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {

@ -0,0 +1,817 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { RadarrMovie } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]> = {};
private sonarrSeasonsCache: Record<string, SonarrSeason[]> = {};
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
try {
await this.initPlexClient();
if (!this.plexClient) {
return;
}
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
const pageSize = 50;
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
if (!this.running) {
throw new Error('Job aborted');
}
const mediaExists = await this.mediaExists(media);
// We can not delete media so if both versions do not exist, we will change both columns to unknown or null
if (!mediaExists) {
if (
media.status !== MediaStatus.UNKNOWN ||
media.status4k !== MediaStatus.UNKNOWN
) {
const request = await requestRepository.find({
relations: {
media: true,
},
where: { media: { id: media.id } },
});
logger.info(
`Media ID ${media.id} does not exist in any of your media instances. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
});
await requestRepository.remove(request);
}
}
if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?
const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
],
});
let didDeleteSeasons = false;
for (const season of seasons) {
if (
!mediaExists &&
(season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN)
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
} else {
const seasonExists = await this.seasonExists(media, season);
if (!seasonExists) {
logger.info(
`Removing season ${season.seasonNumber}, media ID ${media.id} because it does not exist in any of your media instances.`,
{ label: 'AvailabilitySync' }
);
if (
season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
}
const seasonToBeDeleted = await seasonRequestRepository.findOne(
{
relations: {
request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
}
);
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
didDeleteSeasons = true;
}
}
if (didDeleteSeasons) {
if (
media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE
) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
}
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const isTVType = media.mediaType === 'tv';
try {
const request = await requestRepository.findOne({
relations: {
media: true,
},
where: { media: { id: media.id }, is4k: is4k ? true : false },
});
logger.info(
`Media ID ${media.id} does not exist in your ${
is4k ? '4k' : 'non-4k'
} ${
isTVType ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(
media.id,
is4k
? {
status4k: MediaStatus.UNKNOWN,
serviceId4k: null,
externalServiceId4k: null,
externalServiceSlug4k: null,
ratingKey4k: null,
}
: {
status: MediaStatus.UNKNOWN,
serviceId: null,
externalServiceId: null,
externalServiceSlug: null,
ratingKey: null,
}
);
if (isTVType) {
const seasonRepository = getRepository(Season);
await seasonRepository?.update(
{ media: { id: media.id } },
is4k
? { status4k: MediaStatus.UNKNOWN }
: { status: MediaStatus.UNKNOWN }
);
}
await requestRepository.delete({ id: request?.id });
} catch (ex) {
logger.debug(`Failure updating media ID ${media.id}`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
private async mediaExistsInRadarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInRadarr = true;
let existsInRadarr4k = true;
for (const server of this.radarrServers) {
const api = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Check if both exist or if a single non-4k or 4k exists
// If both do not exist we will return false
let meta: RadarrMovie | undefined;
if (!server.is4k && media.externalServiceId) {
meta = await api.getMovie({ id: media.externalServiceId });
}
if (server.is4k && media.externalServiceId4k) {
meta = await api.getMovie({ id: media.externalServiceId4k });
}
if (!server.is4k && (!meta || !meta.hasFile)) {
existsInRadarr = false;
}
if (server.is4k && (!meta || !meta.hasFile)) {
existsInRadarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Radarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
existsInRadarr = false;
}
if (server.is4k) {
existsInRadarr4k = false;
}
}
}
// If only a single non-4k or 4k exists, then change entity columns accordingly
// Related media request will then be deleted
if (
!existsInRadarr &&
(existsInRadarr4k || existsInPlex4k) &&
!existsInPlex
) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (
(existsInRadarr || existsInPlex) &&
!existsInRadarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) {
return true;
}
return false;
}
private async mediaExistsInSonarr(
media: Media,
existsInPlex: boolean,
existsInPlex4k: boolean
): Promise<boolean> {
let existsInSonarr = true;
let existsInSonarr4k = true;
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Check if both exist or if a single non-4k or 4k exists
// If both do not exist we will return false
let meta: SonarrSeries | undefined;
if (!server.is4k && media.externalServiceId) {
meta = await api.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
meta.seasons;
}
if (server.is4k && media.externalServiceId4k) {
meta = await api.getSeriesById(media.externalServiceId4k);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
meta.seasons;
}
if (!server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) {
existsInSonarr = false;
}
if (server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) {
existsInSonarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
existsInSonarr = false;
}
if (server.is4k) {
existsInSonarr4k = false;
}
}
}
// If only a single non-4k or 4k exists, then change entity columns accordingly
// Related media request will then be deleted
if (
!existsInSonarr &&
(existsInSonarr4k || existsInPlex4k) &&
!existsInPlex
) {
if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false);
}
}
if (
(existsInSonarr || existsInPlex) &&
!existsInSonarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true);
}
}
if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) {
return true;
}
return false;
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
seasonExistsInPlex: boolean,
seasonExistsInPlex4k: boolean
): Promise<boolean> {
let seasonExistsInSonarr = true;
let seasonExistsInSonarr4k = true;
const mediaRepository = getRepository(Media);
const seasonRepository = getRepository(Season);
const seasonRequestRepository = getRepository(SeasonRequest);
for (const server of this.sonarrServers) {
const api = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
// Here we can use the cache we built when we fetched the series with mediaExistsInSonarr
// If the cache does not have data, we will fetch with the api route
let seasons: SonarrSeason[] =
this.sonarrSeasonsCache[
`${server.id}-${
!server.is4k ? media.externalServiceId : media.externalServiceId4k
}`
];
if (!server.is4k && media.externalServiceId) {
seasons =
this.sonarrSeasonsCache[
`${server.id}-${media.externalServiceId}`
] ?? (await api.getSeriesById(media.externalServiceId)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
seasons;
}
if (server.is4k && media.externalServiceId4k) {
seasons =
this.sonarrSeasonsCache[
`${server.id}-${media.externalServiceId4k}`
] ?? (await api.getSeriesById(media.externalServiceId4k)).seasons;
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
seasons;
}
const seasonIsUnavailable = seasons?.find(
({ seasonNumber, statistics }) =>
season.seasonNumber === seasonNumber &&
statistics?.episodeFileCount === 0
);
if (!server.is4k && seasonIsUnavailable) {
seasonExistsInSonarr = false;
}
if (server.is4k && seasonIsUnavailable) {
seasonExistsInSonarr4k = false;
}
} catch (ex) {
logger.debug(
`Failure retrieving media ID ${media.id} from your ${
!server.is4k ? 'non-4K' : '4K'
} Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
if (!server.is4k) {
seasonExistsInSonarr = false;
}
if (server.is4k) {
seasonExistsInSonarr4k = false;
}
}
}
try {
const seasonToBeDeleted = await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
is4k: seasonExistsInSonarr ? true : false,
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
// If season does not exist, we will change status to unknown and delete related season request
// If parent media request is empty(all related seasons have been removed), parent is automatically deleted
if (
!seasonExistsInSonarr &&
(seasonExistsInSonarr4k || seasonExistsInPlex4k) &&
!seasonExistsInPlex
) {
if (season.status !== MediaStatus.UNKNOWN) {
logger.info(
`Season ${season.seasonNumber}, media ID ${media.id} does not exist in your non-4k Sonarr and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status === MediaStatus.AVAILABLE) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
if (
(seasonExistsInSonarr || seasonExistsInPlex) &&
!seasonExistsInSonarr4k &&
!seasonExistsInPlex4k
) {
if (season.status4k !== MediaStatus.UNKNOWN) {
logger.info(
`Season ${season.seasonNumber}, media ID ${media.id} does not exist in your 4k Sonarr and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' }
);
await seasonRepository.update(season.id, {
status4k: MediaStatus.UNKNOWN,
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
if (media.status4k === MediaStatus.AVAILABLE) {
logger.info(
`Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE,
});
}
}
}
} catch (ex) {
logger.debug(`Failure updating media ID ${media.id}`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
if (
seasonExistsInSonarr ||
seasonExistsInSonarr4k ||
seasonExistsInPlex ||
seasonExistsInPlex4k
) {
return true;
}
return false;
}
private async mediaExists(media: Media): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let existsInPlex4k = false;
// Check each plex instance to see if media exists
try {
if (ratingKey) {
const meta = await this.plexClient?.getMetadata(ratingKey);
if (meta) {
existsInPlex = true;
}
}
if (ratingKey4k) {
const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
if (meta4k) {
existsInPlex4k = true;
}
}
} catch (ex) {
if (!ex.message.includes('response code: 404')) {
logger.debug(`Failed to retrieve plex metadata`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
// Base case if both media versions exist in plex
if (existsInPlex && existsInPlex4k) {
return true;
}
// We then check radarr or sonarr has that specific media. If not, then we will move to delete
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr(
media,
existsInPlex,
existsInPlex4k
);
// If true, media exists in at least one radarr or plex instance.
if (existsInRadarr) {
logger.warn(
`${media.id} exists in at least one Radarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
if (media.mediaType === 'tv') {
const existsInSonarr = await this.mediaExistsInSonarr(
media,
existsInPlex,
existsInPlex4k
);
// If true, media exists in at least one sonarr or plex instance.
if (existsInSonarr) {
logger.warn(
`${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
}
return false;
}
private async seasonExists(media: Media, season: Season) {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
let seasonExistsInPlex4k = false;
try {
if (ratingKey) {
const children =
this.plexSeasonsCache[ratingKey] ??
(await this.plexClient?.getChildrenMetadata(ratingKey)) ??
[];
this.plexSeasonsCache[ratingKey] = children;
const seasonMeta = children?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta) {
seasonExistsInPlex = true;
}
}
if (ratingKey4k) {
const children4k =
this.plexSeasonsCache[ratingKey4k] ??
(await this.plexClient?.getChildrenMetadata(ratingKey4k)) ??
[];
this.plexSeasonsCache[ratingKey4k] = children4k;
const seasonMeta4k = children4k?.find(
(child) => child.index === season.seasonNumber
);
if (seasonMeta4k) {
seasonExistsInPlex4k = true;
}
}
} catch (ex) {
if (!ex.message.includes('response code: 404')) {
logger.debug(`Failed to retrieve plex's children metadata`, {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
}
}
// Base case if both season versions exist in plex
if (seasonExistsInPlex && seasonExistsInPlex4k) {
return true;
}
const existsInSonarr = await this.seasonExistsInSonarr(
media,
season,
seasonExistsInPlex,
seasonExistsInPlex4k
);
if (existsInSonarr) {
logger.warn(
`Season ${season.seasonNumber}, media ID ${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`,
{
label: 'AvailabilitySync',
}
);
return true;
}
return false;
}
private async initPlexClient() {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin) {
logger.warning('No admin configured. Availability sync skipped.');
return;
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

@ -61,6 +61,7 @@ export interface DVRSettings {
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
}
export interface RadarrSettings extends DVRSettings {
@ -248,7 +249,8 @@ export type JobId =
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup';
| 'image-cache-cleanup'
| 'availability-sync';
interface AllSettings {
clientId: string;
@ -409,6 +411,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
@ -546,7 +551,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 {

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

@ -1,4 +1,5 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@ -9,7 +10,7 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
interface SearchResult {
id: number;
@ -43,6 +44,18 @@ export interface TvResult extends SearchResult {
firstAirDate: string;
}
export interface CollectionResult {
id: number;
mediaType: 'collection';
title: string;
originalTitle: string;
adult: boolean;
posterPath?: string;
backdropPath?: string;
overview: string;
originalLanguage: string;
}
export interface PersonResult {
id: number;
name: string;
@ -53,7 +66,7 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
export type Results = MovieResult | TvResult | PersonResult;
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@ -99,6 +112,20 @@ export const mapTvResult = (
mediaInfo: media,
});
export const mapCollectionResult = (
collectionResult: TmdbCollectionResult
): CollectionResult => ({
id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult,
originalLanguage: collectionResult.original_language,
originalTitle: collectionResult.original_title,
title: collectionResult.title,
overview: collectionResult.overview,
backdropPath: collectionResult.backdrop_path,
posterPath: collectionResult.poster_path,
});
export const mapPersonResult = (
personResult: TmdbPersonResult
): PersonResult => ({
@ -118,7 +145,12 @@ export const mapPersonResult = (
});
export const mapSearchResults = (
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[],
media?: Media[]
): Results[] =>
results.map((result) => {
@ -139,6 +171,8 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
case 'collection':
return mapCollectionResult(result);
default:
return mapPersonResult(result);
}

@ -14,12 +14,13 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
@ -64,6 +65,8 @@ const QueryFilterOptions = z.object({
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
@ -95,6 +98,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
@ -370,6 +375,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
});
@ -647,6 +654,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
@ -800,12 +809,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
@ -829,8 +838,8 @@ discoverRoutes.get<{ page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

@ -183,9 +183,7 @@ serviceRoutes.get<{ tmdbId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {

@ -381,7 +381,14 @@ router.delete<{ id: string }>(
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests);
await requestRepository.remove(user.requests, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
@ -607,7 +614,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
}
);
router.get<{ id: string; page?: number }, WatchlistResponse>(
router.get<{ id: string }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
@ -627,7 +634,7 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
}
const itemsPerPage = 20;
const page = req.params.page ?? 1;
const page = Number(req.query.page) ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
@ -651,8 +658,8 @@ router.get<{ id: string; page?: number }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

@ -1,4 +1,5 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@ -8,17 +9,35 @@ import type {
} from '@server/api/themoviedb/interfaces';
export const isMovie = (
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
movie:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): movie is TmdbMovieResult => {
return (movie as TmdbMovieResult).title !== undefined;
};
export const isPerson = (
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
person:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): person is TmdbPersonResult => {
return (person as TmdbPersonResult).known_for !== undefined;
};
export const isCollection = (
collection:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): collection is TmdbCollectionResult => {
return (collection as TmdbCollectionResult).media_type === 'collection';
};
export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => {

@ -10,6 +10,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
@ -39,6 +40,19 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
return { downloadStatus, downloadStatus4k };
};
const {
data,
error,
@ -46,21 +60,19 @@ 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(() => {
return [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
}, [data?.parts]);
const downloadItems = returnCollectionDownloadItems(data);
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
}, [data]);
const [titles, titles4k] = useMemo(() => {
return [
@ -336,7 +348,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
/>
))}
/>
<div className="pb-8" />
<div className="extra-bottom-space relative" />
</div>
);
};

@ -71,7 +71,7 @@ const Badge = (
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
);
if (href) {
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
}
}

@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<Transition
as={Fragment}
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
<div

@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
CollectionResult,
MovieResult,
PersonResult,
TvResult,
@ -12,7 +13,7 @@ import type {
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[];
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
@ -90,6 +91,18 @@ const ListView = ({
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard

@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition
appear
as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}}
appear
as="div"
enter="transition opacity-0 duration-300 transform scale-75"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}

@ -29,7 +29,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);

@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment}
show={show}
appear
enter="opacity-0 transition ease-in-out duration-300"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition ease-in-out duration-300"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@ -58,16 +58,16 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
appear
enter="transform transition ease-in-out duration-500 sm:duration-700"
enter="transition-transform ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leave="transition-transform ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>

@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
@ -55,7 +56,7 @@ type CreateOption = {
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
dataPlaceholderText?: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
{
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
dataUrl: '/api/v1/discover/movies',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
{
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
dataUrl: '/api/v1/discover/tv',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
];
return (
@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'movie'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'tv'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
default:
dataInput = (
<Field
@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={
activeOption.type ===
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
activeOption.type ===
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
? activeOption.params
?.replace(
'$regionValue',
encodeURIExtraParams(values?.data.split(',')[0])
)
.replace(
'$providersValue',
encodeURIExtraParams(values?.data.split(',')[1])
)
: activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)
}
onNewTitles={updateResultCount}
/>
</div>

@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
default:
return 'Unknown Slider';
}
@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
<div className="w-7/12 truncate md:w-full">
{getSliderTitle(slider)}
</div>
</div>
<div
className={`pointer-events-none ${

@ -35,8 +35,10 @@ const messages = defineMessages({
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
});
type FilterSlideoverProps = {
@ -246,6 +248,45 @@ const FilterSlideover = ({
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>

@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
});
export const QueryFilterOptions = z.object({
@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(),
voteCountLte: z.string().optional(),
voteCountGte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
});
@ -167,6 +171,14 @@ export const prepareFilterValues = (
filterValues.voteAverageLte = values.voteAverageLte;
}
if (values.voteCountGte) {
filterValues.voteCountGte = values.voteCountGte;
}
if (values.voteCountLte) {
filterValues.voteCountLte = values.voteCountLte;
}
if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders;
}
@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
delete clonedFilters.voteAverageLte;
}
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
totalCount += 1;
delete clonedFilters.voteCountGte;
delete clonedFilters.voteCountLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1;
delete clonedFilters.withRuntimeGte;

@ -165,10 +165,10 @@ const Discover = () => {
</Transition>
<Transition
show={isEditing}
enter="transition transform duration-300"
enter="transition duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform"
leave="transition duration-300"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
@ -365,6 +365,36 @@ const Discover = () => {
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/movies?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={`watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
linkUrl={`/discover/tv?watchRegion=${
slider.data?.split(',')[0]
}&watchProviders=${slider.data?.split(',')[1]}`}
/>
);
break;
}
if (isEditing) {

@ -65,10 +65,10 @@ const IssueComment = ({
>
<Transition
as={Fragment}
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
@ -115,11 +115,11 @@ const IssueComment = ({
as={Fragment}
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static
@ -164,7 +164,7 @@ const IssueComment = ({
</Menu>
)}
<div
className={`absolute top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
className={`absolute top-3 z-10 h-3 w-3 rotate-45 bg-gray-800 shadow ring-1 ring-gray-500 ${
isReversed ? '-left-1' : '-right-1'
}`}
/>

@ -57,11 +57,11 @@ const IssueDescription = ({
show={open}
as="div"
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items
static

@ -182,10 +182,10 @@ const IssueDetails = () => {
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}

@ -12,10 +12,10 @@ interface IssueModalProps {
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

@ -34,12 +34,12 @@ const LanguagePicker = () => {
<Transition
as="div"
show={isDropdownOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"

@ -131,13 +131,13 @@ const MobileMenu = () => {
show={isOpen}
as="div"
ref={ref}
enter="transition transform duration-500"
enter="transition duration-500"
enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform"
leave="transition duration-500"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);

@ -0,0 +1,118 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
const PullToRefresh = () => {
const router = useRouter();
const [pullStartPoint, setPullStartPoint] = useState(0);
const [pullChange, setPullChange] = useState(0);
const refreshDiv = useRef<HTMLDivElement>(null);
// Various pull down thresholds that determine icon location
const pullDownInitThreshold = pullChange > 20;
const pullDownStopThreshold = 120;
const pullDownReloadThreshold = pullChange > 340;
const pullDownIconLocation = pullChange / 3;
useEffect(() => {
// Reload function that is called when reload threshold has been hit
// Add loading class to determine when to add spin animation
const forceReload = () => {
refreshDiv.current?.classList.add('loading');
setTimeout(() => {
router.reload();
}, 1000);
};
const html = document.querySelector('html');
// Determines if we are at the top of the page
// Locks or unlocks page when pulling down to refresh
const pullStart = (e: TouchEvent) => {
setPullStartPoint(e.targetTouches[0].screenY);
if (window.scrollY === 0 && window.scrollX === 0) {
refreshDiv.current?.classList.add('block');
refreshDiv.current?.classList.remove('hidden');
document.body.style.touchAction = 'none';
document.body.style.overscrollBehavior = 'none';
if (html) {
html.style.overscrollBehaviorY = 'none';
}
} else {
refreshDiv.current?.classList.remove('block');
refreshDiv.current?.classList.add('hidden');
}
};
// Tracks how far we have pulled down the refresh icon
const pullDown = async (e: TouchEvent) => {
const screenY = e.targetTouches[0].screenY;
const pullLength =
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
setPullChange(pullLength);
};
// Will reload the page if we are past the threshold
// Otherwise, we reset the pull
const pullFinish = () => {
setPullStartPoint(0);
if (pullDownReloadThreshold) {
forceReload();
} else {
setPullChange(0);
}
document.body.style.touchAction = 'auto';
document.body.style.overscrollBehaviorY = 'auto';
if (html) {
html.style.overscrollBehaviorY = 'auto';
}
};
window.addEventListener('touchstart', pullStart, { passive: false });
window.addEventListener('touchmove', pullDown, { passive: false });
window.addEventListener('touchend', pullFinish, { passive: false });
return () => {
window.removeEventListener('touchstart', pullStart);
window.removeEventListener('touchmove', pullDown);
window.removeEventListener('touchend', pullFinish);
};
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
return (
<div
ref={refreshDiv}
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
id="refreshIcon"
style={{
top:
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
? pullDownIconLocation
: pullDownInitThreshold
? pullDownStopThreshold
: '',
}}
>
<div
className={`${
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon
className={`rounded-full ${
pullDownReloadThreshold && 'rotate-180'
} text-indigo-500 transition-all duration-300`}
/>
</div>
</div>
);
};
export default PullToRefresh;

@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [
{
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/issues/,
requiredPermission: [
Permission.MANAGE_ISSUES,
@ -127,10 +125,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
</Transition.Child>
<Transition.Child
as="div"
enter="transition ease-in-out duration-300 transform"
enter="transition-transform ease-in-out duration-300"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave="transition-transform ease-in-out duration-300"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>

@ -63,11 +63,11 @@ const UserDropdown = () => {
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
appear
>
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">

@ -1,8 +1,8 @@
import MobileMenu from '@app/components/Layout/MobileMenu';
import PullToRefresh from '@app/components/Layout/PullToRefresh';
import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown';
import PullToRefresh from '@app/components/PullToRefresh';
import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';

@ -95,10 +95,10 @@ const Login = () => {
<Transition
as="div"
show={!!error}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
import DownloadBlock from '@app/components/DownloadBlock';
import IssueBlock from '@app/components/IssueBlock';
import RequestBlock from '@app/components/RequestBlock';
@ -144,20 +145,24 @@ const ManageSlideOver = ({
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} />
</li>
</Tooltip>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
content={status.title}
>
<DownloadBlock downloadItem={status} is4k />
</li>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
))}
</ul>
</div>

@ -26,6 +26,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowRightCircleIcon,
CloudIcon,
@ -110,6 +111,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
mutate: revalidate,
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
fallbackData: movie,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: movie?.mediaInfo?.downloadStatus,
downloadStatus4k: movie?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(

@ -1,45 +0,0 @@
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import PR from 'pulltorefreshjs';
import { useEffect } from 'react';
import ReactDOMServer from 'react-dom/server';
const PullToRefresh = () => {
const router = useRouter();
useEffect(() => {
PR.init({
mainElement: '#pull-to-refresh',
onRefresh() {
router.reload();
},
iconArrow: ReactDOMServer.renderToString(
<div className="p-2">
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
iconRefreshing: ReactDOMServer.renderToString(
<div
className="animate-spin p-2"
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
distReload: 60,
distIgnore: 15,
shouldPullToRefresh: () =>
!window.scrollY && document.body.style.overflow !== 'hidden',
});
return () => {
PR.destroyAll();
};
}, [router]);
return <div id="pull-to-refresh"></div>;
};
export default PullToRefresh;

@ -122,7 +122,7 @@ const RegionSelector = ({
<Transition
show={open}
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"

@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { withProperties } from '@app/utils/typeHelpers';
import {
ArrowPathIcon,
@ -220,6 +221,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
);
@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
});
const { plexUrl, plexUrl4k } = useDeepLinks({

@ -7,6 +7,7 @@ import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import {
ArrowPathIcon,
CheckIcon,
@ -293,6 +294,13 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
`/api/v1/request/${request.id}`,
{
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}
);

@ -582,10 +582,10 @@ const AdvancedRequester = ({
<Transition
show={open}
enter="transition ease-in duration-300"
enter="transition-opacity ease-in duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-100"
leave="transition-opacity ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"

@ -324,7 +324,7 @@ const CollectionRequestModal = ({
aria-hidden="true"
className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@ -389,7 +389,7 @@ const CollectionRequestModal = ({
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

@ -540,7 +540,7 @@ const TvRequestModal = ({
aria-hidden="true"
className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@ -631,7 +631,7 @@ const TvRequestModal = ({
isSelectedSeason(season.seasonNumber)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

@ -29,10 +29,10 @@ const RequestModal = ({
return (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}

@ -169,15 +169,19 @@ export const GenreSelector = ({
loadDefaultGenre();
}, [defaultValue, type]);
const loadGenreOptions = async () => {
const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
return results.data
.map((result) => ({
label: result.name,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
useEffect(() => {
onChange(watchRegion, activeProvider);
}, [activeProvider, watchRegion, onChange]);
// removed onChange as a dependency as we only need to call it when the value(s) change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProvider, watchRegion]);
const orderedData = useMemo(() => {
if (!data) {
@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
<SmallLoadingSpinner />
) : (
<div className="grid">
<div className="grid grid-cols-6 gap-2">
<div className="provider-icons grid gap-2">
{initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
})}
</div>
{showMore && otherProviders.length > 0 && (
<div className="relative top-2 grid grid-cols-6 gap-2">
<div className="provider-icons relative top-2 grid gap-2">
{otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'

@ -32,7 +32,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
aria-hidden="true"
className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0'
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
} relative inline-block h-5 w-5 rounded-full bg-white shadow transition duration-200 ease-in-out`}
>
<span
className={`${

@ -57,6 +57,9 @@ const messages = defineMessages({
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
@ -214,10 +217,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: radarr?.externalUrl,
syncEnabled: radarr?.syncEnabled ?? false,
enableSearch: !radarr?.preventSearch,
tagRequests: radarr?.tagRequests ?? false,
}}
validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => {
@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission);
@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);

@ -63,10 +63,10 @@ const Release = ({ currentVersion, release, isLatest }: ReleaseProps) => {
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={isModalOpen}

@ -53,6 +53,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',
@ -67,6 +68,8 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
editJobScheduleSelectorSeconds:
'Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}',
imagecache: 'Image Cache',
imagecacheDescription:
'When enabled in settings, Overseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
@ -78,7 +81,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@ -89,10 +92,11 @@ type JobModalState = {
job?: Job;
scheduleHours: number;
scheduleMinutes: number;
scheduleSeconds: number;
};
type JobModalAction =
| { type: 'set'; hours?: number; minutes?: number }
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'close';
}
@ -115,6 +119,7 @@ const jobModalReducer = (
job: action.job,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
};
case 'set':
@ -122,6 +127,7 @@ const jobModalReducer = (
...state,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
};
}
};
@ -149,6 +155,7 @@ const SettingsJobs = () => {
isOpen: false,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
});
const [isSaving, setIsSaving] = useState(false);
@ -200,9 +207,11 @@ const SettingsJobs = () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try {
if (jobModalState.job?.interval === 'short') {
if (jobModalState.job?.interval === 'seconds') {
jobScheduleCron.splice(0, 2, `*/${jobModalState.scheduleSeconds}`, '*');
} else if (jobModalState.job?.interval === 'minutes') {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'long') {
} else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else {
// jobs with interval: fixed should not be editable
@ -244,10 +253,10 @@ const SettingsJobs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={jobModalState.isOpen}
@ -286,7 +295,30 @@ const SettingsJobs = () => {
{intl.formatMessage(messages.editJobSchedulePrompt)}
</label>
<div className="form-input-area">
{jobModalState.job?.interval === 'short' ? (
{jobModalState.job?.interval === 'seconds' ? (
<select
name="jobScheduleSeconds"
className="inline"
value={jobModalState.scheduleSeconds}
onChange={(e) =>
dispatch({
type: 'set',
seconds: Number(e.target.value),
})
}
>
{[30, 45, 60].map((v) => (
<option value={v} key={`jobScheduleSeconds-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorSeconds,
{
jobScheduleSeconds: v,
}
)}
</option>
))}
</select>
) : jobModalState.job?.interval === 'minutes' ? (
<select
name="jobScheduleMinutes"
className="inline"

@ -143,10 +143,10 @@ const SettingsLogs = () => {
/>
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

@ -247,10 +247,10 @@ const SettingsServices = () => {
<Transition
as={Fragment}
show={deleteServerModal.open}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>

@ -62,6 +62,9 @@ const messages = defineMessages({
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
@ -223,10 +226,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
as="div"
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@ -252,6 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch,
tagRequests: sonarr?.tagRequests ?? false,
}}
validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => {
@ -292,6 +296,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission);
@ -960,6 +965,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);

@ -44,10 +44,10 @@ const StatusChecker = () => {
return (
<Transition
as={Fragment}
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear

@ -23,7 +23,7 @@ interface TitleCardProps {
summary?: string;
year?: string;
title: string;
userScore: number;
userScore?: number;
mediaType: MediaType;
status?: MediaStatus;
canExpand?: boolean;
@ -73,7 +73,9 @@ const TitleCard = ({
const showRequestButton = hasPermission(
[
Permission.REQUEST,
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{ type: 'or' }
);
@ -86,7 +88,13 @@ const TitleCard = ({
<RequestModal
tmdbId={id}
show={showRequestModal}
type={mediaType === 'movie' ? 'movie' : 'tv'}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
onComplete={requestComplete}
onUpdating={requestUpdating}
onCancel={closeModal}
@ -130,7 +138,7 @@ const TitleCard = ({
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie'
mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
}`}
@ -138,10 +146,12 @@ const TitleCard = ({
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{mediaType === 'movie'
? intl.formatMessage(globalMessages.movie)
: mediaType === 'collection'
? intl.formatMessage(globalMessages.collection)
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
{currentStatus && (
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
<div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini
status={currentStatus}
@ -154,10 +164,10 @@ const TitleCard = ({
<Transition
as={Fragment}
show={isUpdating}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@ -169,15 +179,23 @@ const TitleCard = ({
<Transition
as={Fragment}
show={!image || showDetail || showRequestModal}
enter="transition transform opacity-0"
enter="transition-opacity"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition transform opacity-100"
leave="transition-opacity"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<Link
href={
mediaType === 'movie'
? `/movie/${id}`
: mediaType === 'collection'
? `/collection/${id}`
: `/tv/${id}`
}
>
<a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
style={{

@ -30,6 +30,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowRightCircleIcon,
@ -109,6 +110,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
mutate: revalidate,
} = useSWR<TvDetailsType>(`/api/v1/tv/${router.query.tvId}`, {
fallbackData: tv,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: tv?.mediaInfo?.downloadStatus,
downloadStatus4k: tv?.mediaInfo?.downloadStatus4k,
},
15000
),
});
const { data: ratingData } = useSWR<RTRating>(
@ -727,18 +735,18 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)}
<ChevronDownIcon
className={`${
open ? 'rotate-180 transform' : ''
open ? 'rotate-180' : ''
} h-6 w-6 text-gray-500`}
/>
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
enter="transition-opacity duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-75 ease-out"
leaveFrom="opacity-100"
leaveTo="opacity-0"
// Not sure why this transition is adding a margin without this here
style={{ margin: '0px' }}
>

@ -155,7 +155,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
aria-hidden="true"
className={`${
isAllUsers() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
@ -194,7 +194,7 @@ const PlexImportModal = ({ onCancel, onComplete }: PlexImportProps) => {
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>

@ -228,10 +228,10 @@ const UserList = () => {
<PageTitle title={intl.formatMessage(messages.users)} />
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
@ -257,10 +257,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={createModal.isOpen}
@ -440,10 +440,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showBulkEditModal}
@ -461,10 +461,10 @@ const UserList = () => {
<Transition
as="div"
enter="opacity-0 transition duration-300"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showImportModal}

@ -15,13 +15,20 @@ export const useLockBodyScroll = (
disabled?: boolean
): void => {
useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow;
const originalOverflowStyle = window.getComputedStyle(
document.body
).overflow;
const originalTouchActionStyle = window.getComputedStyle(
document.body
).touchAction;
if (isLocked && !disabled) {
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
}
return () => {
if (!disabled) {
document.body.style.overflow = originalStyle;
document.body.style.overflow = originalOverflowStyle;
document.body.style.touchAction = originalTouchActionStyle;
}
};
}, [isLocked, disabled]);

@ -16,6 +16,7 @@ const globalMessages = defineMessages({
approved: 'Approved',
movie: 'Movie',
movies: 'Movies',
collection: 'Collection',
tvshow: 'Series',
tvshows: 'Series',
cancel: 'Cancel',

@ -76,7 +76,9 @@
"components.Discover.FilterSlideover.streamingservices": "Streaming Services",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
"components.Discover.FilterSlideover.tmdbuservotecount": "TMDB User Vote Count",
"components.Discover.FilterSlideover.to": "To",
"components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}",
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
"components.Discover.NetworkSlider.networks": "Networks",
@ -105,11 +107,13 @@
"components.Discover.studios": "Studios",
"components.Discover.tmdbmoviegenre": "TMDB Movie Genre",
"components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword",
"components.Discover.tmdbmoviestreamingservices": "TMDB Movie Streaming Services",
"components.Discover.tmdbnetwork": "TMDB Network",
"components.Discover.tmdbsearch": "TMDB Search",
"components.Discover.tmdbstudio": "TMDB Studio",
"components.Discover.tmdbtvgenre": "TMDB Series Genre",
"components.Discover.tmdbtvkeyword": "TMDB Series Keyword",
"components.Discover.tmdbtvstreamingservices": "TMDB TV Streaming Services",
"components.Discover.trending": "Trending",
"components.Discover.tvgenres": "Series Genres",
"components.Discover.upcoming": "Upcoming Movies",
@ -680,6 +684,8 @@
"components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
"components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.RadarrModal.tags": "Tags",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
@ -721,6 +727,7 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.uptodate": "Up to Date",
"components.Settings.SettingsAbout.version": "Version",
"components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync",
"components.Settings.SettingsJobsCache.cache": "Cache",
"components.Settings.SettingsJobsCache.cacheDescription": "Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.",
"components.Settings.SettingsJobsCache.cacheflushed": "{cachename} cache flushed.",
@ -739,6 +746,7 @@
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
@ -855,6 +863,8 @@
"components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.ssl": "Use SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Scan",
"components.Settings.SonarrModal.tagRequests": "Tag Requests",
"components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
"components.Settings.SonarrModal.tags": "Tags",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
@ -1172,6 +1182,7 @@
"i18n.cancel": "Cancel",
"i18n.canceling": "Canceling…",
"i18n.close": "Close",
"i18n.collection": "Collection",
"i18n.decline": "Decline",
"i18n.declined": "Declined",
"i18n.delete": "Delete",

@ -1,7 +1,7 @@
import MovieDetails from '@app/components/MovieDetails';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import axios from 'axios';
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
interface MoviePageProps {
movie?: MovieDetailsType;
@ -11,25 +11,25 @@ const MoviePage: NextPage<MoviePageProps> = ({ movie }) => {
return <MovieDetails movie={movie} />;
};
MoviePage.getInitialProps = async (ctx) => {
if (ctx.req) {
const response = await axios.get<MovieDetailsType>(
`http://localhost:${process.env.PORT || 5055}/api/v1/movie/${
ctx.query.movieId
}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
export const getServerSideProps: GetServerSideProps<MoviePageProps> = async (
ctx
) => {
const response = await axios.get<MovieDetailsType>(
`http://localhost:${process.env.PORT || 5055}/api/v1/movie/${
ctx.query.movieId
}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return {
return {
props: {
movie: response.data,
};
}
return {};
},
};
};
export default MoviePage;

@ -1,7 +1,7 @@
import TvDetails from '@app/components/TvDetails';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
import axios from 'axios';
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
interface TvPageProps {
tv?: TvDetailsType;
@ -11,25 +11,23 @@ const TvPage: NextPage<TvPageProps> = ({ tv }) => {
return <TvDetails tv={tv} />;
};
TvPage.getInitialProps = async (ctx) => {
if (ctx.req) {
const response = await axios.get<TvDetailsType>(
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${
ctx.query.tvId
}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
export const getServerSideProps: GetServerSideProps<TvPageProps> = async (
ctx
) => {
const response = await axios.get<TvDetailsType>(
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return {
return {
props: {
tv: response.data,
};
}
return {};
},
};
};
export default TvPage;

@ -17,7 +17,7 @@
body {
@apply bg-gray-900;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
}
code {
@ -43,8 +43,8 @@
}
.slideover {
padding-top: calc(1rem + env(safe-area-inset-top)) !important;
padding-bottom: calc(1rem + env(safe-area-inset-top)) !important;
padding-top: calc(0.75rem + env(safe-area-inset-top)) !important;
padding-bottom: calc(0.75rem + env(safe-area-inset-top)) !important;
}
.sidebar-close-button {
@ -73,6 +73,10 @@
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
}
.provider-icons {
grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
}
.slider-header {
@apply relative mt-6 mb-4 flex;
}

@ -0,0 +1,18 @@
import type { DownloadingItem } from '@server/lib/downloadtracker';
export const refreshIntervalHelper = (
downloadItem: {
downloadStatus: DownloadingItem[] | undefined;
downloadStatus4k: DownloadingItem[] | undefined;
},
timer: number
) => {
if (
(downloadItem.downloadStatus ?? []).length > 0 ||
(downloadItem.downloadStatus4k ?? []).length > 0
) {
return timer;
} else {
return 0;
}
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save