diff --git a/.all-contributorsrc b/.all-contributorsrc index 113d0af47..371872759 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -809,6 +809,69 @@ "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\"/>", @@ -818,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 759bad474..b2f0d8b73 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,2 @@ # Global code ownership - -- @Fallenbagel - -# i18n locale files - -src/i18n/locale/ @Fallenbagel +* @Fallenbagel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4c88ba9c..77da7ea18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: build_and_push: name: Build & Publish Docker Images if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-20.04 + runs-on: self-hosted steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 4039c58e2..9c5b143b1 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -41,6 +41,8 @@ jobs: fi - name: Set Up QEMU uses: docker/setup-qemu-action@v2 + - name: Configure Git + run: git config --add safe.directory /data/parts/jellyseerr/src - name: Build Snap Package uses: diddlesnaps/snapcraft-multiarch-action@v1 id: build diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index 545f25658..52a5e883a 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -187,7 +187,7 @@ describe('Discover', () => { cy.wait('@getWatchlist'); - const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist'); + const sliderHeader = cy.contains('.slider-header', 'Watchlist'); sliderHeader.scrollIntoView(); @@ -203,7 +203,7 @@ describe('Discover', () => { .find('[data-testid=title-card-title]') .invoke('text') .then((text) => { - cy.contains('.slider-header', 'Plex Watchlist') + cy.contains('.slider-header', 'Watchlist') .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() diff --git a/cypress/e2e/pull-to-refresh.cy.ts b/cypress/e2e/pull-to-refresh.cy.ts index d56c55897..732ee4137 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/overseerr-api.yml b/overseerr-api.yml index 20bc21849..1f38f4338 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -36,6 +36,8 @@ tags: description: Endpoints related to retrieving collection details. - name: service description: Endpoints related to getting service (Radarr/Sonarr) details. + - name: watchlist + description: Collection of media to watch later servers: - url: '{server}/api/v1' variables: @@ -44,6 +46,34 @@ servers: components: schemas: + Watchlist: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + tmdbId: + type: number + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' User: type: object properties: @@ -3962,6 +3992,41 @@ paths: restricted: type: boolean example: false + /watchlist: + post: + summary: Add media to watchlist + tags: + - watchlist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + /watchlist/{tmdbId}: + delete: + summary: Delete watchlist item + description: Removes a watchlist item. + tags: + - watchlist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed watchlist item /user/{userId}/watchlist: get: summary: Get the Plex watchlist for a specific user @@ -3969,6 +4034,7 @@ paths: Retrieves a user's Plex Watchlist in a JSON object. tags: - users + - watchlist parameters: - in: path name: userId @@ -4439,6 +4505,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: @@ -4718,6 +4794,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 3c4da6f65..257e2994f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:next": "next build", "build": "yarn build:next && yarn build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", + "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", @@ -29,17 +30,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 +48,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", @@ -64,23 +65,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", @@ -89,42 +89,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", @@ -133,45 +132,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 7695e3987..99a74eb1b 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 2d80c65b4..bba039cf2 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -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 4c931ff97..ef36fcd6d 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 955e1b12e..775a89765 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 a19f07422..fda068224 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 47217aa09..68a5622c0 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -3,6 +3,8 @@ import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import type { User } from '@server/entity/User'; +import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; @@ -12,7 +14,6 @@ import { Column, CreateDateColumn, Entity, - In, Index, OneToMany, PrimaryGeneratedColumn, @@ -25,6 +26,7 @@ import Season from './Season'; @Entity() class Media { public static async getRelatedMedia( + user: User | undefined, tmdbIds: number | number[] ): Promise { const mediaRepository = getRepository(Media); @@ -37,9 +39,16 @@ class Media { finalIds = tmdbIds; } - const media = await mediaRepository.find({ - where: { tmdbId: In(finalIds) }, - }); + const media = await mediaRepository + .createQueryBuilder('media') + .leftJoinAndSelect( + 'media.watchlists', + 'watchlist', + 'media.id= watchlist.media and watchlist.requestedBy = :userId', + { userId: user?.id } + ) //, + .where(' media.tmdbId in (:...finalIds)', { finalIds }) + .getMany(); return media; } catch (e) { @@ -94,6 +103,9 @@ class Media { @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) public requests: MediaRequest[]; + @OneToMany(() => Watchlist, (watchlist) => watchlist.media) + public watchlists: null | Watchlist[]; + @OneToMany(() => Season, (season) => season.media, { cascade: true, eager: true, diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 61122afc3..e980860c6 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, diff --git a/server/entity/User.ts b/server/entity/User.ts index 8780e2d8a..e4c8314c3 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,6 +1,7 @@ import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; +import { Watchlist } from '@server/entity/Watchlist'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import PreparedEmail from '@server/lib/email'; import type { PermissionCheckOptions } from '@server/lib/permissions'; @@ -103,6 +104,9 @@ export class User { @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; + @OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy) + public watchlists: Watchlist[]; + @Column({ nullable: true }) public movieQuotaLimit?: number; diff --git a/server/entity/Watchlist.ts b/server/entity/Watchlist.ts new file mode 100644 index 000000000..df820120e --- /dev/null +++ b/server/entity/Watchlist.ts @@ -0,0 +1,157 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import logger from '@server/logger'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import type { ZodNumber, ZodOptional, ZodString } from 'zod'; + +export class DuplicateWatchlistRequestError extends Error {} +export class NotFoundError extends Error { + constructor(message = 'Not found') { + super(message); + this.name = 'NotFoundError'; + } +} + +@Entity() +@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy']) +export class Watchlist implements WatchlistItem { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar' }) + public ratingKey = ''; + + @Column({ type: 'varchar' }) + public mediaType: MediaType; + + @Column({ type: 'varchar' }) + title = ''; + + @Column() + @Index() + public tmdbId: number; + + @ManyToOne(() => User, (user) => user.watchlists, { + eager: true, + onDelete: 'CASCADE', + }) + public requestedBy: User; + + @ManyToOne(() => Media, (media) => media.watchlists, { + eager: true, + onDelete: 'CASCADE', + }) + public media: Media; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + public static async createWatchlist({ + watchlistRequest, + user, + }: { + watchlistRequest: { + mediaType: MediaType; + ratingKey?: ZodOptional['_output']; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + }; + user: User; + }): Promise { + const watchlistRepository = getRepository(this); + const mediaRepository = getRepository(Media); + const tmdb = new TheMovieDb(); + + const tmdbMedia = + watchlistRequest.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId }) + : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId }); + + const existing = await watchlistRepository + .createQueryBuilder('watchlist') + .leftJoinAndSelect('watchlist.requestedBy', 'user') + .where('user.id = :userId', { userId: user.id }) + .andWhere('watchlist.tmdbId = :tmdbId', { + tmdbId: watchlistRequest.tmdbId, + }) + .andWhere('watchlist.mediaType = :mediaType', { + mediaType: watchlistRequest.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + logger.warn('Duplicate request for watchlist blocked', { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + label: 'Watchlist', + }); + + throw new DuplicateWatchlistRequestError(); + } + + let media = await mediaRepository.findOne({ + where: { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + }, + }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: tmdbMedia.external_ids.tvdb_id, + mediaType: watchlistRequest.mediaType, + }); + } + + const watchlist = new this({ + ...watchlistRequest, + requestedBy: user, + media, + }); + + await mediaRepository.save(media); + await watchlistRepository.save(watchlist); + return watchlist; + } + + public static async deleteWatchlist( + tmdbId: Watchlist['tmdbId'], + user: User + ): Promise { + const watchlistRepository = getRepository(this); + const watchlist = await watchlistRepository.findOneBy({ + tmdbId, + requestedBy: { id: user.id }, + }); + if (!watchlist) { + throw new NotFoundError('not Found'); + } + + if (watchlist) { + await watchlistRepository.delete(watchlist.id); + } + + return watchlist; + } +} diff --git a/server/interfaces/api/watchlistCreate.ts b/server/interfaces/api/watchlistCreate.ts new file mode 100644 index 000000000..6cc6af3bb --- /dev/null +++ b/server/interfaces/api/watchlistCreate.ts @@ -0,0 +1,9 @@ +import { MediaType } from '@server/constants/media'; +import { z } from 'zod'; + +export const watchlistCreate = z.object({ + ratingKey: z.coerce.string().optional(), + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), + title: z.coerce.string().optional(), +}); diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 93ccfe391..a9f61fff6 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -1,7 +1,8 @@ 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 } from '@server/api/servarr/sonarr'; +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'; @@ -47,158 +48,150 @@ class AvailabilitySync { try { for await (const media of this.loadAvailableMediaPaginated(pageSize)) { - try { - if (!this.running) { - throw new Error('Job aborted'); - } + if (!this.running) { + 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 - 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.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 }, - }, - ], + // 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 } }, }); - let didDeleteSeasons = false; - for (const season of seasons) { - if ( - !mediaExists && - (season.status !== MediaStatus.UNKNOWN || - season.status4k !== MediaStatus.UNKNOWN) - ) { - await seasonRepository.update( - { id: season.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( { - status: MediaStatus.UNKNOWN, - status4k: MediaStatus.UNKNOWN, + relations: { + request: { + media: true, + }, + }, + where: { + request: { + media: { + id: media.id, + }, + }, + seasonNumber: season.seasonNumber, + }, } ); - } else { - const seasonExists = await this.seasonExists(media, season); - if (!seasonExists) { - logger.info( - `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; + 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.tvdbId} as PARTIALLY_AVAILABLE because we deleted some of its seasons.`, - { label: 'AvailabilitySync' } - ); + 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.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, - }); - } + if (media.status4k === MediaStatus.AVAILABLE) { + await mediaRepository.update(media.id, { + status4k: MediaStatus.PARTIALLY_AVAILABLE, + }); } } } } - } catch (ex) { - logger.error('Failure with media.', { - errorMessage: ex.message, - label: 'AvailabilitySync', - }); } } } catch (ex) { @@ -254,9 +247,9 @@ class AvailabilitySync { }); logger.info( - `${media.tmdbId} does not exist in your ${is4k ? '4k' : 'non-4k'} ${ - isTVType ? 'sonarr' : 'radarr' - } and plex instance. We will change its status to unknown.`, + `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' } ); @@ -306,46 +299,70 @@ class AvailabilitySync { apiKey: server.apiKey, 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 - //if both do not exist we will return false - if (!server.is4k && !meta.id) { - existsInRadarr = false; - } + let meta: RadarrMovie | undefined; - if (server.is4k && !meta.id) { - existsInRadarr4k = false; + 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 (existsInRadarr && existsInRadarr4k) { - return true; - } - - if (!existsInRadarr && existsInPlex) { - return true; - } - - 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 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 && !existsInRadarr4k && !existsInPlex4k) { + if ( + (existsInRadarr || existsInPlex) && + !existsInRadarr4k && + !existsInPlex4k + ) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } - if (existsInRadarr || existsInRadarr4k) { + if (existsInRadarr || existsInRadarr4k || existsInPlex || existsInPlex4k) { return true; } @@ -357,10 +374,6 @@ class AvailabilitySync { existsInPlex: boolean, existsInPlex4k: boolean ): Promise { - if (!media.tvdbId) { - return false; - } - let existsInSonarr = true; let existsInSonarr4k = true; @@ -369,49 +382,75 @@ class AvailabilitySync { 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 - 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 both do not exist we will return false - if (!server.is4k && !meta.id) { - existsInSonarr = false; - } + if (server.is4k && media.externalServiceId4k) { + meta = await api.getSeriesById(media.externalServiceId4k); + this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = + meta.seasons; + } - if (server.is4k && !meta.id) { - existsInSonarr4k = false; + 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 (existsInSonarr && existsInSonarr4k) { - return true; - } - - if (!existsInSonarr && existsInPlex) { - return true; - } - - 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 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 && !existsInSonarr4k && !existsInPlex4k) { + if ( + (existsInSonarr || existsInPlex) && + !existsInSonarr4k && + !existsInPlex4k + ) { if (media.status4k !== MediaStatus.UNKNOWN) { this.mediaUpdater(media, true); } } - if (existsInSonarr || existsInSonarr4k) { + if (existsInSonarr || existsInSonarr4k || existsInPlex || existsInPlex4k) { return true; } @@ -424,10 +463,6 @@ class AvailabilitySync { seasonExistsInPlex: boolean, seasonExistsInPlex4k: boolean ): Promise { - if (!media.tvdbId) { - return false; - } - let seasonExistsInSonarr = true; let seasonExistsInSonarr4k = true; @@ -441,35 +476,67 @@ class AvailabilitySync { url: SonarrAPI.buildUrl(server, '/api/v3'), }); - const seasons = - this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] ?? - (await api.getSeriesByTvdbId(media.tvdbId)).seasons; - this.sonarrSeasonsCache[`${server.id}-${media.tvdbId}`] = seasons; + 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 - const hasMonitoredSeason = seasons.find( - ({ monitored, seasonNumber }) => - monitored && season.seasonNumber === seasonNumber - ); + let seasons: SonarrSeason[] = + this.sonarrSeasonsCache[ + `${server.id}-${ + !server.is4k ? media.externalServiceId : media.externalServiceId4k + }` + ]; - if (!server.is4k && !hasMonitoredSeason) { - seasonExistsInSonarr = false; + 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; + } } - - 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({ @@ -489,16 +556,16 @@ class AvailabilitySync { }, }); - //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 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 && + (seasonExistsInSonarr4k || seasonExistsInPlex4k) && !seasonExistsInPlex ) { if (season.status !== MediaStatus.UNKNOWN) { 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' } ); await seasonRepository.update(season.id, { @@ -511,7 +578,7 @@ class AvailabilitySync { if (media.status === MediaStatus.AVAILABLE) { 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' } ); await mediaRepository.update(media.id, { @@ -522,13 +589,13 @@ class AvailabilitySync { } if ( - seasonExistsInSonarr && + (seasonExistsInSonarr || seasonExistsInPlex) && !seasonExistsInSonarr4k && !seasonExistsInPlex4k ) { if (season.status4k !== MediaStatus.UNKNOWN) { 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' } ); await seasonRepository.update(season.id, { @@ -541,7 +608,7 @@ class AvailabilitySync { if (media.status4k === MediaStatus.AVAILABLE) { 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' } ); await mediaRepository.update(media.id, { @@ -551,7 +618,12 @@ class AvailabilitySync { } } - if (seasonExistsInSonarr || seasonExistsInSonarr4k) { + if ( + seasonExistsInSonarr || + seasonExistsInSonarr4k || + seasonExistsInPlex || + seasonExistsInPlex4k + ) { return true; } @@ -565,7 +637,7 @@ class AvailabilitySync { let existsInPlex = false; let existsInPlex4k = false; - //check each plex instance to see if media exists + // Check each plex instance to see if media exists try { if (ratingKey) { const meta = await this.plexClient?.getMetadata(ratingKey); @@ -573,6 +645,7 @@ class AvailabilitySync { existsInPlex = true; } } + if (ratingKey4k) { const meta4k = await this.plexClient?.getMetadata(ratingKey4k); if (meta4k) { @@ -580,18 +653,17 @@ class AvailabilitySync { } } } 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')) { throw ex; } } - //base case for if both media versions exist in plex + // 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 + // 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, @@ -599,10 +671,10 @@ class AvailabilitySync { 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) { 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', } @@ -619,10 +691,10 @@ class AvailabilitySync { 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) { 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', } @@ -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) { return true; } @@ -686,7 +758,7 @@ class AvailabilitySync { if (existsInSonarr) { 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', } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index ebc8f4af0..b1d105773 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -69,6 +69,7 @@ export interface DVRSettings { externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; + tagRequests: boolean; } export interface RadarrSettings extends DVRSettings { diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 46147f3fc..b4a072970 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -65,6 +65,7 @@ class WatchlistSync { const response = await plexTvApi.getWatchlist({ size: 200 }); const mediaItems = await Media.getRelatedMedia( + user, response.items.map((i) => i.tmdbId) ); diff --git a/server/migration/1682608634546-AddWatchlists.ts b/server/migration/1682608634546-AddWatchlists.ts new file mode 100644 index 000000000..492fb183e --- /dev/null +++ b/server/migration/1682608634546-AddWatchlists.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWatchlists1682608634546 implements MigrationInterface { + name = 'AddWatchlists1682608634546'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); + await queryRunner.query(`DROP TABLE "watchlist"`); + } +} diff --git a/server/models/Search.ts b/server/models/Search.ts index 6ab696fe3..2193bbe15 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/repositories/watchlist.repository.ts b/server/repositories/watchlist.repository.ts new file mode 100644 index 000000000..128e64f5a --- /dev/null +++ b/server/repositories/watchlist.repository.ts @@ -0,0 +1,11 @@ +import { getRepository } from '@server/datasource'; +import { Watchlist } from '@server/entity/Watchlist'; + +export const UserRepository = getRepository(Watchlist).extend({ + // findByName(firstName: string, lastName: string) { + // return this.createQueryBuilder("user") + // .where("user.firstName = :firstName", { firstName }) + // .andWhere("user.lastName = :lastName", { lastName }) + // .getMany() + // }, +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 1dabcdf31..7adcc73ab 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -380,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { if (e.message === 'Unauthorized') { - logger.info( + logger.warn( 'Failed login attempt from user with incorrect Jellyfin credentials', { label: 'Auth', diff --git a/server/routes/collection.ts b/server/routes/collection.ts index d58b0357d..cc2a36e76 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -16,6 +16,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, collection.parts.map((part) => part.id) ); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index f032fa66b..b5fe22ea7 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,6 +6,7 @@ import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; +import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, WatchlistResponse, @@ -14,12 +15,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 +66,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,11 +99,14 @@ discoverRoutes.get('/movies', async (req, res, next) => { withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -164,6 +171,7 @@ discoverRoutes.get<{ language: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -221,6 +229,7 @@ discoverRoutes.get<{ genreId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -268,6 +277,7 @@ discoverRoutes.get<{ studioId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -317,6 +327,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -370,11 +381,14 @@ discoverRoutes.get('/tv', async (req, res, next) => { withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, + voteCountGte: query.voteCountGte, + voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -438,6 +452,7 @@ discoverRoutes.get<{ language: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -495,6 +510,7 @@ discoverRoutes.get<{ genreId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -542,6 +558,7 @@ discoverRoutes.get<{ networkId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -591,6 +608,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -629,6 +647,7 @@ discoverRoutes.get('/trending', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -647,6 +666,8 @@ discoverRoutes.get('/trending', async (req, res, next) => { ) : isPerson(result) ? mapPersonResult(result) + : isCollection(result) + ? mapCollectionResult(result) : mapTvResult( result, media.find( @@ -681,6 +702,7 @@ discoverRoutes.get<{ keywordId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -813,6 +835,25 @@ discoverRoutes.get, WatchlistResponse>( select: ['id', 'plexToken'], }); + if (activeUser) { + const [result, total] = await getRepository(Watchlist).findAndCount({ + where: { requestedBy: { id: activeUser?.id } }, + relations: { + /*requestedBy: true,media:true*/ + }, + // loadRelationIds: true, + take: itemsPerPage, + skip: offset, + }); + if (total) { + return res.json({ + page: page, + totalPages: total / itemsPerPage, + totalResults: total, + results: result, + }); + } + } if (!activeUser?.plexToken) { // We will just return an empty array if the user has no Plex token return res.json({ diff --git a/server/routes/index.ts b/server/routes/index.ts index f76f09fa0..552ca78d0 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import { mapWatchProviderDetails } from '@server/models/common'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import settingsRoutes from '@server/routes/settings'; +import watchlistRoutes from '@server/routes/watchlist'; import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; @@ -116,6 +117,7 @@ router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); +router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index f11cead8c..8d609262b 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -45,6 +45,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); @@ -86,6 +87,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/person.ts b/server/routes/person.ts index 7f5d62236..d5cb89867 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -42,10 +42,12 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => { }); const castMedia = await Media.getRelatedMedia( + req.user, combinedCredits.cast.map((result) => result.id) ); const crewMedia = await Media.getRelatedMedia( + req.user, combinedCredits.crew.map((result) => result.id) ); diff --git a/server/routes/search.ts b/server/routes/search.ts index b9254221a..0de090cad 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -34,6 +34,7 @@ searchRoutes.get('/', async (req, res, next) => { } const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/service.ts b/server/routes/service.ts index b77d58c9d..083e1eb57 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/tv.ts b/server/routes/tv.ts index d45e40620..1d2b4deed 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -69,6 +69,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); @@ -109,6 +110,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 55a912f36..046a1471d 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -8,6 +8,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import { Watchlist } from '@server/entity/Watchlist'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, @@ -382,7 +383,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()); @@ -699,8 +707,7 @@ router.get<{ id: string }, WatchlistResponse>( ) { return next({ status: 403, - message: - "You do not have permission to view this user's Plex Watchlist.", + message: "You do not have permission to view this user's Watchlist.", }); } @@ -714,6 +721,24 @@ router.get<{ id: string }, WatchlistResponse>( }); if (!user?.plexToken) { + if (user) { + const [result, total] = await getRepository(Watchlist).findAndCount({ + where: { requestedBy: { id: user?.id } }, + relations: { requestedBy: true }, + // loadRelationIds: true, + take: itemsPerPage, + skip: offset, + }); + if (total) { + return res.json({ + page: page, + totalPages: total / itemsPerPage, + totalResults: total, + results: result, + }); + } + } + // We will just return an empty array if the user has no Plex token return res.json({ page: 1, diff --git a/server/routes/watchlist.ts b/server/routes/watchlist.ts new file mode 100644 index 000000000..bbb44da01 --- /dev/null +++ b/server/routes/watchlist.ts @@ -0,0 +1,73 @@ +import { + DuplicateWatchlistRequestError, + NotFoundError, + Watchlist, +} from '@server/entity/Watchlist'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { QueryFailedError } from 'typeorm'; + +import { watchlistCreate } from '@server/interfaces/api/watchlistCreate'; + +const watchlistRoutes = Router(); + +watchlistRoutes.post( + '/', + async (req, res, next) => { + try { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to add watchlist.', + }); + } + const values = watchlistCreate.parse(req.body); + + const request = await Watchlist.createWatchlist({ + watchlistRequest: values, + user: req.user, + }); + return res.status(201).json(request); + } catch (error) { + if (!(error instanceof Error)) { + return; + } + switch (error.constructor) { + case QueryFailedError: + logger.warn('Something wrong with data watchlist', { + tmdbId: req.body.tmdbId, + mediaType: req.body.mediaType, + label: 'Watchlist', + }); + return next({ status: 409, message: 'Something wrong' }); + case DuplicateWatchlistRequestError: + return next({ status: 409, message: error.message }); + default: + return next({ status: 500, message: error.message }); + } + } + } +); + +watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to delete watchlist data.', + }); + } + try { + await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user); + return res.status(204).send(); + } catch (e) { + if (e instanceof NotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } +}); + +export default watchlistRoutes; diff --git a/server/utils/typeHelpers.ts b/server/utils/typeHelpers.ts index 507ece8cd..548378ff7 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/snap/snapcraft.yaml b/snap/snapcraft.yaml index 61db367ac..3d157b821 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -37,7 +37,7 @@ parts: override-pull: | snapcraftctl pull # Get information to determine snap grade and version - git config --global --add safe.directory /data/parts/jellyyseerr/src + git config --global --add safe.directory /data/parts/jellyseerr/src #setup yarn.rc echo "--install.frozen-lockfile\n--install.network-timeout 1000000" > .yarnrc BRANCH=$(git rev-parse --abbrev-ref HEAD) diff --git a/src/assets/services/emby.svg b/src/assets/services/emby.svg new file mode 100644 index 000000000..eddc540cd --- /dev/null +++ b/src/assets/services/emby.svg @@ -0,0 +1,46 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 34b379e24..2274d2750 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -338,6 +338,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { { /> ))} /> -
+
); }; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 47ce6586c..17eda5b1d 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/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 6f09f768b..907cc8e24 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll'; import globalMessages from '@app/i18n/globalMessages'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { + CollectionResult, MovieResult, PersonResult, TvResult, @@ -12,7 +13,7 @@ import type { import { useIntl } from 'react-intl'; type ListViewProps = { - items?: (TvResult | MovieResult | PersonResult)[]; + items?: (TvResult | MovieResult | PersonResult | CollectionResult)[]; plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; @@ -57,7 +58,9 @@ const ListView = ({ case 'movie': titleCard = ( ); break; + case 'collection': + titleCard = ( + + ); + break; case 'person': titleCard = ( { @@ -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 970a9887a..9a0f3aa76 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/PlexWatchlistSlider/index.tsx b/src/components/Discover/PlexWatchlistSlider/index.tsx index 76fba57ca..bfb52a052 100644 --- a/src/components/Discover/PlexWatchlistSlider/index.tsx +++ b/src/components/Discover/PlexWatchlistSlider/index.tsx @@ -1,6 +1,6 @@ import Slider from '@app/components/Slider'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; -import { UserType, useUser } from '@app/hooks/useUser'; +import { useUser } from '@app/hooks/useUser'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import Link from 'next/link'; @@ -8,7 +8,7 @@ import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages({ - plexwatchlist: 'Your Plex Watchlist', + plexwatchlist: 'Your Watchlist', emptywatchlist: 'Media added to your Plex Watchlist will appear here.', }); @@ -22,12 +22,11 @@ const PlexWatchlistSlider = () => { totalPages: number; totalResults: number; results: WatchlistItem[]; - }>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, { + }>('/api/v1/discover/watchlist', { revalidateOnMount: true, }); if ( - user?.userType !== UserType.PLEX || (watchlistItems && watchlistItems.results.length === 0 && !user?.settings?.watchlistSyncMovies && @@ -69,6 +68,7 @@ const PlexWatchlistSlider = () => { key={`watchlist-slider-item-${item.ratingKey}`} tmdbId={item.tmdbId} type={item.mediaType} + isAddedToWatchlist={true} /> ))} /> diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 6fcbe43cb..c7013f9c7 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -74,7 +74,7 @@ export const sliderTitles = defineMessages({ recentlyAdded: 'Recently Added', upcoming: 'Upcoming Movies', trending: 'Trending', - plexwatchlist: 'Your Plex Watchlist', + plexwatchlist: 'Your Watchlist', moviegenres: 'Movie Genres', tvgenres: 'Series Genres', studios: 'Studios', @@ -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 74383f13b..38875dbed 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -365,6 +365,36 @@ 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/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index d7c4b4602..1de0b5a36 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,3 +1,4 @@ +import EmbyLogo from '@app/assets/services/emby.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; import JellyfinLogo from '@app/assets/services/jellyfin.svg'; import PlexLogo from '@app/assets/services/plex.svg'; @@ -9,6 +10,7 @@ import useLocale from '@app/hooks/useLocale'; import useSettings from '@app/hooks/useSettings'; import { MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; +import getConfig from 'next/config'; interface ExternalLinkBlockProps { mediaType: 'movie' | 'tv'; @@ -28,6 +30,7 @@ const ExternalLinkBlock = ({ mediaUrl, }: ExternalLinkBlockProps) => { const settings = useSettings(); + const { publicRuntimeConfig } = getConfig(); const { locale } = useLocale(); return ( @@ -41,6 +44,8 @@ const ExternalLinkBlock = ({ > {settings.currentSettings.mediaServerType === MediaServerType.PLEX ? ( + ) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? ( + ) : ( )} diff --git a/src/components/Layout/PullToRefresh/index.tsx b/src/components/Layout/PullToRefresh/index.tsx new file mode 100644 index 000000000..cdedcf43c --- /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/index.tsx b/src/components/Layout/index.tsx index f18e879af..a1964b0ba 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/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 5bd38301e..ff9575ff8 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -1,5 +1,7 @@ import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; import useSettings from '@app/hooks/useSettings'; +import { InformationCircleIcon } from '@heroicons/react/24/solid'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import getConfig from 'next/config'; @@ -13,6 +15,8 @@ const messages = defineMessages({ password: 'Password', host: '{mediaServerName} URL', email: 'Email', + emailtooltip: + 'Address does not need to be associated with your {mediaServerName} instance.', validationhostrequired: '{mediaServerName} URL required', validationhostformat: 'Valid URL required', validationemailrequired: 'Email required', @@ -63,6 +67,10 @@ const JellyfinLogin: React.FC = ({ ), password: Yup.string(), }); + const mediaServerFormatValues = { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin', + }; return ( = ({
@@ -114,20 +117,34 @@ const JellyfinLogin: React.FC = ({ id="host" name="host" type="text" - placeholder={intl.formatMessage(messages.host, { - mediaServerName: - publicRuntimeConfig.JELLYFIN_TYPE == 'emby' - ? 'Emby' - : 'Jellyfin', - })} + placeholder={intl.formatMessage( + messages.host, + mediaServerFormatValues + )} />
{errors.host && touched.host && (
{errors.host}
)}
-