diff --git a/.all-contributorsrc b/.all-contributorsrc index c57826d6..37187275 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -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": "\"All-orange.svg\"/>", @@ -782,5 +881,6 @@ "repoType": "github", "repoHost": "https://github.com", "skipCi": false, - "commitConvention": "angular" + "commitConvention": "angular", + "commitType": "docs" } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6effae04..68a2d018 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 45da7ba6..1a237571 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,8 @@ } ], "editor.formatOnSave": true, - "typescript.preferences.importModuleSpecifier": "non-relative" + "typescript.preferences.importModuleSpecifier": "non-relative", + "files.associations": { + "globals.css": "tailwindcss" + } } diff --git a/README.md b/README.md index ae82f548..6fe73c27 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors

@@ -182,6 +182,21 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d ceptonit
ceptonit

📖 aedelbro
aedelbro

💻 + + Pedro Nascimento
Pedro Nascimento

💻 + Owen Voke
Owen Voke

💻 + Sebastian K
Sebastian K

💻 + jariz
jariz

💻 + Alex
Alex

💻 + Zeb Muller
Zeb Muller

💻 + Shane Friedman
Shane Friedman

💻 + + + Izaac Brånn
Izaac Brånn

💻 + Salman Tariq
Salman Tariq

💻 + Andrew Kennedy
Andrew Kennedy

💻 + Fallenbagel
Fallenbagel

💻 + diff --git a/cypress/e2e/pull-to-refresh.cy.ts b/cypress/e2e/pull-to-refresh.cy.ts index d56c5589..732ee413 100644 --- a/cypress/e2e/pull-to-refresh.cy.ts +++ b/cypress/e2e/pull-to-refresh.cy.ts @@ -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( diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index a0756ae2..469994a3 100644 --- a/cypress/e2e/settings/discover-customization.cy.ts +++ b/cypress/e2e/settings/discover-customization.cy.ts @@ -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) diff --git a/next.config.js b/next.config.js index b0e872e8..9cd65f97 100644 --- a/next.config.js +++ b/next.config.js @@ -19,5 +19,6 @@ module.exports = { }, experimental: { scrollRestoration: true, + largePageDataBytes: 256000, }, }; diff --git a/overseerr-api.yml b/overseerr-api.yml index 443a9c94..c8b52885 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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: diff --git a/package.json b/package.json index b3d10eda..ee3d52b2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index 7695e398..99a74eb1 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -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 ); } diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index eca0208c..6cda2a49 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -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 { + try { + const response = await this.axios.get(`/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 { try { const response = await this.axios.get('/series/lookup', { diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 4c931ff9..ef36fcd6 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -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 => { @@ -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 => { @@ -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, }, diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 955e1b12..775a8976 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -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 { diff --git a/server/constants/discover.ts b/server/constants/discover.ts index a19f0742..fda06822 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -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[] = [ diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 6a681c47..2d169172 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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, diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index fad97ef6..e980860c 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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; diff --git a/server/entity/SeasonRequest.ts b/server/entity/SeasonRequest.ts index f9eeef50..c55906eb 100644 --- a/server/entity/SeasonRequest.ts +++ b/server/entity/SeasonRequest.ts @@ -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) { Object.assign(this, init); } + + @AfterRemove() + public async handleRemoveParent(): Promise { + 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; diff --git a/server/index.ts b/server/index.ts index 93703402..b426f0f3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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( diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 725e67b5..932d6107 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -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', { diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts new file mode 100644 index 00000000..231dd9a2 --- /dev/null +++ b/server/lib/availabilitySync.ts @@ -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 = {}; + private sonarrSeasonsCache: Record = {}; + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cf475554..c3981fe9 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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 { diff --git a/server/middleware/clearcookies.ts b/server/middleware/clearcookies.ts new file mode 100644 index 00000000..73713e52 --- /dev/null +++ b/server/middleware/clearcookies.ts @@ -0,0 +1,6 @@ +const clearCookies: Middleware = (_req, res, next) => { + res.removeHeader('Set-Cookie'); + next(); +}; + +export default clearCookies; diff --git a/server/models/Search.ts b/server/models/Search.ts index 6ab696fe..2193bbe1 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -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); } diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 2c3c665f..487d1a32 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -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, 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, diff --git a/server/routes/service.ts b/server/routes/service.ts index b77d58c9..083e1eb5 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -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 { diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f77b7e51..94784df5 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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, diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 507ece8c..548378ff 100644 --- a/server/utils/typeHelpers.ts +++ b/server/utils/typeHelpers.ts @@ -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 => { diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 0136113a..ff22ee10 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -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(`/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) => { /> ))} /> -
+
); }; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 47ce6586..17eda5b1 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -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'); } } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index b5bc0cb6..b0d314d1 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
); break; + case 'collection': + titleCard = ( + + ); + break; case 'person': titleCard = ( ( 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( (
( }} 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} diff --git a/src/components/Common/SlideCheckbox/index.tsx b/src/components/Common/SlideCheckbox/index.tsx index a514d6c0..320dd667 100644 --- a/src/components/Common/SlideCheckbox/index.tsx +++ b/src/components/Common/SlideCheckbox/index.tsx @@ -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`} > ); diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index 48c1f854..ec2ea263 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -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 = ({
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
e.stopPropagation()} > diff --git a/src/components/Discover/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx index 24b9d3fd..40d26fff 100644 --- a/src/components/Discover/CreateSlider/index.tsx +++ b/src/components/Discover/CreateSlider/index.tsx @@ -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 = ( + Number(v)) ?? [] + } + onChange={(region, providers) => { + setFieldValue('data', `${region},${providers.join('|')}`); + }} + /> + ); + break; + case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: + dataInput = ( + Number(v)) ?? [] + } + onChange={(region, providers) => { + setFieldValue('data', `${region},${providers.join('|')}`); + }} + /> + ); + break; default: dataInput = ( { '$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} />
diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index 970a9887..9a0f3aa7 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -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`} > -
{getSliderTitle(slider)}
+
+ {getSliderTitle(slider)} +
+ + {intl.formatMessage(messages.tmdbuservotecount)} + +
+ { + 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, + })} + /> +
{intl.formatMessage(messages.streamingservices)} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 6fcbe43c..0571f1fc 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -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; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index b9071b42..38875dbe 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -165,10 +165,10 @@ const Discover = () => {
{ /> ); break; + case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES: + sliderComponent = ( + + ); + break; + case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: + sliderComponent = ( + + ); + break; } if (isEditing) { diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index e1e265b0..0a38fa53 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -65,10 +65,10 @@ const IssueComment = ({ > )}
diff --git a/src/components/IssueDetails/IssueDescription/index.tsx b/src/components/IssueDetails/IssueDescription/index.tsx index 7121f095..7dc8c8d3 100644 --- a/src/components/IssueDetails/IssueDescription/index.tsx +++ b/src/components/IssueDetails/IssueDescription/index.tsx @@ -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" > { ( {
{ 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); diff --git a/src/components/Layout/PullToRefresh/index.tsx b/src/components/Layout/PullToRefresh/index.tsx new file mode 100644 index 00000000..cdedcf43 --- /dev/null +++ b/src/components/Layout/PullToRefresh/index.tsx @@ -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(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 ( +
+
+ +
+
+ ); +}; + +export default PullToRefresh; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 4baf56a6..81ebb86c 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -71,9 +71,7 @@ const SidebarLinks: SidebarLinkProps[] = [ { href: '/issues', messagesKey: 'issues', - svgIcon: ( - - ), + svgIcon: , activeRegExp: /^\/issues/, requiredPermission: [ Permission.MANAGE_ISSUES, @@ -127,10 +125,10 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => { diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index c21a9c50..6d3fe7b9 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -63,11 +63,11 @@ const UserDropdown = () => { diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index b30b9712..878f27b1 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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'; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 5ad862b1..fe92629a 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -95,10 +95,10 @@ const Login = () => { diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index b6fd51cf..8609c828 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -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 = ({
    {data.mediaInfo?.downloadStatus?.map((status, index) => ( -
  • - -
  • +
  • + +
  • + ))} {data.mediaInfo?.downloadStatus4k?.map((status, index) => ( -
  • - -
  • +
  • + +
  • + ))}
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 6f8b2e01..1b142d4d 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -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(`/api/v1/movie/${router.query.movieId}`, { fallbackData: movie, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: movie?.mediaInfo?.downloadStatus, + downloadStatus4k: movie?.mediaInfo?.downloadStatus4k, + }, + 15000 + ), }); const { data: ratingData } = useSWR( diff --git a/src/components/PullToRefresh/index.tsx b/src/components/PullToRefresh/index.tsx deleted file mode 100644 index 68939c48..00000000 --- a/src/components/PullToRefresh/index.tsx +++ /dev/null @@ -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( -
- -
- ), - iconRefreshing: ReactDOMServer.renderToString( -
- -
- ), - instructionsPullToRefresh: ReactDOMServer.renderToString(
), - instructionsReleaseToRefresh: ReactDOMServer.renderToString(
), - instructionsRefreshing: ReactDOMServer.renderToString(
), - distReload: 60, - distIgnore: 15, - shouldPullToRefresh: () => - !window.scrollY && document.body.style.overflow !== 'hidden', - }); - return () => { - PR.destroyAll(); - }; - }, [router]); - - return
; -}; - -export default PullToRefresh; diff --git a/src/components/RegionSelector/index.tsx b/src/components/RegionSelector/index.tsx index d0a0113e..38febf9a 100644 --- a/src/components/RegionSelector/index.tsx +++ b/src/components/RegionSelector/index.tsx @@ -122,7 +122,7 @@ const RegionSelector = ({ { request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; + const { data: title, error } = useSWR( inView ? `${url}` : null ); @@ -229,6 +231,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { mutate: revalidate, } = useSWR(`/api/v1/request/${request.id}`, { fallbackData: request, + refreshInterval: refreshIntervalHelper( + { + downloadStatus: request.media.downloadStatus, + downloadStatus4k: request.media.downloadStatus4k, + }, + 15000 + ), }); const { plexUrl, plexUrl4k } = useDeepLinks({ diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index dbce03e5..a42483ab 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -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 + ), } ); diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 2589d515..4f5bb9ac 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -582,10 +582,10 @@ const AdvancedRequester = ({