diff --git a/.all-contributorsrc b/.all-contributorsrc index 378c8ee28..664f5658a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -144,6 +144,15 @@ "code", "doc" ] + }, + { + "login": "mmozeiko", + "name": "Mārtiņš Možeiko", + "avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4", + "profile": "https://github.com/mmozeiko", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d401771f..03a03c2f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,31 @@ jobs: if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci') runs-on: ubuntu-latest steps: - - name: checkout + - name: Checkout uses: actions/checkout@v2 - - name: Build and push to Docker Hub - uses: docker/build-push-action@v1 + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 with: username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - repository: sctx/overseerr - build_args: COMMIT_TAG=${{ github.sha }} - tags: develop + password: ${{ secrets.DOCKER_TOKEN }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.CR_PAT }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + tags: | + sctx/overseerr:develop + sctx/overseerr:${{ github.sha }} + ghcr.io/sct/overseerr:develop + ghcr.io/sct/overseerr:${{ github.sha }} diff --git a/.github/workflows/invalid_template.yml b/.github/workflows/invalid_template.yml index c98d3174b..647cdeca5 100644 --- a/.github/workflows/invalid_template.yml +++ b/.github/workflows/invalid_template.yml @@ -15,5 +15,5 @@ jobs: issue-comment: > :wave: @{issue-author}, please edit your issue and follow the template provided. close-issue: false - lock-issue: true + lock-issue: false issue-lock-reason: 'resolved' diff --git a/README.md b/README.md index 03d6e5276..ef1ca9d1a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -118,6 +118,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
ecelebi29

💻 📖 +
Mārtiņš Možeiko

💻 diff --git a/overseerr-api.yml b/overseerr-api.yml index 90c2bcb77..d84ac3fac 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -436,12 +436,7 @@ components: spokenLanguages: type: array items: - type: object - properties: - iso_639_1: - type: string - name: - type: string + $ref: '#/components/schemas/SpokenLanguage' status: type: string tagline: @@ -592,6 +587,10 @@ components: type: array items: $ref: '#/components/schemas/ProductionCompany' + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' seasons: type: array items: @@ -617,6 +616,10 @@ components: $ref: '#/components/schemas/Crew' externalIds: $ref: '#/components/schemas/ExternalIds' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' mediaInfo: $ref: '#/components/schemas/MediaInfo' MediaRequest: @@ -961,6 +964,28 @@ components: type: string mediaInfo: $ref: '#/components/schemas/MediaInfo' + Keyword: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: 'anime' + SpokenLanguage: + type: object + properties: + englishName: + type: string + example: 'English' + nullable: true + iso_639_1: + type: string + example: 'en' + name: + type: string + example: 'English' securitySchemes: cookieAuth: type: apiKey @@ -975,7 +1000,7 @@ paths: /settings/main: get: summary: Returns main settings - description: Retreives all main settings in JSON format + description: Retrieves all main settings in JSON format tags: - settings responses: @@ -1003,6 +1028,19 @@ paths: application/json: schema: $ref: '#/components/schemas/MainSettings' + /settings/main/regenerate: + get: + summary: Returns main settings with newly generated API Key + description: Retreives all main settings in JSON format with new API Key + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' /settings/plex: get: summary: Returns plex settings @@ -1140,7 +1178,7 @@ paths: /settings/radarr/test: post: summary: Test radarr configuration - description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success + description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success tags: - settings requestBody: @@ -1284,7 +1322,7 @@ paths: /settings/sonarr/test: post: summary: Test Sonarr configuration - description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success + description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success tags: - settings requestBody: @@ -1387,7 +1425,7 @@ paths: /settings/initialize: get: summary: Set the application as initialized - description: Sets the app as initalized and allows the user to navigate to pages other than the setup page + description: Sets the app as initialized and allows the user to navigate to pages other than the setup page tags: - settings responses: @@ -1870,6 +1908,51 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + /discover/keyword/{keywordId}/movies: + get: + summary: Request list of movies from keyword + description: Returns list of movies based on provided keyword ID in JSON format + tags: + - search + parameters: + - in: path + name: keywordId + required: true + schema: + type: number + example: 207317 + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' /request: get: summary: Get all requests diff --git a/package.json b/package.json index c27830053..64dd419c0 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,8 @@ "prepareCmd": "docker build -t sctx/overseerr ." } ], - "semantic-release-docker" + "semantic-release-docker", + "@semantic-release/github" ], "branches": [ "master" diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 4a86b68bb..903cd4cc6 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -6,7 +6,7 @@ interface SonarrSeason { monitored: boolean; } -interface SonarrSeries { +export interface SonarrSeries { title: string; sortTitle: string; seasonCount: number; @@ -33,7 +33,7 @@ interface SonarrSeries { tvMazeId: number; firstAired: string; lastInfoSync?: string; - seriesType: string; + seriesType: 'standard' | 'daily' | 'anime'; cleanTitle: string; imdbId: string; titleSlug: string; @@ -78,6 +78,7 @@ interface AddSeriesOptions { seasons: number[]; seasonFolder: boolean; rootFolderPath: string; + seriesType: SonarrSeries['seriesType']; monitored?: boolean; searchNow?: boolean; } @@ -153,6 +154,7 @@ class SonarrAPI { seasonFolder: options.seasonFolder, monitored: options.monitored, rootFolderPath: options.rootFolderPath, + seriesType: options.seriesType, addOptions: { ignoreEpisodesWithFiles: true, searchForMissingEpisodes: options.searchNow, @@ -164,7 +166,7 @@ class SonarrAPI { } catch (e) { logger.error('Something went wrong adding a series to Sonarr', { label: 'Sonarr API', - message: e.message, + errorMessage: e.message, error: e, }); throw new Error('Failed to add series'); diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 4260cdfc3..6f87823b6 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -1,5 +1,7 @@ import axios, { AxiosInstance } from 'axios'; +export const ANIME_KEYWORD_ID = 210024; + interface SearchOptions { query: string; page?: number; @@ -258,6 +260,11 @@ export interface TmdbTvDetails { name: string; origin_country: string; }[]; + spoken_languages: { + english_name: string; + iso_639_1: string; + name: string; + }[]; seasons: TmdbTvSeasonResult[]; status: string; type: string; @@ -268,6 +275,14 @@ export interface TmdbTvDetails { crew: TmdbCreditCrew[]; }; external_ids: TmdbExternalIds; + keywords: { + results: TmdbKeyword[]; + }; +} + +export interface TmdbKeyword { + id: number; + name: string; } export interface TmdbPersonDetail { @@ -437,7 +452,10 @@ class TheMovieDb { }): Promise => { try { const response = await this.axios.get(`/tv/${tvId}`, { - params: { language, append_to_response: 'credits,external_ids' }, + params: { + language, + append_to_response: 'credits,external_ids,keywords', + }, }); return response.data; @@ -524,6 +542,32 @@ class TheMovieDb { } } + public async getMoviesByKeyword({ + keywordId, + page = 1, + language = 'en-US', + }: { + keywordId: number; + page?: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/keyword/${keywordId}/movies`, + { + params: { + page, + language, + }, + } + ); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); + } + } + public async getTvRecommendations({ tvId, page = 1, diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e584dc55c..e5c993679 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -15,11 +15,11 @@ import { User } from './User'; import Media from './Media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; -import TheMovieDb from '../api/themoviedb'; +import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb'; import RadarrAPI from '../api/radarr'; import logger from '../logger'; import SeasonRequest from './SeasonRequest'; -import SonarrAPI from '../api/sonarr'; +import SonarrAPI, { SonarrSeries } from '../api/sonarr'; import notificationManager, { Notification } from '../lib/notifications'; @Entity() @@ -36,10 +36,18 @@ export class MediaRequest { }) public media: Media; - @ManyToOne(() => User, (user) => user.requests, { eager: true }) + @ManyToOne(() => User, (user) => user.requests, { + eager: true, + onDelete: 'CASCADE', + }) public requestedBy: User; - @ManyToOne(() => User, { nullable: true, cascade: true, eager: true }) + @ManyToOne(() => User, { + nullable: true, + cascade: true, + eager: true, + onDelete: 'SET NULL', + }) public modifiedBy?: User; @CreateDateColumn() @@ -328,14 +336,32 @@ export class MediaRequest { throw new Error('Series was missing tvdb id'); } + let seriesType: SonarrSeries['seriesType'] = 'standard'; + + // Change series type to anime if the anime keyword is present on tmdb + if ( + series.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) + ) { + seriesType = 'anime'; + } + // Run this asynchronously so we don't wait for it on the UI side sonarr.addSeries({ - profileId: sonarrSettings.activeProfileId, - rootFolderPath: sonarrSettings.activeDirectory, + profileId: + seriesType === 'anime' && sonarrSettings.activeAnimeProfileId + ? sonarrSettings.activeAnimeProfileId + : sonarrSettings.activeProfileId, + rootFolderPath: + seriesType === 'anime' && sonarrSettings.activeAnimeDirectory + ? sonarrSettings.activeAnimeDirectory + : sonarrSettings.activeDirectory, title: series.name, tvdbid: series.external_ids.tvdb_id, seasons: this.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, + seriesType, monitored: true, searchNow: true, }); diff --git a/server/index.ts b/server/index.ts index 657c2bfeb..ef7cd56e0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -101,11 +101,20 @@ app ); const port = Number(process.env.PORT) || 3000; - server.listen(port, () => { - logger.info(`Server ready on port ${port}`, { - label: 'Server', + const host = process.env.HOST; + if (host) { + server.listen(port, host, () => { + logger.info(`Server ready on ${host} port ${port}`, { + label: 'Server', + }); }); - }); + } else { + server.listen(port, () => { + logger.info(`Server ready on port ${port}`, { + label: 'Server', + }); + }); + } }) .catch((err) => { logger.error(err.stack); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index f618615cb..327563631 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -213,7 +213,7 @@ class Settings { } private generateApiKey(): string { - return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64'); + return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64'); } /** diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index f541c3d60..946a0b72b 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -40,7 +40,7 @@ export const isAuthenticated = ( if (!req.user || !req.user.hasPermission(permissions ?? 0)) { res.status(403).json({ status: 403, - error: 'You do not have permisson to access this endpoint', + error: 'You do not have permission to access this endpoint', }); } else { next(); diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts new file mode 100644 index 000000000..ce3de8499 --- /dev/null +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserRequestDeleteCascades1608219049304 + implements MigrationInterface { + name = 'AddUserRequestDeleteCascades1608219049304'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/models/Tv.ts b/server/models/Tv.ts index f1e8f7797..7c8759a9b 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -7,6 +7,7 @@ import { mapCrew, ExternalIds, mapExternalIds, + Keyword, } from './common'; import { TmdbTvEpisodeResult, @@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season { externalIds: ExternalIds; } +interface SpokenLanguage { + englishName: string; + iso_639_1: string; + name: string; +} + export interface TvDetails { id: number; backdropPath?: string; @@ -74,6 +81,7 @@ export interface TvDetails { overview: string; popularity: number; productionCompanies: ProductionCompany[]; + spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; type: string; @@ -84,6 +92,7 @@ export interface TvDetails { crew: Crew[]; }; externalIds: ExternalIds; + keywords: Keyword[]; mediaInfo?: Media; } @@ -161,6 +170,11 @@ export const mapTvDetails = ( originCountry: company.origin_country, logoPath: company.logo_path, })), + spokenLanguages: show.spoken_languages.map((language) => ({ + englishName: language.english_name, + iso_639_1: language.iso_639_1, + name: language.name, + })), seasons: show.seasons.map(mapSeasonResult), status: show.status, type: show.type, @@ -179,5 +193,9 @@ export const mapTvDetails = ( crew: show.credits.crew.map(mapCrew), }, externalIds: mapExternalIds(show.external_ids), + keywords: show.keywords.results.map((keyword) => ({ + id: keyword.id, + name: keyword.name, + })), mediaInfo: media, }); diff --git a/server/models/common.ts b/server/models/common.ts index 696f7df23..90945dc2e 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -11,6 +11,11 @@ export interface ProductionCompany { name: string; } +export interface Keyword { + id: number; + name: string; +} + export interface Genre { id: number; name: string; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 50a5f2201..c734e7020 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -14,7 +14,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { return res.status(500).json({ status: 500, error: - 'Requsted user endpoint withuot valid authenticated user in session', + 'Requested user endpoint without valid authenticated user in session', }); } const user = await userRepository.findOneOrFail({ diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e6c9de45b..1193354ce 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -121,4 +121,36 @@ discoverRoutes.get('/trending', async (req, res) => { }); }); +discoverRoutes.get<{ keywordId: string }>( + '/keyword/:keywordId/movies', + async (req, res) => { + const tmdb = new TheMovieDb(); + + const data = await tmdb.getMoviesByKeyword({ + keywordId: Number(req.params.keywordId), + page: Number(req.query.page), + language: req.query.language as string, + }); + + const media = await Media.getRelatedMedia( + data.results.map((result) => result.id) + ); + + return res.status(200).json({ + page: data.page, + totalPages: data.total_pages, + totalResults: data.total_results, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + export default discoverRoutes; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index 25a702b07..f90ae9b62 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -23,16 +23,27 @@ import { getAppVersion } from '../utils/appVersion'; const settingsRoutes = Router(); -settingsRoutes.get('/main', (req, res) => { +const filteredMainSettings = ( + user: User, + main: MainSettings +): Partial => { + if (!user?.hasPermission(Permission.ADMIN)) { + return { + applicationUrl: main.applicationUrl, + }; + } + + return main; +}; + +settingsRoutes.get('/main', (req, res, next) => { const settings = getSettings(); - if (!req.user?.hasPermission(Permission.ADMIN)) { - return res.status(200).json({ - applicationUrl: settings.main.applicationUrl, - } as Partial); + if (!req.user) { + return next({ status: 500, message: 'User missing from request' }); } - res.status(200).json(settings.main); + res.status(200).json(filteredMainSettings(req.user, settings.main)); }); settingsRoutes.post('/main', (req, res) => { @@ -44,6 +55,18 @@ settingsRoutes.post('/main', (req, res) => { return res.status(200).json(settings.main); }); +settingsRoutes.get('/main/regenerate', (req, res, next) => { + const settings = getSettings(); + + const main = settings.regenerateApiKey(); + + if (!req.user) { + return next({ status: 500, message: 'User missing from request' }); + } + + return res.status(200).json(filteredMainSettings(req.user, main)); +}); + settingsRoutes.get('/plex', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 9f9201e41..7e8b06257 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; import { mapTvResult } from '../models/Search'; import Media from '../entity/Media'; import RottenTomatoes from '../api/rottentomatoes'; +import logger from '../logger'; const tvRoutes = Router(); @@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => { return res.status(200).json(mapTvDetails(tv, media)); } catch (e) { + logger.error('Failed to get tv show', { + label: 'API', + errorMessage: e.message, + }); return next({ status: 404, message: 'TV Show does not exist' }); } }); diff --git a/server/routes/user.ts b/server/routes/user.ts index 60753e9be..e6dd136a5 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,7 +1,9 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; +import { MediaRequest } from '../entity/MediaRequest'; import { User } from '../entity/User'; import { hasPermission, Permission } from '../lib/permissions'; +import logger from '../logger'; const router = Router(); @@ -94,13 +96,49 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); - const user = await userRepository.findOneOrFail({ + const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, + relations: ['requests'], }); + + if (!user) { + return next({ status: 404, message: 'User not found' }); + } + + if (user.id === 1) { + return next({ status: 405, message: 'This account cannot be deleted.' }); + } + + if (user.hasPermission(Permission.ADMIN)) { + return next({ + status: 405, + message: 'You cannot delete users with administrative privileges.', + }); + } + + const requestRepository = getRepository(MediaRequest); + + /** + * Requests are usually deleted through a cascade constraint. Those however, do + * not trigger the removal event so listeners to not run and the parent Media + * will not be updated back to unknown for titles that were still pending. So + * we manually remove all requests from the user here so the parent media's + * properly reflect the change. + */ + await requestRepository.remove(user.requests); + await userRepository.delete(user.id); return res.status(200).json(user.filter()); } catch (e) { - next({ status: 404, message: 'User not found' }); + logger.error('Something went wrong deleting a user', { + label: 'API', + userId: req.params.id, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Something went wrong deleting the user', + }); } }); diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index beb8ff8ee..9420b85ea 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -8,7 +8,7 @@ import { useIntl } from 'react-intl'; import globalMessages from '../../../i18n/globalMessages'; import Transition from '../../Transition'; -interface ModalProps extends React.HTMLAttributes { +interface ModalProps { title?: string; onCancel?: (e?: MouseEvent) => void; onOk?: (e?: MouseEvent) => void; diff --git a/src/components/Discover/Holiday/index.tsx b/src/components/Discover/Holiday/index.tsx new file mode 100644 index 000000000..319101946 --- /dev/null +++ b/src/components/Discover/Holiday/index.tsx @@ -0,0 +1,70 @@ +import React, { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import type { MovieResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { LanguageContext } from '../../../context/LanguageContext'; +import Header from '../../Common/Header'; + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: MovieResult[]; +} + +const Holiday: React.FC = () => { + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/discover/keyword/207317/movies?page=${ + pageIndex + 1 + }&language=${locale}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + if (error) { + return
{error}
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as MovieResult[] + ); + + const isEmpty = !isLoadingInitialData && titles?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.results.length < 20); + + return ( + <> +
Happy Holidays!
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default Holiday; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 895237c74..7790150f9 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -63,6 +63,12 @@ const Discover: React.FC = () => { } = useSWR( `/api/v1/discover/movies/upcoming?language=${locale}` ); + const { + data: holUpcomingData, + error: holUpcomingError, + } = useSWR( + `/api/v1/discover/keyword/207317/movies?language=${locale}` + ); const { data: trendingData, error: trendingError } = useSWR( `/api/v1/discover/trending?language=${locale}` @@ -140,6 +146,57 @@ const Discover: React.FC = () => { placeholder={} emptyMessage={intl.formatMessage(messages.nopending)} /> + {/* Special Temporary Slider */} + + ( + + ))} + /> + {/* End Special Temporary Slider */}
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 799a32579..5d4b36c7f 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import SearchInput from './SearchInput'; import UserDropdown from './UserDropdown'; import Sidebar from './Sidebar'; -import Notifications from './Notifications'; import LanguagePicker from './LanguagePicker'; import { useRouter } from 'next/router'; import { defineMessages, FormattedMessage } from 'react-intl'; @@ -47,7 +46,6 @@ const Layout: React.FC = ({ children }) => {
-
diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 87095b7bf..56dd81e12 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -20,7 +20,7 @@ const Login: React.FC = () => { // Effect that is triggered when the `authToken` comes back from the Plex OAuth // We take the token and attempt to login. If we get a success message, we will - // ask swr to revalidate the user which _shouid_ come back with a valid user. + // ask swr to revalidate the user which _should_ come back with a valid user. useEffect(() => { const login = async () => { setProcessing(true); diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index df99de949..f11fb2b7a 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -65,6 +65,7 @@ const messages = defineMessages({ 'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.', approve: 'Approve', decline: 'Decline', + studio: 'Studio', }); interface MovieDetailsProps { @@ -484,14 +485,32 @@ const MovieDetails: React.FC = ({ movie }) => {
)} -
- - - - - {data.originalLanguage} - -
+ {data.spokenLanguages.some( + (lng) => lng.iso_639_1 === data.originalLanguage + ) && ( +
+ + + + + { + data.spokenLanguages.find( + (lng) => lng.iso_639_1 === data.originalLanguage + )?.name + } + +
+ )} + {data.productionCompanies[0] && ( +
+ + + + + {data.productionCompanies[0]?.name} + +
+ )} diff --git a/src/components/Settings/CopyButton.tsx b/src/components/Settings/CopyButton.tsx index 717667d59..0ddf4dbfb 100644 --- a/src/components/Settings/CopyButton.tsx +++ b/src/components/Settings/CopyButton.tsx @@ -25,7 +25,10 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => { return (