diff --git a/.all-contributorsrc b/.all-contributorsrc index d8cbe98d..50d69cf1 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -216,6 +216,25 @@ "contributions": [ "code" ] + }, + { + "login": "ankarhem", + "name": "Jakob Ankarhem", + "avatar_url": "https://avatars1.githubusercontent.com/u/14110063?v=4", + "profile": "https://github.com/ankarhem", + "contributions": [ + "doc", + "code" + ] + }, + { + "login": "jayesh100", + "name": "Jayesh", + "avatar_url": "https://avatars1.githubusercontent.com/u/8022175?v=4", + "profile": "https://github.com/jayesh100", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.gitignore b/.gitignore index 70bef39d..379afe40 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ config/settings.json # logs config/logs/*.log* +config/logs/*.json # dist files dist diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66d98b66..99ba8dd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,16 +14,46 @@ All help is welcome and greatly appreciated. If you would like to contribute to ### Getting Started 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. -2. Create a new branch `git checkout -b BRANCH_NAME` + ``` + git clone https://github.com/YOUR_USERNAME/overseerr.git + cd overseerr/ + ``` +2. Add the remote upstream. -- Its recommended to name the branch something relevant to the feature or fix you are working on. -- An example of this would be `fix-title-cards` or `feature-new-system`. -- Bad examples would be `patch` or `bug`. + ``` + git remote add upstream https://github.com/sct/overseerr.git + ``` -3. Install dependencies `yarn` -4. `yarn dev` to build and watch for changes +3. Create a new branch -You can also run the development environment in [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. + ``` + git checkout -b BRANCH_NAME develop + ``` + + - Its recommended to name the branch something relevant to the feature or fix you are working on. + - An example of this would be `fix-title-cards` or `feature-new-system`. + - Bad examples would be `patch` or `bug`. + +4. Run development environment + + ``` + yarn + yarn install + ``` + + - Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. + +5. Create your patch and run appropriate tests. + +6. Follow the [guidelines](#contributing-code). + +7. Should you need to update your fork you can do so by rebasing from upstream. + + ``` + git fetch upstream + git rebase upstream/develop + git push origin BRANCH_NAME -f + ``` ### Contributing Code diff --git a/README.md b/README.md index bc494612..2707710a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -128,6 +128,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
doob187

🚇
johnpyp

💻 +
Jakob Ankarhem

📖 💻 +
Jayesh

💻 diff --git a/overseerr-api.yml b/overseerr-api.yml index f6d6aab5..6fe13218 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -383,6 +383,36 @@ components: type: string name: type: string + RelatedVideo: + type: object + properties: + url: + type: string + example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ + key: + type: string + example: 9qhL2_UxXM0 + name: + type: string + example: Trailer for some movie (1978) + size: + type: number + example: 1080 + type: + type: string + example: Trailer + enum: + - Clip + - Teaser + - Trailer + - Featurette + - Opening Credits + - Behind the Scenes + - Bloopers + site: + type: string + enum: + - 'YouTube' MovieDetails: type: object properties: @@ -408,6 +438,10 @@ components: $ref: '#/components/schemas/Genre' homepage: type: string + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' originalLanguage: type: string originalTitle: @@ -463,6 +497,19 @@ components: type: array items: $ref: '#/components/schemas/Crew' + collection: + type: object + properties: + id: + type: number + example: 1 + name: + type: string + example: A collection + posterPath: + type: string + backdropPath: + type: string externalIds: $ref: '#/components/schemas/ExternalIds' mediaInfo: @@ -794,6 +841,20 @@ components: properties: webhookUrl: type: string + SlackSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string NotificationEmailSettings: type: object properties: @@ -991,6 +1052,26 @@ components: name: type: string example: 'English' + Collection: + type: object + properties: + id: + type: number + example: 123 + name: + type: string + example: A Movie Collection + overview: + type: string + example: Overview of collection + posterPath: + type: string + backdropPath: + type: string + parts: + type: array + items: + $ref: '#/components/schemas/MovieResult' securitySchemes: cookieAuth: type: apiKey @@ -1554,6 +1635,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/slack: + get: + summary: Return current slack notification settings + description: Returns current slack notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned slack settings + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + post: + summary: Update slack notification settings + description: Update current slack notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + /settings/notifications/slack/test: + post: + summary: Test the provided slack settings + description: Sends a test notification to the slack agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats @@ -1691,7 +1818,8 @@ paths: application/json: schema: type: array - $ref: '#/components/schemas/User' + items: + $ref: '#/components/schemas/User' /user/{userId}: get: @@ -2132,6 +2260,30 @@ paths: responses: '204': description: Succesfully removed request + /request/{requestId}/retry: + post: + summary: Retry a failed request + description: | + Retries a request by resending requests to Sonarr or Radarr + + Requires the `MANAGE_REQUESTS` permission or `ADMIN` + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: 1 + responses: + '200': + description: Retry triggered + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' /request/{requestId}/{status}: get: summary: Update a requests status @@ -2626,6 +2778,31 @@ paths: responses: '204': description: Succesfully removed media item + /collection/{collectionId}: + get: + summary: Request collection details + description: Returns back full collection details in JSON format + tags: + - collection + parameters: + - in: path + name: collectionId + required: true + schema: + type: number + example: 537982 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Collection details + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' security: - cookieAuth: [] diff --git a/package.json b/package.json index f8f14eed..69e4e416 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "typeorm": "^0.2.29", "uuid": "^8.3.2", "winston": "^3.3.3", + "winston-daily-rotate-file": "^4.5.0", "xml2js": "^0.4.23", "yamljs": "^0.3.0", "yup": "^0.32.8" diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 2fe47973..c076d98f 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index a1d533ee..8d57aae3 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 3a253dcc..34715194 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest index 38af1e93..45d6efa4 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -14,7 +14,7 @@ "type": "image/png" } ], - "theme_color": "#ffffff", - "background_color": "#1e2937", + "theme_color": "#2d3748", + "background_color": "#2d3748", "display": "standalone" } diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 968cb21d..f3aaedc4 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -76,7 +76,7 @@ class RadarrAPI { } }; - public addMovie = async (options: RadarrMovieOptions): Promise => { + public addMovie = async (options: RadarrMovieOptions): Promise => { try { const response = await this.axios.post(`/movie`, { title: options.title, @@ -104,7 +104,9 @@ class RadarrAPI { label: 'Radarr', options, }); + return false; } + return true; } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', @@ -112,8 +114,13 @@ class RadarrAPI { label: 'Radarr', errorMessage: e.message, options, + response: e?.response?.data, } ); + if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') { + return true; + } + return false; } }; diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index a4937876..e3cd2353 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -116,7 +116,7 @@ class SonarrAPI { } } - public async addSeries(options: AddSeriesOptions): Promise { + public async addSeries(options: AddSeriesOptions): Promise { try { const series = await this.getSeriesByTvdbId(options.tvdbid); @@ -147,9 +147,10 @@ class SonarrAPI { label: 'Sonarr', options, }); + return false; } - return newSeriesResponse.data; + return true; } const createdSeriesResponse = await this.axios.post( @@ -188,16 +189,18 @@ class SonarrAPI { label: 'Sonarr', options, }); + return false; } - return createdSeriesResponse.data; + return true; } catch (e) { logger.error('Something went wrong adding a series to Sonarr', { label: 'Sonarr API', errorMessage: e.message, error: e, + response: e?.response?.data, }); - throw new Error('Failed to add series'); + return false; } } diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 43f9f364..83fa3fad 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -190,7 +190,30 @@ export interface TmdbMovieDetails { cast: TmdbCreditCast[]; crew: TmdbCreditCrew[]; }; + belongs_to_collection?: { + id: number; + name: string; + poster_path?: string; + backdrop_path?: string; + }; external_ids: TmdbExternalIds; + videos: TmdbVideoResult; +} + +export interface TmdbVideo { + id: string; + key: string; + name: string; + site: 'YouTube'; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; } export interface TmdbTvEpisodeResult { @@ -278,6 +301,11 @@ export interface TmdbTvDetails { keywords: { results: TmdbKeyword[]; }; + videos: TmdbVideoResult; +} + +export interface TmdbVideoResult { + results: TmdbVideo[]; } export interface TmdbKeyword { @@ -344,6 +372,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult { external_ids: TmdbExternalIds; } +export interface TmdbCollection { + id: number; + name: string; + overview?: string; + poster_path?: string; + backdrop_path?: string; + parts: TmdbMovieResult[]; +} + class TheMovieDb { private apiKey = 'db55323b8d3e4154498498a75642b381'; private axios: AxiosInstance; @@ -438,7 +475,10 @@ class TheMovieDb { const response = await this.axios.get( `/movie/${movieId}`, { - params: { language, append_to_response: 'credits,external_ids' }, + params: { + language, + append_to_response: 'credits,external_ids,videos', + }, } ); @@ -459,7 +499,7 @@ class TheMovieDb { const response = await this.axios.get(`/tv/${tvId}`, { params: { language, - append_to_response: 'credits,external_ids,keywords', + append_to_response: 'credits,external_ids,keywords,videos', }, }); @@ -866,6 +906,29 @@ class TheMovieDb { ); } } + + public async getCollection({ + collectionId, + language = 'en-US', + }: { + collectionId: number; + language?: string; + }): Promise { + try { + const response = await this.axios.get( + `/collection/${collectionId}`, + { + params: { + language, + }, + } + ); + + return response.data; + } catch (e) { + throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); + } + } } export default TheMovieDb; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 09a80680..ebf0b1c9 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -69,6 +69,12 @@ export class MediaRequest { Object.assign(this, init); } + @AfterUpdate() + @AfterInsert() + public async sendMedia(): Promise { + await Promise.all([this._sendToRadarr(), this._sendToSonarr()]); + } + @AfterInsert() private async _notifyNewRequest() { if (this.status === MediaRequestStatus.PENDING) { @@ -163,7 +169,7 @@ export class MediaRequest { @AfterUpdate() @AfterInsert() - private async _updateParentStatus() { + public async updateParentStatus(): Promise { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, @@ -229,14 +235,13 @@ export class MediaRequest { } } - @AfterUpdate() - @AfterInsert() private async _sendToRadarr() { if ( this.status === MediaRequestStatus.APPROVED && this.type === MediaType.MOVIE ) { try { + const mediaRepository = getRepository(Media); const settings = getSettings(); if (settings.radarr.length === 0 && !settings.radarr[0]) { logger.info( @@ -268,17 +273,49 @@ export class MediaRequest { const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); // Run this asynchronously so we don't wait for it on the UI side - radarr.addMovie({ - profileId: radarrSettings.activeProfileId, - qualityProfileId: radarrSettings.activeProfileId, - rootFolderPath: radarrSettings.activeDirectory, - minimumAvailability: radarrSettings.minimumAvailability, - title: movie.title, - tmdbId: movie.id, - year: Number(movie.release_date.slice(0, 4)), - monitored: true, - searchNow: true, - }); + radarr + .addMovie({ + profileId: radarrSettings.activeProfileId, + qualityProfileId: radarrSettings.activeProfileId, + rootFolderPath: radarrSettings.activeDirectory, + minimumAvailability: radarrSettings.minimumAvailability, + title: movie.title, + tmdbId: movie.id, + year: Number(movie.release_date.slice(0, 4)), + monitored: true, + searchNow: true, + }) + .then(async (success) => { + if (!success) { + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + if (!media) { + logger.error('Media not present'); + return; + } + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added movie request failed to add to Radarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: movie.title, + message: 'Movie failed to add to Radarr', + notifyUser: admin, + media, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + }); + } + }); logger.info('Sent request to Radarr', { label: 'Media Request' }); } catch (e) { throw new Error( @@ -288,8 +325,6 @@ export class MediaRequest { } } - @AfterUpdate() - @AfterInsert() private async _sendToSonarr() { if ( this.status === MediaRequestStatus.APPROVED && @@ -352,23 +387,55 @@ export class MediaRequest { } // Run this asynchronously so we don't wait for it on the UI side - sonarr.addSeries({ - 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, - }); + sonarr + .addSeries({ + 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, + }) + .then(async (success) => { + if (!success) { + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added series request failed to add to Sonarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: series.name, + message: 'Series failed to add to Sonarr', + notifyUser: admin, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, + media, + extra: [ + { + name: 'Seasons', + value: this.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + }); + } + }); logger.info('Sent request to Sonarr', { label: 'Media Request' }); } catch (e) { throw new Error( diff --git a/server/index.ts b/server/index.ts index 857232d0..76371a94 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,7 @@ import notificationManager from './lib/notifications'; import DiscordAgent from './lib/notifications/agents/discord'; import EmailAgent from './lib/notifications/agents/email'; import { getAppVersion } from './utils/appVersion'; +import SlackAgent from './lib/notifications/agents/slack'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -42,7 +43,11 @@ app const settings = getSettings().load(); // Register Notification Agents - notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]); + notificationManager.registerAgents([ + new DiscordAgent(), + new EmailAgent(), + new SlackAgent(), + ]); // Start Jobs startJobs(); diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index a0df2c4c..95446945 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -158,6 +158,15 @@ class DiscordAgent } ); + if (settings.main.applicationUrl) { + fields.push({ + name: 'View Media', + value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`, + }); + } + break; + case Notification.MEDIA_FAILED: + color = EmbedColors.RED; if (settings.main.applicationUrl) { fields.push({ name: 'View Media', diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 90755e92..18cd3e59 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -112,6 +112,52 @@ class EmailAgent } } + private async sendMediaFailedEmail(payload: NotificationPayload) { + // This is getting main settings for the whole app + const applicationUrl = getSettings().main.applicationUrl; + try { + const userRepository = getRepository(User); + const users = await userRepository.find(); + + // Send to all users with the manage requests permission (or admins) + users + .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) + .forEach((user) => { + const email = this.getNewEmail(); + + email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: user.email, + }, + locals: { + body: + "A user's new request has failed to add to Sonarr or Radarr", + mediaName: payload.subject, + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.notifyUser.username, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + requestType: 'Failed Request', + }, + }); + }); + return true; + } catch (e) { + logger.error('Mail notification failed to send', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } + private async sendMediaApprovedEmail(payload: NotificationPayload) { // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; @@ -228,6 +274,9 @@ class EmailAgent case Notification.MEDIA_AVAILABLE: this.sendMediaAvailableEmail(payload); break; + case Notification.MEDIA_FAILED: + this.sendMediaFailedEmail(payload); + break; case Notification.TEST_NOTIFICATION: this.sendTestEmail(payload); break; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts new file mode 100644 index 00000000..221228d8 --- /dev/null +++ b/server/lib/notifications/agents/slack.ts @@ -0,0 +1,225 @@ +import axios from 'axios'; +import { Notification } from '..'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentSlack } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface EmbedField { + type: 'plain_text' | 'mrkdwn'; + text: string; +} + +interface TextItem { + type: 'plain_text' | 'mrkdwn'; + text: string; + emoji?: boolean; +} + +interface Element { + type: 'button'; + text?: TextItem; + value: string; + url: string; + action_id: 'button-action'; +} + +interface EmbedBlock { + type: 'header' | 'actions' | 'section' | 'context'; + block_id?: 'section789'; + text?: TextItem; + fields?: EmbedField[]; + accessory?: { + type: 'image'; + image_url: string; + alt_text: string; + }; + elements?: Element[]; +} + +interface SlackBlockEmbed { + blocks: EmbedBlock[]; +} + +class SlackAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentSlack { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.slack; + } + + public buildEmbed( + type: Notification, + payload: NotificationPayload + ): SlackBlockEmbed { + const settings = getSettings(); + let header = 'Overseerr'; + let actionUrl: string | undefined; + + const fields: EmbedField[] = []; + + switch (type) { + case Notification.MEDIA_PENDING: + header = 'New Request'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nPending Approval', + } + ); + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + case Notification.MEDIA_APPROVED: + header = 'Request Approved'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nProcessing Request', + } + ); + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + case Notification.MEDIA_AVAILABLE: + header = 'Now available!'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nAvailable', + } + ); + + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + } + + const blocks: EmbedBlock[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: header, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${payload.subject}*`, + }, + }, + ]; + + if (payload.message) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: payload.message, + }, + accessory: payload.image + ? { + type: 'image', + image_url: payload.image, + alt_text: payload.subject, + } + : undefined, + }); + } + + if (fields.length > 0) { + blocks.push({ + type: 'section', + fields: [ + ...fields, + ...(payload.extra ?? []).map( + (extra): EmbedField => ({ + type: 'mrkdwn', + text: `*${extra.name}*\n${extra.value}`, + }) + ), + ], + }); + } + + if (actionUrl) { + blocks.push({ + type: 'actions', + elements: [ + { + action_id: 'button-action', + type: 'button', + url: actionUrl, + value: 'open_overseerr', + text: { + type: 'plain_text', + text: 'Open Overseerr', + }, + }, + ], + }); + } + + return { + blocks, + }; + } + + // TODO: Add checking for type here once we add notification type filters for agents + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public shouldSend(_type: Notification): boolean { + if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending slack notification', { label: 'Notifications' }); + try { + const webhookUrl = this.getSettings().options.webhookUrl; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, this.buildEmbed(type, payload)); + + return true; + } catch (e) { + logger.error('Error sending Slack notification', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } +} + +export default SlackAgent; diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index c826bfeb..0c711abe 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -5,7 +5,8 @@ export enum Notification { MEDIA_PENDING = 2, MEDIA_APPROVED = 4, MEDIA_AVAILABLE = 8, - TEST_NOTIFICATION = 16, + MEDIA_FAILED = 16, + TEST_NOTIFICATION = 32, } class NotificationManager { diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index dae8b964..6d328f8c 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -7,6 +7,8 @@ export enum Permission { REQUEST = 32, VOTE = 64, AUTO_APPROVE = 128, + AUTO_APPROVE_MOVIE = 256, + AUTO_APPROVE_TV = 512, } /** diff --git a/server/lib/settings.ts b/server/lib/settings.ts index b0d2c45f..c3cdfee6 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -66,6 +66,12 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig { }; } +export interface NotificationAgentSlack extends NotificationAgentConfig { + options: { + webhookUrl: string; + }; +} + export interface NotificationAgentEmail extends NotificationAgentConfig { options: { emailFrom: string; @@ -81,6 +87,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { interface NotificationAgents { email: NotificationAgentEmail; discord: NotificationAgentDiscord; + slack: NotificationAgentSlack; } interface NotificationSettings { @@ -142,6 +149,13 @@ class Settings { webhookUrl: '', }, }, + slack: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + }, + }, }, }, }; diff --git a/server/logger.ts b/server/logger.ts index 06c7d025..75e80151 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -1,5 +1,17 @@ import * as winston from 'winston'; +import 'winston-daily-rotate-file'; import path from 'path'; +import fs from 'fs'; + +// Migrate away from old log +const OLD_LOG_FILE = path.join(__dirname, '../config/logs/overseerr.log'); +if (fs.existsSync(OLD_LOG_FILE)) { + const file = fs.lstatSync(OLD_LOG_FILE); + + if (!file.isSymbolicLink()) { + fs.unlinkSync(OLD_LOG_FILE); + } +} const hformat = winston.format.printf( ({ level, label, message, timestamp, ...metadata }) => { @@ -29,10 +41,14 @@ const logger = winston.createLogger({ hformat ), }), - new winston.transports.File({ - filename: path.join(__dirname, '../config/logs/overseerr.log'), - maxsize: 20971520, - maxFiles: 6, + new winston.transports.DailyRotateFile({ + filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '7d', + createSymlink: true, + symlinkName: 'overseerr.log', }), ], }); diff --git a/server/models/Collection.ts b/server/models/Collection.ts new file mode 100644 index 00000000..f80a1ad7 --- /dev/null +++ b/server/models/Collection.ts @@ -0,0 +1,29 @@ +import { TmdbCollection } from '../api/themoviedb'; +import Media from '../entity/Media'; +import { mapMovieResult, MovieResult } from './Search'; + +export interface Collection { + id: number; + name: string; + overview?: string; + posterPath?: string; + backdropPath?: string; + parts: MovieResult[]; +} + +export const mapCollection = ( + collection: TmdbCollection, + media: Media[] +): Collection => ({ + id: collection.id, + name: collection.name, + overview: collection.overview, + posterPath: collection.poster_path, + backdropPath: collection.backdrop_path, + parts: collection.parts.map((part) => + mapMovieResult( + part, + media?.find((req) => req.tmdbId === part.id) + ) + ), +}); diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 11f357e7..a9367d73 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -8,9 +8,26 @@ import { mapCrew, ExternalIds, mapExternalIds, + mapVideos, } from './common'; import Media from '../entity/Media'; +export interface Video { + url?: string; + site: 'YouTube'; + key: string; + name: string; + size: number; + type: + | 'Clip' + | 'Teaser' + | 'Trailer' + | 'Featurette' + | 'Opening Credits' + | 'Behind the Scenes' + | 'Bloopers'; +} + export interface MovieDetails { id: number; imdbId?: string; @@ -23,6 +40,7 @@ export interface MovieDetails { originalTitle: string; overview?: string; popularity: number; + relatedVideos?: Video[]; posterPath?: string; productionCompanies: ProductionCompany[]; productionCountries: { @@ -46,6 +64,12 @@ export interface MovieDetails { cast: Cast[]; crew: Crew[]; }; + collection?: { + id: number; + name: string; + posterPath?: string; + backdropPath?: string; + }; mediaInfo?: Media; externalIds: ExternalIds; } @@ -58,6 +82,7 @@ export const mapMovieDetails = ( adult: movie.adult, budget: movie.budget, genres: movie.genres, + relatedVideos: mapVideos(movie.videos), originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, @@ -87,6 +112,14 @@ export const mapMovieDetails = ( cast: movie.credits.cast.map(mapCast), crew: movie.credits.crew.map(mapCrew), }, + collection: movie.belongs_to_collection + ? { + id: movie.belongs_to_collection.id, + name: movie.belongs_to_collection.name, + posterPath: movie.belongs_to_collection.poster_path, + backdropPath: movie.belongs_to_collection.backdrop_path, + } + : undefined, externalIds: mapExternalIds(movie.external_ids), mediaInfo: media, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 7c8759a9..5ff2f631 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -8,6 +8,7 @@ import { ExternalIds, mapExternalIds, Keyword, + mapVideos, } from './common'; import { TmdbTvEpisodeResult, @@ -16,6 +17,7 @@ import { TmdbSeasonWithEpisodes, } from '../api/themoviedb'; import type Media from '../entity/Media'; +import { Video } from './Movie'; interface Episode { id: number; @@ -67,6 +69,7 @@ export interface TvDetails { genres: Genre[]; homepage: string; inProduction: boolean; + relatedVideos?: Video[]; languages: string[]; lastAirDate: string; lastEpisodeToAir?: Episode; @@ -145,6 +148,7 @@ export const mapTvDetails = ( id: genre.id, name: genre.name, })), + relatedVideos: mapVideos(show.videos), homepage: show.homepage, id: show.id, inProduction: show.in_production, diff --git a/server/models/common.ts b/server/models/common.ts index 90945dc2..733cf2df 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -2,8 +2,12 @@ import { TmdbCreditCast, TmdbCreditCrew, TmdbExternalIds, + TmdbVideo, + TmdbVideoResult, } from '../api/themoviedb'; +import { Video } from '../models/Movie'; + export interface ProductionCompany { id: number; logoPath?: string; @@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({ tvrageId: eids.tvrage_id, twitterId: eids.twitter_id, }); + +export const mapVideos = (videoResult: TmdbVideoResult): Video[] => + videoResult?.results.map(({ key, name, size, type, site }: TmdbVideo) => ({ + site, + key, + name, + size, + type, + url: siteUrlCreator(site, key), + })); + +const siteUrlCreator = (site: Video['site'], key: string): string => + ({ + YouTube: `https://www.youtube.com/watch?v=${key}/`, + }[site]); diff --git a/server/routes/collection.ts b/server/routes/collection.ts new file mode 100644 index 00000000..75f1a455 --- /dev/null +++ b/server/routes/collection.ts @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import TheMovieDb from '../api/themoviedb'; +import Media from '../entity/Media'; +import { mapCollection } from '../models/Collection'; + +const collectionRoutes = Router(); + +collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const collection = await tmdb.getCollection({ + collectionId: Number(req.params.id), + language: req.query.language as string, + }); + + const media = await Media.getRelatedMedia( + collection.parts.map((part) => part.id) + ); + + return res.status(200).json(mapCollection(collection, media)); + } catch (e) { + return next({ status: 404, message: 'Collection does not exist' }); + } +}); + +export default collectionRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index eda282da..bf094ec0 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -12,6 +12,7 @@ import movieRoutes from './movie'; import tvRoutes from './tv'; import mediaRoutes from './media'; import personRoutes from './person'; +import collectionRoutes from './collection'; const router = Router(); @@ -34,6 +35,7 @@ router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); +router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/auth', authRoutes); router.get('/', (_req, res) => { diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 492b22f7..af4992ed 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -4,6 +4,7 @@ import { mapMovieDetails } from '../models/Movie'; import { mapMovieResult } from '../models/Search'; import Media from '../entity/Media'; import RottenTomatoes from '../api/rottentomatoes'; +import logger from '../logger'; const movieRoutes = Router(); @@ -11,15 +12,19 @@ movieRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { - const movie = await tmdb.getMovie({ + const tmdbMovie = await tmdb.getMovie({ movieId: Number(req.params.id), language: req.query.language as string, }); - const media = await Media.getMedia(movie.id); + const media = await Media.getMedia(tmdbMovie.id); - return res.status(200).json(mapMovieDetails(movie, media)); + return res.status(200).json(mapMovieDetails(tmdbMovie, media)); } catch (e) { + logger.error('Something went wrong getting movie', { + label: 'Movie', + message: e.message, + }); return next({ status: 404, message: 'Movie does not exist' }); } }); diff --git a/server/routes/request.ts b/server/routes/request.ts index b5602572..857d11f7 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -127,12 +127,16 @@ requestRoutes.post( media, requestedBy: req.user, // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission(Permission.AUTO_APPROVE) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE) - ? req.user - : undefined, + status: + req.user?.hasPermission(Permission.AUTO_APPROVE) || + req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: + req.user?.hasPermission(Permission.AUTO_APPROVE) || + req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) + ? req.user + : undefined, }); await requestRepository.save(request); @@ -172,19 +176,25 @@ requestRoutes.post( } as Media, requestedBy: req.user, // If the user is an admin or has the "auto approve" permission, automatically approve the request - status: req.user?.hasPermission(Permission.AUTO_APPROVE) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, - modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE) - ? req.user - : undefined, + status: + req.user?.hasPermission(Permission.AUTO_APPROVE) || + req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: + req.user?.hasPermission(Permission.AUTO_APPROVE) || + req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + ? req.user + : undefined, seasons: finalSeasons.map( (sn) => new SeasonRequest({ seasonNumber: sn, - status: req.user?.hasPermission(Permission.AUTO_APPROVE) - ? MediaRequestStatus.APPROVED - : MediaRequestStatus.PENDING, + status: + req.user?.hasPermission(Permission.AUTO_APPROVE) || + req.user?.hasPermission(Permission.AUTO_APPROVE_TV) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, }) ), }); @@ -244,6 +254,32 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { } }); +requestRoutes.post<{ + requestId: string; +}>( + '/:requestId/retry', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + const requestRepository = getRepository(MediaRequest); + + try { + const request = await requestRepository.findOneOrFail({ + where: { id: Number(req.params.requestId) }, + relations: ['requestedBy', 'modifiedBy'], + }); + + await request.updateParentStatus(); + await request.sendMedia(); + return res.status(200).json(request); + } catch (e) { + logger.error('Error processing request retry', { + label: 'Media Request', + message: e.message, + }); + next({ status: 404, message: 'Request not found' }); + } + } +); requestRoutes.get<{ requestId: string; status: 'pending' | 'approve' | 'decline'; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index a0172221..4f22fe01 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -24,6 +24,7 @@ import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; import { Notification } from '../lib/notifications'; import DiscordAgent from '../lib/notifications/agents/discord'; import EmailAgent from '../lib/notifications/agents/email'; +import SlackAgent from '../lib/notifications/agents/slack'; const settingsRoutes = Router(); @@ -468,6 +469,40 @@ settingsRoutes.post('/notifications/discord/test', (req, res, next) => { return res.status(204).send(); }); +settingsRoutes.get('/notifications/slack', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +settingsRoutes.post('/notifications/slack', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.slack = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +settingsRoutes.post('/notifications/slack/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const slackAgent = new SlackAgent(req.body); + slackAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/notifications/email', (_req, res) => { const settings = getSettings(); diff --git a/src/assets/extlogos/discord_white.svg b/src/assets/extlogos/discord_white.svg new file mode 100644 index 00000000..50ef8d29 --- /dev/null +++ b/src/assets/extlogos/discord_white.svg @@ -0,0 +1 @@ + diff --git a/src/assets/extlogos/slack.svg b/src/assets/extlogos/slack.svg new file mode 100644 index 00000000..dbcfb00b --- /dev/null +++ b/src/assets/extlogos/slack.svg @@ -0,0 +1 @@ + diff --git a/src/assets/unavailable.svg b/src/assets/unavailable.svg index 96e8bc64..747f1c7b 100644 --- a/src/assets/unavailable.svg +++ b/src/assets/unavailable.svg @@ -1,4 +1,4 @@ - + diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx new file mode 100644 index 00000000..93e954ea --- /dev/null +++ b/src/components/CollectionDetails/index.tsx @@ -0,0 +1,267 @@ +import axios from 'axios'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import React, { useContext, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { MediaStatus } from '../../../server/constants/media'; +import type { MediaRequest } from '../../../server/entity/MediaRequest'; +import type { Collection } from '../../../server/models/Collection'; +import { LanguageContext } from '../../context/LanguageContext'; +import globalMessages from '../../i18n/globalMessages'; +import Error from '../../pages/_error'; +import Badge from '../Common/Badge'; +import Button from '../Common/Button'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import Modal from '../Common/Modal'; +import Slider from '../Slider'; +import TitleCard from '../TitleCard'; +import Transition from '../Transition'; + +const messages = defineMessages({ + overviewunavailable: 'Overview unavailable', + overview: 'Overview', + movies: 'Movies', + numberofmovies: 'Number of Movies: {count}', + requesting: 'Requesting…', + request: 'Request', + requestcollection: 'Request Collection', + requestswillbecreated: + 'The following titles will have requests created for them:', + requestSuccess: '{title} successfully requested!', +}); + +interface CollectionDetailsProps { + collection?: Collection; +} + +const CollectionDetails: React.FC = ({ + collection, +}) => { + const intl = useIntl(); + const router = useRouter(); + const { addToast } = useToasts(); + const { locale } = useContext(LanguageContext); + const [requestModal, setRequestModal] = useState(false); + const [isRequesting, setRequesting] = useState(false); + const { data, error, revalidate } = useSWR( + `/api/v1/collection/${router.query.collectionId}?language=${locale}`, + { + initialData: collection, + revalidateOnMount: true, + } + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const requestableParts = data.parts.filter( + (part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN + ); + + const requestBundle = async () => { + try { + setRequesting(true); + await Promise.all( + requestableParts.map(async (part) => { + await axios.post('/api/v1/request', { + mediaId: part.id, + mediaType: 'movie', + }); + }) + ); + + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } catch (e) { + addToast('Something went wrong requesting the collection.', { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setRequesting(false); + setRequestModal(false); + revalidate(); + } + }; + + return ( +
+ + {data.name} - Overseerr + + + requestBundle()} + okText={ + isRequesting + ? intl.formatMessage(messages.requesting) + : intl.formatMessage(messages.request) + } + okDisabled={isRequesting} + okButtonType="primary" + onCancel={() => setRequestModal(false)} + title={intl.formatMessage(messages.requestcollection)} + iconSvg={ + + + + } + > +

{intl.formatMessage(messages.requestswillbecreated)}

+
    + {data.parts + .filter( + (part) => + !part.mediaInfo || + part.mediaInfo?.status === MediaStatus.UNKNOWN + ) + .map((part) => ( +
  • {part.title}
  • + ))} +
+
+
+
+
+ +
+
+
+ {data.parts.every( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {!data.parts.every( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && + data.parts.some( + (part) => part.mediaInfo?.status === MediaStatus.AVAILABLE + ) && ( + + {intl.formatMessage(globalMessages.partiallyavailable)} + + )} +
+

{data.name}

+ + {intl.formatMessage(messages.numberofmovies, { + count: data.parts.length, + })} + +
+
+ {data.parts.some( + (part) => + !part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN + ) && ( + + )} +
+
+
+
+

+ {intl.formatMessage(messages.overview)} +

+

+ {data.overview + ? data.overview + : intl.formatMessage(messages.overviewunavailable)} +

+
+
+
+
+
+ {intl.formatMessage(messages.movies)} +
+
+
+ ( + + ))} + /> +
+
+ ); +}; + +export default CollectionDetails; diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 60a9edca..84c529c9 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -2,31 +2,66 @@ import React from 'react'; interface AlertProps { title: string; - type?: 'warning'; + type?: 'warning' | 'info'; } -const Alert: React.FC = ({ title, children }) => { - return ( -
-
-
+const Alert: React.FC = ({ title, children, type }) => { + let design = { + bgColor: 'bg-yellow-600', + titleColor: 'text-yellow-200', + textColor: 'text-yellow-300', + svg: ( + + ), + }; + + switch (type) { + case 'info': + design = { + bgColor: 'bg-indigo-600', + titleColor: 'text-indigo-200', + textColor: 'text-indigo-300', + svg: ( -
+ ), + }; + break; + } + + return ( +
+
+
{design.svg}
-

{title}

-
{children}
+

+ {title} +

+
{children}
diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 278ac771..1f7672bc 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -51,7 +51,7 @@ const Button: React.FC = ({ break; default: buttonStyle.push( - 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' + 'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50' ); } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 7a9940b3..0165d5ae 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -14,7 +14,7 @@ const DropdownItem: React.FC> = ({ ...props }) => ( {children} @@ -31,6 +31,7 @@ const ButtonWithDropdown: React.FC = ({ text, children, dropdownIcon, + className, ...props }) => { const [isOpen, setIsOpen] = useState(false); @@ -38,22 +39,22 @@ const ButtonWithDropdown: React.FC = ({ useClickOutside(buttonRef, () => setIsOpen(false)); return ( - + - + {children && ( + + )} {(!data.mediaInfo || data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
+ {data.collection && ( + + )}
{(data.voteCount > 0 || ratingData) && (
diff --git a/src/components/PermissionOption/index.tsx b/src/components/PermissionOption/index.tsx new file mode 100644 index 00000000..d779669d --- /dev/null +++ b/src/components/PermissionOption/index.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { hasPermission } from '../../../server/lib/permissions'; +import { Permission, User } from '../../hooks/useUser'; + +export interface PermissionItem { + id: string; + name: string; + description: string; + permission: Permission; + children?: PermissionItem[]; +} + +interface PermissionOptionProps { + option: PermissionItem; + currentPermission: number; + user?: User; + parent?: PermissionItem; + onUpdate: (newPermissions: number) => void; +} + +const PermissionOption: React.FC = ({ + option, + currentPermission, + onUpdate, + user, + parent, +}) => { + return ( + <> +
+
+ { + onUpdate( + hasPermission(option.permission, currentPermission) + ? currentPermission - option.permission + : currentPermission + option.permission + ); + }} + checked={ + hasPermission(option.permission, currentPermission) || + (!!parent?.permission && + hasPermission(parent.permission, currentPermission)) + } + /> +
+
+ +

{option.description}

+
+
+ {(option.children ?? []).map((child) => ( +
+ onUpdate(newPermission)} + parent={option} + /> +
+ ))} + + ); +}; + +export default PermissionOption; diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 7eb32fa0..e0a44d4f 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -44,30 +44,18 @@ const PersonDetails: React.FC = () => { } const sortedCast = combinedCredits?.cast.sort((a, b) => { - const aDate = - a.mediaType === 'movie' - ? a.releaseDate?.slice(0, 4) ?? 0 - : a.firstAirDate?.slice(0, 4) ?? 0; - const bDate = - b.mediaType === 'movie' - ? b.releaseDate?.slice(0, 4) ?? 0 - : b.firstAirDate?.slice(0, 4) ?? 0; - if (aDate > bDate) { + const aVotes = a.voteCount ?? 0; + const bVotes = b.voteCount ?? 0; + if (aVotes > bVotes) { return -1; } return 1; }); const sortedCrew = combinedCredits?.crew.sort((a, b) => { - const aDate = - a.mediaType === 'movie' - ? a.releaseDate?.slice(0, 4) ?? 0 - : a.firstAirDate?.slice(0, 4) ?? 0; - const bDate = - b.mediaType === 'movie' - ? b.releaseDate?.slice(0, 4) ?? 0 - : b.firstAirDate?.slice(0, 4) ?? 0; - if (aDate > bDate) { + const aVotes = a.voteCount ?? 0; + const bVotes = b.voteCount ?? 0; + if (aVotes > bVotes) { return -1; } return 1; @@ -75,6 +63,94 @@ const PersonDetails: React.FC = () => { const isLoading = !combinedCredits && !errorCombinedCredits; + const cast = (sortedCast ?? []).length > 0 && ( + <> +
+
+
+ {intl.formatMessage(messages.appearsin)} +
+
+
+
    + {sortedCast?.map((media, index) => { + return ( +
  • + + {media.character && ( +
    + {intl.formatMessage(messages.ascharacter, { + character: media.character, + })} +
    + )} +
  • + ); + })} +
+ + ); + + const crew = (sortedCrew ?? []).length > 0 && ( + <> +
+
+
+ {intl.formatMessage(messages.crewmember)} +
+
+
+
    + {sortedCrew?.map((media, index) => { + return ( +
  • + + {media.job && ( +
    + {media.job} +
    + )} +
  • + ); + })} +
+ + ); + return ( <> {(sortedCrew || sortedCast) && ( @@ -126,96 +202,7 @@ const PersonDetails: React.FC = () => {
- {(sortedCast ?? []).length > 0 && ( - <> -
-
-
- {intl.formatMessage(messages.appearsin)} -
-
-
-
    - {sortedCast?.map((media, index) => { - return ( -
  • - - {media.character && ( -
    - {intl.formatMessage(messages.ascharacter, { - character: media.character, - })} -
    - )} -
  • - ); - })} -
- - )} - {(sortedCrew ?? []).length > 0 && ( - <> -
-
-
- {intl.formatMessage(messages.crewmember)} -
-
-
-
    - {sortedCrew?.map((media, index) => { - return ( -
  • - - {media.job && ( -
    - {media.job} -
    - )} -
  • - ); - })} -
- - )} + {data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]} {isLoading && } ); diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index d2ea4a73..f42741de 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import { @@ -15,16 +15,21 @@ import useSWR from 'swr'; import Badge from '../../Common/Badge'; import StatusBadge from '../../StatusBadge'; import Table from '../../Common/Table'; -import { MediaRequestStatus } from '../../../../server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../../server/constants/media'; import Button from '../../Common/Button'; import axios from 'axios'; import globalMessages from '../../../i18n/globalMessages'; import Link from 'next/link'; +import { useToasts } from 'react-toast-notifications'; const messages = defineMessages({ requestedby: 'Requested by {username}', seasons: 'Seasons', notavailable: 'N/A', + failedretry: 'Something went wrong retrying the request', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -33,13 +38,17 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { interface RequestItemProps { request: MediaRequest; - onDelete: () => void; + revalidateList: () => void; } -const RequestItem: React.FC = ({ request, onDelete }) => { +const RequestItem: React.FC = ({ + request, + revalidateList, +}) => { const { ref, inView } = useInView({ triggerOnce: true, }); + const { addToast } = useToasts(); const intl = useIntl(); const { hasPermission } = useUser(); const { locale } = useContext(LanguageContext); @@ -50,13 +59,15 @@ const RequestItem: React.FC = ({ request, onDelete }) => { const { data: title, error } = useSWR( inView ? `${url}?language=${locale}` : null ); - const { data: requestData, revalidate } = useSWR( + const { data: requestData, revalidate, mutate } = useSWR( `/api/v1/request/${request.id}`, { initialData: request, } ); + const [isRetrying, setRetrying] = useState(false); + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.get(`/api/v1/request/${request.id}/${type}`); @@ -68,7 +79,23 @@ const RequestItem: React.FC = ({ request, onDelete }) => { const deleteRequest = async () => { await axios.delete(`/api/v1/request/${request.id}`); - onDelete(); + revalidateList(); + }; + + const retryRequest = async () => { + setRetrying(true); + + try { + const result = await axios.post(`/api/v1/request/${request.id}/retry`); + mutate(result.data); + } catch (e) { + addToast(intl.formatMessage(messages.failedretry), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setRetrying(false); + } }; if (!title && !error) { @@ -138,7 +165,13 @@ const RequestItem: React.FC = ({ request, onDelete }) => { )} - + {requestData.media.status === MediaStatus.UNKNOWN ? ( + + {intl.formatMessage(globalMessages.failed)} + + ) : ( + + )}
@@ -167,6 +200,31 @@ const RequestItem: React.FC = ({ request, onDelete }) => {
+ {requestData.media.status === MediaStatus.UNKNOWN && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} {requestData.status !== MediaRequestStatus.PENDING && hasPermission(Permission.MANAGE_REQUESTS) && (