Merge upstream/develop

This commit is contained in:
Fallenbagel
2023-06-10 04:55:26 +05:00
39 changed files with 2738 additions and 1997 deletions

View File

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

14
.github/CODEOWNERS vendored
View File

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

View File

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

View File

@@ -4505,6 +4505,16 @@ paths:
schema: schema:
type: number type: number
example: 10 example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query - in: query
name: watchRegion name: watchRegion
schema: schema:
@@ -4784,6 +4794,16 @@ paths:
schema: schema:
type: number type: number
example: 10 example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query - in: query
name: watchRegion name: watchRegion
schema: schema:

View File

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

View File

@@ -76,6 +76,15 @@ export interface SonarrSeries {
ignoreEpisodesWithoutFiles?: boolean; ignoreEpisodesWithoutFiles?: boolean;
searchForMissingEpisodes?: boolean; searchForMissingEpisodes?: boolean;
}; };
statistics: {
seasonCount: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
releaseGroups: string[];
percentOfEpisodes: number;
};
} }
export interface AddSeriesOptions { 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[]> { public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try { try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', { const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {

View File

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

View File

@@ -28,6 +28,18 @@ export interface TmdbTvResult extends TmdbMediaResult {
first_air_date: string; 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 { export interface TmdbPersonResult {
id: number; id: number;
name: string; name: string;
@@ -45,7 +57,12 @@ interface TmdbPaginatedResponse {
} }
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[]; results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
} }
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {

View File

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

View File

@@ -704,7 +704,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory; let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId; let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags; let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
if ( if (
this.rootFolder && this.rootFolder &&
@@ -764,6 +764,38 @@ export class MediaRequest {
return; 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 ( if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) { ) {
@@ -970,7 +1002,11 @@ export class MediaRequest {
let tags = let tags =
seriesType === 'anime' seriesType === 'anime'
? sonarrSettings.animeTags ? sonarrSettings.animeTags
: sonarrSettings.tags; ? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
if ( if (
this.rootFolder && 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 = { const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile, profileId: qualityProfile,
languageProfileId: languageProfile, languageProfileId: languageProfile,

View File

@@ -1,7 +1,8 @@
import type { PlexMetadata } from '@server/api/plexapi'; import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI 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 RadarrAPI from '@server/api/servarr/radarr';
import type { SonarrSeason } from '@server/api/servarr/sonarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
@@ -47,158 +48,150 @@ class AvailabilitySync {
try { try {
for await (const media of this.loadAvailableMediaPaginated(pageSize)) { for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
try { if (!this.running) {
if (!this.running) { throw new Error('Job aborted');
throw new Error('Job aborted'); }
}
const mediaExists = await this.mediaExists(media); 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 // We can not delete media so if both versions do not exist, we will change both columns to unknown or null
if (!mediaExists) { if (!mediaExists) {
if ( if (
media.status !== MediaStatus.UNKNOWN || media.status !== MediaStatus.UNKNOWN ||
media.status4k !== MediaStatus.UNKNOWN media.status4k !== MediaStatus.UNKNOWN
) { ) {
const request = await requestRepository.find({ const request = await requestRepository.find({
relations: { relations: {
media: true, media: true,
}, },
where: { media: { id: media.id } }, where: { media: { id: media.id } },
});
logger.info(
`${
media.mediaType === 'tv' ? media.tvdbId : media.tmdbId
} does not exist in any of your media instances. We will change its status to unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.update(media.id, {
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
serviceId: null,
serviceId4k: null,
externalServiceId: null,
externalServiceId4k: null,
externalServiceSlug: null,
externalServiceSlug4k: null,
ratingKey: null,
ratingKey4k: null,
});
await requestRepository.remove(request);
}
}
if (media.mediaType === 'tv') {
// ok, the show itself exists, but do all it's seasons?
const seasons = await seasonRepository.find({
where: [
{ status: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
{ status4k: MediaStatus.AVAILABLE, media: { id: media.id } },
{
status4k: MediaStatus.PARTIALLY_AVAILABLE,
media: { id: media.id },
},
],
}); });
let didDeleteSeasons = false; logger.info(
for (const season of seasons) { `Media ID ${media.id} does not exist in any of your media instances. Status will be changed to unknown.`,
if ( { label: 'AvailabilitySync' }
!mediaExists && );
(season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN) await mediaRepository.update(media.id, {
) { status: MediaStatus.UNKNOWN,
await seasonRepository.update( status4k: MediaStatus.UNKNOWN,
{ id: season.id }, 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(
{ {
status: MediaStatus.UNKNOWN, relations: {
status4k: MediaStatus.UNKNOWN, request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
} }
); );
} else {
const seasonExists = await this.seasonExists(media, season);
if (!seasonExists) { if (seasonToBeDeleted) {
logger.info( await seasonRequestRepository.remove(seasonToBeDeleted);
`Removing season ${season.seasonNumber}, media id: ${media.tvdbId} because it does not exist in any of your media instances.`,
{ label: 'AvailabilitySync' }
);
if (
season.status !== MediaStatus.UNKNOWN ||
season.status4k !== MediaStatus.UNKNOWN
) {
await seasonRepository.update(
{ id: season.id },
{
status: MediaStatus.UNKNOWN,
status4k: MediaStatus.UNKNOWN,
}
);
}
const seasonToBeDeleted =
await seasonRequestRepository.findOne({
relations: {
request: {
media: true,
},
},
where: {
request: {
media: {
id: media.id,
},
},
seasonNumber: season.seasonNumber,
},
});
if (seasonToBeDeleted) {
await seasonRequestRepository.remove(seasonToBeDeleted);
}
didDeleteSeasons = true;
} }
didDeleteSeasons = true;
} }
}
if (didDeleteSeasons) { if (didDeleteSeasons) {
if ( if (
media.status === MediaStatus.AVAILABLE || media.status === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.AVAILABLE media.status4k === MediaStatus.AVAILABLE
) { ) {
logger.info( logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`, `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
if (media.status === MediaStatus.AVAILABLE) { if (media.status === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, { await mediaRepository.update(media.id, {
status: MediaStatus.PARTIALLY_AVAILABLE, status: MediaStatus.PARTIALLY_AVAILABLE,
}); });
} }
if (media.status4k === MediaStatus.AVAILABLE) { if (media.status4k === MediaStatus.AVAILABLE) {
await mediaRepository.update(media.id, { await mediaRepository.update(media.id, {
status4k: MediaStatus.PARTIALLY_AVAILABLE, status4k: MediaStatus.PARTIALLY_AVAILABLE,
}); });
}
} }
} }
} }
} }
} catch (ex) {
logger.error('Failure with media.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} }
} }
} catch (ex) { } catch (ex) {
@@ -254,9 +247,9 @@ class AvailabilitySync {
}); });
logger.info( logger.info(
`${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${ `Media ID ${media.id} does not exist in your ${is4k ? '4k' : 'non-4k'} ${
isTVType ? 'sonarr' : 'radarr' isTVType ? 'Sonarr' : 'Radarr'
} and plex instance. We will change its status to unknown.`, } and Plex instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
@@ -306,46 +299,70 @@ class AvailabilitySync {
apiKey: server.apiKey, apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'), url: RadarrAPI.buildUrl(server, '/api/v3'),
}); });
const meta = await api.getMovieByTmdbId(media.tmdbId); try {
// Check if both exist or if a single non-4k or 4k exists
// If both do not exist we will return false
//check if both exist or if a single non-4k or 4k exists let meta: RadarrMovie | undefined;
//if both do not exist we will return false
if (!server.is4k && !meta.id) {
existsInRadarr = false;
}
if (server.is4k && !meta.id) { if (!server.is4k && media.externalServiceId) {
existsInRadarr4k = false; 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 (existsInRadarr && existsInRadarr4k) { // If only a single non-4k or 4k exists, then change entity columns accordingly
return true; // Related media request will then be deleted
} if (
!existsInRadarr &&
if (!existsInRadarr && existsInPlex) { (existsInRadarr4k || existsInPlex4k) &&
return true; !existsInPlex
} ) {
if (!existsInRadarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInRadarr && existsInRadarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) { if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false); this.mediaUpdater(media, false);
} }
} }
if (existsInRadarr && !existsInRadarr4k && !existsInPlex4k) { if (
(existsInRadarr || existsInPlex) &&
!existsInRadarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) { if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true); this.mediaUpdater(media, true);
} }
} }
if (existsInRadarr || existsInRadarr4k) { if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) {
return true; return true;
} }
@@ -357,10 +374,6 @@ class AvailabilitySync {
existsInPlex: boolean, existsInPlex: boolean,
existsInPlex4k: boolean existsInPlex4k: boolean
): Promise<boolean> { ): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let existsInSonarr = true; let existsInSonarr = true;
let existsInSonarr4k = true; let existsInSonarr4k = true;
@@ -369,49 +382,75 @@ class AvailabilitySync {
apiKey: server.apiKey, apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'), 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
const meta = await api.getSeriesByTvdbId(media.tvdbId); let meta: SonarrSeries | undefined;
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = meta.seasons; if (!server.is4k && media.externalServiceId) {
meta = await api.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
meta.seasons;
}
//check if both exist or if a single non-4k or 4k exists if (server.is4k && media.externalServiceId4k) {
//if both do not exist we will return false meta = await api.getSeriesById(media.externalServiceId4k);
if (!server.is4k && !meta.id) { this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
existsInSonarr = false; meta.seasons;
} }
if (server.is4k && !meta.id) { if (!server.is4k && (!meta || meta.statistics.episodeFileCount === 0)) {
existsInSonarr4k = false; 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 (existsInSonarr && existsInSonarr4k) { // If only a single non-4k or 4k exists, then change entity columns accordingly
return true; // Related media request will then be deleted
} if (
!existsInSonarr &&
if (!existsInSonarr && existsInPlex) { (existsInSonarr4k || existsInPlex4k) &&
return true; !existsInPlex
} ) {
if (!existsInSonarr4k && existsInPlex4k) {
return true;
}
//if only a single non-4k or 4k exists, then change entity columns accordingly
//related media request will then be deleted
if (!existsInSonarr && existsInSonarr4k && !existsInPlex) {
if (media.status !== MediaStatus.UNKNOWN) { if (media.status !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, false); this.mediaUpdater(media, false);
} }
} }
if (existsInSonarr && !existsInSonarr4k && !existsInPlex4k) { if (
(existsInSonarr || existsInPlex) &&
!existsInSonarr4k &&
!existsInPlex4k
) {
if (media.status4k !== MediaStatus.UNKNOWN) { if (media.status4k !== MediaStatus.UNKNOWN) {
this.mediaUpdater(media, true); this.mediaUpdater(media, true);
} }
} }
if (existsInSonarr || existsInSonarr4k) { if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) {
return true; return true;
} }
@@ -424,10 +463,6 @@ class AvailabilitySync {
seasonExistsInPlex: boolean, seasonExistsInPlex: boolean,
seasonExistsInPlex4k: boolean seasonExistsInPlex4k: boolean
): Promise<boolean> { ): Promise<boolean> {
if (!media.tvdbId) {
return false;
}
let seasonExistsInSonarr = true; let seasonExistsInSonarr = true;
let seasonExistsInSonarr4k = true; let seasonExistsInSonarr4k = true;
@@ -441,35 +476,67 @@ class AvailabilitySync {
url: SonarrAPI.buildUrl(server, '/api/v3'), url: SonarrAPI.buildUrl(server, '/api/v3'),
}); });
const seasons = try {
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ?? // Here we can use the cache we built when we fetched the series with mediaExistsInSonarr
(await api.getSeriesByTvdbId(media.tvdbId)).seasons; // If the cache does not have data, we will fetch with the api route
this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons;
const hasMonitoredSeason = seasons.find( let seasons: SonarrSeason[] =
({ monitored, seasonNumber }) => this.sonarrSeasonsCache[
monitored && season.seasonNumber === seasonNumber `${server.id}-${
); !server.is4k ? media.externalServiceId : media.externalServiceId4k
}`
];
if (!server.is4k && !hasMonitoredSeason) { if (!server.is4k && media.externalServiceId) {
seasonExistsInSonarr = false; 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;
}
} }
if (server.is4k && !hasMonitoredSeason) {
seasonExistsInSonarr4k = false;
}
}
if (seasonExistsInSonarr && seasonExistsInSonarr4k) {
return true;
}
if (!seasonExistsInSonarr && seasonExistsInPlex) {
return true;
}
if (!seasonExistsInSonarr4k && seasonExistsInPlex4k) {
return true;
} }
const seasonToBeDeleted = await seasonRequestRepository.findOne({ const seasonToBeDeleted = await seasonRequestRepository.findOne({
@@ -489,16 +556,16 @@ class AvailabilitySync {
}, },
}); });
//if season does not exist, we will change status to unknown and delete related season request // 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 parent media request is empty(all related seasons have been removed), parent is automatically deleted
if ( if (
!seasonExistsInSonarr && !seasonExistsInSonarr &&
seasonExistsInSonarr4k && (seasonExistsInSonarr4k || seasonExistsInPlex4k) &&
!seasonExistsInPlex !seasonExistsInPlex
) { ) {
if (season.status !== MediaStatus.UNKNOWN) { if (season.status !== MediaStatus.UNKNOWN) {
logger.info( logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your non-4k sonarr and plex instance. We will change its status to unknown.`, `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' } { label: 'AvailabilitySync' }
); );
await seasonRepository.update(season.id, { await seasonRepository.update(season.id, {
@@ -511,7 +578,7 @@ class AvailabilitySync {
if (media.status === MediaStatus.AVAILABLE) { if (media.status === MediaStatus.AVAILABLE) {
logger.info( logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
await mediaRepository.update(media.id, { await mediaRepository.update(media.id, {
@@ -522,13 +589,13 @@ class AvailabilitySync {
} }
if ( if (
seasonExistsInSonarr && (seasonExistsInSonarr || seasonExistsInPlex) &&
!seasonExistsInSonarr4k && !seasonExistsInSonarr4k &&
!seasonExistsInPlex4k !seasonExistsInPlex4k
) { ) {
if (season.status4k !== MediaStatus.UNKNOWN) { if (season.status4k !== MediaStatus.UNKNOWN) {
logger.info( logger.info(
`${media.tvdbId}, season: ${season.seasonNumber} does not exist in your 4k sonarr and plex instance. We will change its status to unknown.`, `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' } { label: 'AvailabilitySync' }
); );
await seasonRepository.update(season.id, { await seasonRepository.update(season.id, {
@@ -541,7 +608,7 @@ class AvailabilitySync {
if (media.status4k === MediaStatus.AVAILABLE) { if (media.status4k === MediaStatus.AVAILABLE) {
logger.info( logger.info(
`Marking media id: ${media.tvdbId} as PARTIALLY_AVAILABLE because we deleted one of its seasons.`, `Marking media ID ${media.id} as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
await mediaRepository.update(media.id, { await mediaRepository.update(media.id, {
@@ -551,7 +618,12 @@ class AvailabilitySync {
} }
} }
if (seasonExistsInSonarr || seasonExistsInSonarr4k) { if (
seasonExistsInSonarr ||
seasonExistsInSonarr4k ||
seasonExistsInPlex ||
seasonExistsInPlex4k
) {
return true; return true;
} }
@@ -565,7 +637,7 @@ class AvailabilitySync {
let existsInPlex = false; let existsInPlex = false;
let existsInPlex4k = false; let existsInPlex4k = false;
//check each plex instance to see if media exists // Check each plex instance to see if media exists
try { try {
if (ratingKey) { if (ratingKey) {
const meta = await this.plexClient?.getMetadata(ratingKey); const meta = await this.plexClient?.getMetadata(ratingKey);
@@ -573,6 +645,7 @@ class AvailabilitySync {
existsInPlex = true; existsInPlex = true;
} }
} }
if (ratingKey4k) { if (ratingKey4k) {
const meta4k = await this.plexClient?.getMetadata(ratingKey4k); const meta4k = await this.plexClient?.getMetadata(ratingKey4k);
if (meta4k) { if (meta4k) {
@@ -580,18 +653,17 @@ class AvailabilitySync {
} }
} }
} catch (ex) { } catch (ex) {
// TODO: oof, not the nicest way of handling this, but plex-api does not leave us with any other options...
if (!ex.message.includes('response code: 404')) { if (!ex.message.includes('response code: 404')) {
throw ex; throw ex;
} }
} }
//base case for if both media versions exist in plex // Base case if both media versions exist in plex
if (existsInPlex && existsInPlex4k) { if (existsInPlex && existsInPlex4k) {
return true; return true;
} }
//we then check radarr or sonarr has that specific media. If not, then we will move to delete // 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 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') { if (media.mediaType === 'movie') {
const existsInRadarr = await this.mediaExistsInRadarr( const existsInRadarr = await this.mediaExistsInRadarr(
media, media,
@@ -599,10 +671,10 @@ class AvailabilitySync {
existsInPlex4k existsInPlex4k
); );
//if true, media exists in at least one radarr or plex instance. // If true, media exists in at least one radarr or plex instance.
if (existsInRadarr) { if (existsInRadarr) {
logger.warn( logger.warn(
`${media.tmdbId} exists in at least one radarr or plex instance. Media will be updated if set to available.`, `${media.id} exists in at least one Radarr or Plex instance. Media will be updated if set to available.`,
{ {
label: 'AvailabilitySync', label: 'AvailabilitySync',
} }
@@ -619,10 +691,10 @@ class AvailabilitySync {
existsInPlex4k existsInPlex4k
); );
//if true, media exists in at least one sonarr or plex instance. // If true, media exists in at least one sonarr or plex instance.
if (existsInSonarr) { if (existsInSonarr) {
logger.warn( logger.warn(
`${media.tvdbId} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, `${media.id} exists in at least one Sonarr or Plex instance. Media will be updated if set to available.`,
{ {
label: 'AvailabilitySync', label: 'AvailabilitySync',
} }
@@ -672,7 +744,7 @@ class AvailabilitySync {
} }
} }
//base case for if both season versions exist in plex // Base case if both season versions exist in plex
if (seasonExistsInPlex && seasonExistsInPlex4k) { if (seasonExistsInPlex && seasonExistsInPlex4k) {
return true; return true;
} }
@@ -686,7 +758,7 @@ class AvailabilitySync {
if (existsInSonarr) { if (existsInSonarr) {
logger.warn( logger.warn(
`${media.tvdbId}, season: ${season.seasonNumber} exists in at least one sonarr or plex instance. Media will be updated if set to available.`, `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', label: 'AvailabilitySync',
} }

View File

@@ -69,6 +69,7 @@ export interface DVRSettings {
externalUrl?: string; externalUrl?: string;
syncEnabled: boolean; syncEnabled: boolean;
preventSearch: boolean; preventSearch: boolean;
tagRequests: boolean;
} }
export interface RadarrSettings extends DVRSettings { export interface RadarrSettings extends DVRSettings {

View File

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

View File

@@ -15,12 +15,13 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie'; import { mapProductionCompany } from '@server/models/Movie';
import { import {
mapCollectionResult,
mapMovieResult, mapMovieResult,
mapPersonResult, mapPersonResult,
mapTvResult, mapTvResult,
} from '@server/models/Search'; } from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv'; 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 { Router } from 'express';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { z } from 'zod'; import { z } from 'zod';
@@ -65,6 +66,8 @@ const QueryFilterOptions = z.object({
withRuntimeLte: z.coerce.string().optional(), withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(), voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(), voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(), network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(),
@@ -96,6 +99,8 @@ discoverRoutes.get('/movies', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte, withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte, voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte, voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders, watchProviders: query.watchProviders,
watchRegion: query.watchRegion, watchRegion: query.watchRegion,
}); });
@@ -376,6 +381,8 @@ discoverRoutes.get('/tv', async (req, res, next) => {
withRuntimeLte: query.withRuntimeLte, withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte, voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte, voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders, watchProviders: query.watchProviders,
watchRegion: query.watchRegion, watchRegion: query.watchRegion,
}); });
@@ -659,6 +666,8 @@ discoverRoutes.get('/trending', async (req, res, next) => {
) )
: isPerson(result) : isPerson(result)
? mapPersonResult(result) ? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult( : mapTvResult(
result, result,
media.find( media.find(

View File

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

View File

@@ -383,7 +383,14 @@ router.delete<{ id: string }>(
* we manually remove all requests from the user here so the parent media's * we manually remove all requests from the user here so the parent media's
* properly reflect the change. * 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); await userRepository.delete(user.id);
return res.status(200).json(user.filter()); return res.status(200).json(user.filter());

View File

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

View File

@@ -349,7 +349,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
/> />
))} ))}
/> />
<div className="pb-8" /> <div className="extra-bottom-space relative" />
</div> </div>
); );
}; };

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants'; import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider'; import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type { import type {
TmdbCompanySearchResponse, TmdbCompanySearchResponse,
@@ -55,7 +56,7 @@ type CreateOption = {
dataUrl: string; dataUrl: string;
params?: string; params?: string;
titlePlaceholderText: string; titlePlaceholderText: string;
dataPlaceholderText: string; dataPlaceholderText?: string;
}; };
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => { const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder), titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch), 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 ( return (
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
/> />
); );
break; 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: default:
dataInput = ( dataInput = (
<Field <Field
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
'$value', '$value',
encodeURIExtraParams(values.data) encodeURIExtraParams(values.data)
)} )}
extraParams={activeOption.params?.replace( extraParams={
'$value', activeOption.type ===
encodeURIExtraParams(values.data) 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} onNewTitles={updateResultCount}
/> />
</div> </div>

View File

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

View File

@@ -35,8 +35,10 @@ const messages = defineMessages({
ratingText: 'Ratings between {minValue} and {maxValue}', ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters', clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score', tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime', runtime: 'Runtime',
streamingservices: 'Streaming Services', streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
}); });
type FilterSlideoverProps = { type FilterSlideoverProps = {
@@ -246,6 +248,45 @@ const FilterSlideover = ({
})} })}
/> />
</div> </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"> <span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)} {intl.formatMessage(messages.streamingservices)}
</span> </span>

View File

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

View File

@@ -365,6 +365,36 @@ const Discover = () => {
/> />
); );
break; 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) { if (isEditing) {

View File

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

View File

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

View File

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

View File

@@ -169,15 +169,19 @@ export const GenreSelector = ({
loadDefaultGenre(); loadDefaultGenre();
}, [defaultValue, type]); }, [defaultValue, type]);
const loadGenreOptions = async () => { const loadGenreOptions = async (inputValue: string) => {
const results = await axios.get<GenreSliderItem[]>( const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}` `/api/v1/discover/genreslider/${type}`
); );
return results.data.map((result) => ({ return results.data
label: result.name, .map((result) => ({
value: result.id, label: result.name,
})); value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
}; };
return ( return (
@@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
useEffect(() => { useEffect(() => {
onChange(watchRegion, activeProvider); 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(() => { const orderedData = useMemo(() => {
if (!data) { if (!data) {
@@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
<SmallLoadingSpinner /> <SmallLoadingSpinner />
) : ( ) : (
<div className="grid"> <div className="grid">
<div className="grid grid-cols-6 gap-2"> <div className="provider-icons grid gap-2">
{initialProviders.map((provider) => { {initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id); const isActive = activeProvider.includes(provider.id);
return ( return (
@@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <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 isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
})} })}
</div> </div>
{showMore && otherProviders.length > 0 && ( {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) => { {otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id); const isActive = activeProvider.includes(provider.id);
return ( return (
@@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`} key={`prodiver-${provider.id}`}
> >
<div <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 isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500' ? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600' : 'bg-gray-700 ring-gray-500 hover:bg-gray-600'

View File

@@ -57,6 +57,9 @@ const messages = defineMessages({
testFirstTags: 'Test connection to load tags', testFirstTags: 'Test connection to load tags',
tags: 'Tags', tags: 'Tags',
enableSearch: 'Enable Automatic Search', 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', validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash', validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
@@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: radarr?.externalUrl, externalUrl: radarr?.externalUrl,
syncEnabled: radarr?.syncEnabled ?? false, syncEnabled: radarr?.syncEnabled ?? false,
enableSearch: !radarr?.preventSearch, enableSearch: !radarr?.preventSearch,
tagRequests: radarr?.tagRequests ?? false,
}} }}
validationSchema={RadarrSettingsSchema} validationSchema={RadarrSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
@@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
externalUrl: values.externalUrl, externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled, syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch, preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
}; };
if (!radarr) { if (!radarr) {
await axios.post('/api/v1/settings/radarr', submission); await axios.post('/api/v1/settings/radarr', submission);
@@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
/> />
</div> </div>
</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> </div>
</Modal> </Modal>
); );

View File

@@ -62,6 +62,9 @@ const messages = defineMessages({
syncEnabled: 'Enable Scan', syncEnabled: 'Enable Scan',
externalUrl: 'External URL', externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search', 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', validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash', validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
@@ -252,6 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: sonarr?.externalUrl, externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false, syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch, enableSearch: !sonarr?.preventSearch,
tagRequests: sonarr?.tagRequests ?? false,
}} }}
validationSchema={SonarrSettingsSchema} validationSchema={SonarrSettingsSchema}
onSubmit={async (values) => { onSubmit={async (values) => {
@@ -292,6 +296,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
externalUrl: values.externalUrl, externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled, syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch, preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
}; };
if (!sonarr) { if (!sonarr) {
await axios.post('/api/v1/settings/sonarr', submission); await axios.post('/api/v1/settings/sonarr', submission);
@@ -960,6 +965,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
/> />
</div> </div>
</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> </div>
</Modal> </Modal>
); );

View File

@@ -32,7 +32,7 @@ interface TitleCardProps {
summary?: string; summary?: string;
year?: string; year?: string;
title: string; title: string;
userScore: number; userScore?: number;
mediaType: MediaType; mediaType: MediaType;
status?: MediaStatus; status?: MediaStatus;
canExpand?: boolean; canExpand?: boolean;
@@ -157,7 +157,9 @@ const TitleCard = ({
const showRequestButton = hasPermission( const showRequestButton = hasPermission(
[ [
Permission.REQUEST, Permission.REQUEST,
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV, mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
], ],
{ type: 'or' } { type: 'or' }
); );
@@ -170,7 +172,13 @@ const TitleCard = ({
<RequestModal <RequestModal
tmdbId={id} tmdbId={id}
show={showRequestModal} show={showRequestModal}
type={mediaType === 'movie' ? 'movie' : 'tv'} type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
}
onComplete={requestComplete} onComplete={requestComplete}
onUpdating={requestUpdating} onUpdating={requestUpdating}
onCancel={closeModal} onCancel={closeModal}
@@ -214,7 +222,7 @@ const TitleCard = ({
<div className="absolute left-0 right-0 flex items-center justify-between p-2"> <div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div <div
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${ 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-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600' : 'border-purple-600 bg-purple-600'
}`} }`}
@@ -222,6 +230,8 @@ 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"> <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' {mediaType === 'movie'
? intl.formatMessage(globalMessages.movie) ? intl.formatMessage(globalMessages.movie)
: mediaType === 'collection'
? intl.formatMessage(globalMessages.collection)
: intl.formatMessage(globalMessages.tvshow)} : intl.formatMessage(globalMessages.tvshow)}
</div> </div>
</div> </div>
@@ -283,7 +293,15 @@ const TitleCard = ({
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="absolute inset-0 overflow-hidden rounded-xl"> <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 <a
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left" className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
style={{ style={{

View File

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

View File

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

View File

@@ -14,6 +14,24 @@
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series", "components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist", "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist",
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}",
"components.Discover.FilterSlideover.clearfilters": "Clear Active Filters",
"components.Discover.FilterSlideover.filters": "Filters",
"components.Discover.FilterSlideover.firstAirDate": "First Air Date",
"components.Discover.FilterSlideover.from": "From",
"components.Discover.FilterSlideover.genres": "Genres",
"components.Discover.FilterSlideover.keywords": "Keywords",
"components.Discover.FilterSlideover.originalLanguage": "Original Language",
"components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}",
"components.Discover.FilterSlideover.releaseDate": "Release Date",
"components.Discover.FilterSlideover.runtime": "Runtime",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
"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.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
"components.Discover.NetworkSlider.networks": "Networks", "components.Discover.NetworkSlider.networks": "Networks",
@@ -30,6 +48,21 @@
"components.Discover.populartv": "Popular Series", "components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added", "components.Discover.recentlyAdded": "Recently Added",
"components.Discover.recentrequests": "Recent Requests", "components.Discover.recentrequests": "Recent Requests",
"components.Discover.resetfailed": "Something went wrong resetting the discover customization settings.",
"components.Discover.resetsuccess": "Sucessfully reset discover customization settings.",
"components.Discover.resettodefault": "Reset to Default",
"components.Discover.resetwarning": "Reset all sliders to default. This will also delete any custom sliders!",
"components.Discover.stopediting": "Stop Editing",
"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.trending": "Trending",
"components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcoming": "Upcoming Movies",
"components.Discover.upcomingmovies": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies",
@@ -590,6 +623,8 @@
"components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servername": "Server Name",
"components.Settings.RadarrModal.ssl": "Use SSL", "components.Settings.RadarrModal.ssl": "Use SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan", "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.tags": "Tags",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
@@ -742,6 +777,8 @@
"components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.ssl": "Use SSL", "components.Settings.SonarrModal.ssl": "Use SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Scan", "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.tags": "Tags",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles", "components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
@@ -1095,6 +1132,7 @@
"i18n.cancel": "Cancel", "i18n.cancel": "Cancel",
"i18n.canceling": "Canceling…", "i18n.canceling": "Canceling…",
"i18n.close": "Close", "i18n.close": "Close",
"i18n.collection": "Collection",
"i18n.decline": "Decline", "i18n.decline": "Decline",
"i18n.declined": "Declined", "i18n.declined": "Declined",
"i18n.delete": "Delete", "i18n.delete": "Delete",

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
body { body {
@apply bg-gray-900; @apply bg-gray-900;
overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch;
} }
code { code {
@@ -73,6 +73,10 @@
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
} }
.provider-icons {
grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
}
.slider-header { .slider-header {
@apply relative mt-6 mb-4 flex; @apply relative mt-6 mb-4 flex;
} }

3200
yarn.lock

File diff suppressed because it is too large Load Diff