From ddfc5e6aa8fc636931f495d6f23d56367466e3b5 Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 2 Mar 2021 08:18:31 +0000 Subject: [PATCH 01/77] fix: add correct permission checks to modifying user password/permissions --- server/routes/user/index.ts | 5 +++- server/routes/user/usersettings.ts | 29 ++++++++++++++++--- .../UserSettings/UserPasswordChange/index.tsx | 26 +++++++++++++++-- src/i18n/locale/en.json | 2 ++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 803aed7c..d29567a6 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -167,7 +167,10 @@ router.get<{ id: string }, UserRequestsResponse>( } ); -const canMakePermissionsChange = (permissions: number, user?: User) => +export const canMakePermissionsChange = ( + permissions: number, + user?: User +): boolean => // Only let the owner grant admin privileges !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || // Only let users with the manage settings permission, grant the same permission diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index c2e07511..e102e2e2 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; +import { canMakePermissionsChange } from '.'; import { User } from '../../entity/User'; import { UserSettings } from '../../entity/UserSettings'; import { @@ -21,6 +22,7 @@ const isOwnProfileOrAdmin = (): Middleware => { message: "You do not have permission to view this user's settings.", }); } + next(); }; return authMiddleware; @@ -137,7 +139,19 @@ userSettingsRoutes.post< if (req.body.newPassword.length < 8) { return next({ status: 400, - message: 'Password must be at least 8 characters', + message: 'Password must be at least 8 characters.', + }); + } + + if ( + (user.id === 1 && req.user?.id !== 1) || + (user.hasPermission(Permission.ADMIN) && + user.id !== req.user?.id && + req.user?.id !== 1) + ) { + return next({ + status: 403, + message: "You do not have permission to modify this user's password.", }); } @@ -283,13 +297,20 @@ userSettingsRoutes.post< return next({ status: 404, message: 'User not found.' }); } - if (user.id === 1) { + // Only let the owner user modify themselves + if (user.id === 1 && req.user?.id !== 1) { return next({ - status: 500, - message: 'Permissions for user with ID 1 cannot be modified', + status: 403, + message: 'You do not have permission to modify this user', }); } + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } user.permissions = req.body.permissions; await userRepository.save(user); diff --git a/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx index cc973aa0..545cf539 100644 --- a/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx +++ b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; -import { useUser } from '../../../../hooks/useUser'; +import { Permission, useUser } from '../../../../hooks/useUser'; import Error from '../../../../pages/_error'; import Alert from '../../../Common/Alert'; import Button from '../../../Common/Button'; @@ -33,6 +33,9 @@ const messages = defineMessages({ nopasswordsetDescription: 'This user account currently does not have a password specifically for {applicationTitle}.\ Configure a password below to enable this account to sign in as a "local user."', + nopermission: 'No Permission', + nopermissionDescription: + "You do not have permission to modify this user's password.", }); const UserPasswordChange: React.FC = () => { @@ -41,14 +44,14 @@ const UserPasswordChange: React.FC = () => { const { addToast } = useToasts(); const router = useRouter(); const { user: currentUser } = useUser(); - const { user } = useUser({ id: Number(router.query.userId) }); + const { user, hasPermission } = useUser({ id: Number(router.query.userId) }); const { data, error, revalidate } = useSWR<{ hasPassword: boolean }>( user ? `/api/v1/user/${user?.id}/settings/password` : null ); const PasswordChangeSchema = Yup.object().shape({ currentPassword: Yup.lazy(() => - data?.hasPassword + data?.hasPassword && currentUser?.id === user?.id ? Yup.string().required( intl.formatMessage(messages.validationCurrentPassword) ) @@ -73,6 +76,23 @@ const UserPasswordChange: React.FC = () => { return ; } + if ( + currentUser?.id !== user?.id && + hasPermission(Permission.ADMIN) && + currentUser?.id !== 1 + ) { + return ( + <> +
+

{intl.formatMessage(messages.password)}

+
+ + {intl.formatMessage(messages.nopermissionDescription)} + + + ); + } + return ( <>
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a7ad2a04..1a4315a8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -708,6 +708,8 @@ "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", "components.UserProfile.UserSettings.UserPasswordChange.nopasswordset": "No Password Set", "components.UserProfile.UserSettings.UserPasswordChange.nopasswordsetDescription": "This user account currently does not have a password specifically for {applicationTitle}. Configure a password below to enable this account to sign in as a \"local user.\"", + "components.UserProfile.UserSettings.UserPasswordChange.nopermission": "No Permission", + "components.UserProfile.UserSettings.UserPasswordChange.nopermissionDescription": "You do not have permission to modify this user's password.", "components.UserProfile.UserSettings.UserPasswordChange.password": "Password", "components.UserProfile.UserSettings.UserPasswordChange.save": "Save Changes", "components.UserProfile.UserSettings.UserPasswordChange.saving": "Saving…", From 1b55d2dfbc06d900e7370a4ddfd81789a25bf00c Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Tue, 2 Mar 2021 05:06:52 -0500 Subject: [PATCH 02/77] feat(ui): display "Owner" role instead of "Admin" for user ID 1 (#1050) * feat(ui): display "Owner" role instead of "Admin" for user ID 1 Also add role to user settings page, and fix the missing "Account Type" string and use the same verbiage on the user list page * feat(lang): generate translation keys * fix: utilize hasPermission returned by useUser instead of importing from server/lib/permissions --- src/components/UserList/index.tsx | 9 ++++-- .../UserGeneralSettings/index.tsx | 29 +++++++++++++++++-- src/i18n/locale/en.json | 8 ++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index ebe88b3a..e9be2cdf 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -32,13 +32,14 @@ const messages = defineMessages({ '{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex.', user: 'User', totalrequests: 'Total Requests', - usertype: 'User Type', + accounttype: 'Account Type', role: 'Role', created: 'Created', lastupdated: 'Last Updated', edit: 'Edit', bulkedit: 'Bulk Edit', delete: 'Delete', + owner: 'Owner', admin: 'Admin', plexuser: 'Plex User', deleteuser: 'Delete User', @@ -472,7 +473,7 @@ const UserList: React.FC = () => { {intl.formatMessage(messages.user)} {intl.formatMessage(messages.totalrequests)} - {intl.formatMessage(messages.usertype)} + {intl.formatMessage(messages.accounttype)} {intl.formatMessage(messages.role)} {intl.formatMessage(messages.created)} {intl.formatMessage(messages.lastupdated)} @@ -543,7 +544,9 @@ const UserList: React.FC = () => { )} - {hasPermission(Permission.ADMIN, user.permissions) + {user.id === 1 + ? intl.formatMessage(messages.owner) + : hasPermission(Permission.ADMIN, user.permissions) ? intl.formatMessage(messages.admin) : intl.formatMessage(messages.user)} diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index d426f87c..3551aff3 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -7,7 +7,7 @@ import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { Language } from '../../../../../server/lib/settings'; import useSettings from '../../../../hooks/useSettings'; -import { UserType, useUser } from '../../../../hooks/useUser'; +import { UserType, useUser, Permission } from '../../../../hooks/useUser'; import Error from '../../../../pages/_error'; import Badge from '../../../Common/Badge'; import Button from '../../../Common/Button'; @@ -19,8 +19,13 @@ const messages = defineMessages({ displayName: 'Display Name', save: 'Save Changes', saving: 'Saving…', + accounttype: 'Account Type', plexuser: 'Plex User', localuser: 'Local User', + role: 'Role', + owner: 'Owner', + admin: 'Admin', + user: 'User', toastSettingsSuccess: 'Settings successfully saved!', toastSettingsFailure: 'Something went wrong while saving settings.', region: 'Discover Region', @@ -37,7 +42,9 @@ const UserGeneralSettings: React.FC = () => { const intl = useIntl(); const { addToast } = useToasts(); const router = useRouter(); - const { user, mutate } = useUser({ id: Number(router.query.userId) }); + const { user, hasPermission, mutate } = useUser({ + id: Number(router.query.userId), + }); const { currentSettings } = useSettings(); const { data, error, revalidate } = useSWR<{ username?: string; @@ -107,7 +114,9 @@ const UserGeneralSettings: React.FC = () => { return (
-
Account Type
+
+ {intl.formatMessage(messages.accounttype)} +
{user?.userType === UserType.PLEX ? ( @@ -122,6 +131,20 @@ const UserGeneralSettings: React.FC = () => {
+
+
+ {intl.formatMessage(messages.role)} +
+
+
+ {user?.id === 1 + ? intl.formatMessage(messages.owner) + : hasPermission(Permission.ADMIN) + ? intl.formatMessage(messages.admin) + : intl.formatMessage(messages.user)} +
+
+

- + {intl.formatMessage(messages.sonarrsettings)}

- + {intl.formatMessage(messages.sonarrSettingsDescription)}

@@ -337,12 +355,14 @@ const SettingsServices: React.FC = () => { setEditSonarrModal({ open: true, sonarr })} onDelete={() => setDeleteServerModal({ @@ -373,7 +393,7 @@ const SettingsServices: React.FC = () => { clipRule="evenodd" /> - + {intl.formatMessage(messages.addsonarr)}
From 0fa005a99cd868b5a235ae9ce65b4c64b05d0f47 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Wed, 3 Mar 2021 20:27:19 -0500 Subject: [PATCH 10/77] fix(ui): fix Radarr logo alignment (#1068) --- public/images/radarr_logo.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg index 3ccb70e9..4af99613 100644 --- a/public/images/radarr_logo.svg +++ b/public/images/radarr_logo.svg @@ -1 +1 @@ - +image/svg+xml From 436523139e8f1594c352b17032734b4498d3994f Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Wed, 3 Mar 2021 22:42:19 -0500 Subject: [PATCH 11/77] feat(ui): display season count on TV details page (#1078) * feat(ui): display season count on TV details page * feat(lang): add new translation string --- src/components/TvDetails/index.tsx | 14 ++++++++++++-- src/i18n/locale/en.json | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 2139f0e2..204cd61b 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -72,6 +72,7 @@ const messages = defineMessages({ markavailable: 'Mark as Available', mark4kavailable: 'Mark 4K as Available', allseasonsmarkedavailable: '* All seasons will be marked as available.', + seasons: '{seasonCount} Seasons', }); interface TvDetailsProps { @@ -178,12 +179,21 @@ const TvDetails: React.FC = ({ tv }) => { ); } + const seasonCount = data.seasons.filter((season) => season.seasonNumber !== 0) + .length; + + if (seasonCount) { + seriesAttributes.push( + intl.formatMessage(messages.seasons, { seasonCount: seasonCount }) + ); + } + if (data.genres.length) { seriesAttributes.push(data.genres.map((g) => g.name).join(', ')); } const isComplete = - data.seasons.filter((season) => season.seasonNumber !== 0).length <= + seasonCount <= ( data.mediaInfo?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE @@ -191,7 +201,7 @@ const TvDetails: React.FC = ({ tv }) => { ).length; const is4kComplete = - data.seasons.filter((season) => season.seasonNumber !== 0).length <= + seasonCount <= ( data.mediaInfo?.seasons.filter( (season) => season.status4k === MediaStatus.AVAILABLE diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b16e0875..edf19871 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -627,6 +627,7 @@ "components.TvDetails.playonplex": "Play on Plex", "components.TvDetails.recommendations": "Recommendations", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…", + "components.TvDetails.seasons": "{seasonCount} Seasons", "components.TvDetails.showtype": "Show Type", "components.TvDetails.similar": "Similar Series", "components.TvDetails.similarsubtext": "Other series similar to {title}", From f28112f057df2589f31ae0d0b14e8b50e479fdb7 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Wed, 3 Mar 2021 23:22:35 -0500 Subject: [PATCH 12/77] feat: add genre/studio/network view to Discover results (#1067) * feat: add genres view to movie/series Discover results * feat: add studio/network view to movie/series Discover results * fix: remove with_release_type filter, since it is removing valid/desired results --- overseerr-api.yml | 146 +++++++++++++++--- server/api/themoviedb/index.ts | 117 +++++++++++--- server/api/themoviedb/interfaces.ts | 29 ++++ server/routes/discover.ts | 4 + server/routes/index.ts | 32 ++++ src/components/Discover/DiscoverMovies.tsx | 36 ++++- src/components/Discover/DiscoverTv.tsx | 36 ++++- .../Discover/DiscoverTvUpcoming.tsx | 6 +- src/components/Discover/Trending.tsx | 6 +- src/components/Discover/Upcoming.tsx | 6 +- src/components/Discover/index.tsx | 10 +- src/components/MovieDetails/index.tsx | 22 ++- src/components/TvDetails/index.tsx | 29 +++- src/i18n/locale/en.json | 4 + .../discover/movies/genre/[genreId]/index.tsx | 9 ++ .../movies/studio/[studioId]/index.tsx | 9 ++ .../discover/tv/genre/[genreId]/index.tsx | 9 ++ .../discover/tv/network/[networkId]/index.tsx | 9 ++ 18 files changed, 438 insertions(+), 81 deletions(-) create mode 100644 src/pages/discover/movies/genre/[genreId]/index.tsx create mode 100644 src/pages/discover/movies/studio/[studioId]/index.tsx create mode 100644 src/pages/discover/tv/genre/[genreId]/index.tsx create mode 100644 src/pages/discover/tv/network/[networkId]/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index b4047e52..c6502b3f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3223,6 +3223,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 10751 + - in: query + name: studio + schema: + type: number + example: 2 responses: '200': description: Results @@ -3301,6 +3311,16 @@ paths: schema: type: string example: en + - in: query + name: genre + schema: + type: number + example: 18 + - in: query + name: network + schema: + type: number + example: 1 responses: '200': description: Results @@ -4326,14 +4346,16 @@ paths: content: application/json: schema: - type: object - properties: - iso_3166_1: - type: string - example: US - english_name: - type: string - example: United States of America + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America /languages: get: summary: Languages supported by TMDb @@ -4346,17 +4368,103 @@ paths: content: application/json: schema: - type: object - properties: - iso_639_1: - type: string - example: en - english_name: - type: string - example: English - name: - type: string - example: English + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: number + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: number + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDb movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 18 + name: + type: string + example: Drama security: - cookieAuth: [] diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b7bfeb92..8fbedaa2 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -3,9 +3,13 @@ import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, + TmdbStudio, TmdbExternalIdResponse, + TmdbGenre, + TmdbGenresResult, TmdbLanguage, TmdbMovieDetails, + TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetail, TmdbRegion, @@ -30,6 +34,8 @@ interface DiscoverMovieOptions { language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; + genre?: number; + studio?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -53,6 +59,8 @@ interface DiscoverTvOptions { firstAirDateGte?: string; firstAirDateLte?: string; includeEmptyReleaseDate?: boolean; + genre?: number; + network?: number; sortBy?: | 'popularity.asc' | 'popularity.desc' @@ -120,7 +128,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`); } }; @@ -142,7 +150,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDB] Failed to fetch person combined credits: ${e.message}` + `[TMDb] Failed to fetch person combined credits: ${e.message}` ); } }; @@ -168,7 +176,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`); } }; @@ -194,7 +202,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); } }; @@ -220,7 +228,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`); } }; @@ -246,7 +254,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } } @@ -272,7 +280,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } } @@ -298,7 +306,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); } } @@ -325,7 +333,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { throw new Error( - `[TMDB] Failed to fetch tv recommendations: ${e.message}` + `[TMDb] Failed to fetch TV recommendations: ${e.message}` ); } } @@ -349,7 +357,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`); } } @@ -360,6 +368,8 @@ class TheMovieDb extends ExternalAPI { language = 'en', primaryReleaseDateGte, primaryReleaseDateLte, + genre, + studio, }: DiscoverMovieOptions = {}): Promise => { try { const data = await this.get('/discover/movie', { @@ -368,17 +378,18 @@ class TheMovieDb extends ExternalAPI { page, include_adult: includeAdult, language, - with_release_type: '3|2', region: this.region, with_original_language: this.originalLanguage, 'primary_release_date.gte': primaryReleaseDateGte, 'primary_release_date.lte': primaryReleaseDateLte, + with_genres: genre, + with_companies: studio, }, }); return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`); } }; @@ -389,6 +400,8 @@ class TheMovieDb extends ExternalAPI { firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, + genre, + network, }: DiscoverTvOptions = {}): Promise => { try { const data = await this.get('/discover/tv', { @@ -401,12 +414,14 @@ class TheMovieDb extends ExternalAPI { 'first_air_date.lte': firstAirDateLte, with_original_language: this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, + with_genres: genre, + with_networks: network, }, }); return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`); } }; @@ -432,7 +447,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); } }; @@ -459,7 +474,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -482,7 +497,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -505,7 +520,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`); } }; @@ -537,7 +552,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`); + throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`); } } @@ -564,11 +579,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - '[TMDB] Failed to find a title with the provided IMDB id' + '[TMDb] Failed to find a title with the provided IMDB id' ); } catch (e) { throw new Error( - `[TMDB] Failed to get movie by external imdb ID: ${e.message}` + `[TMDb] Failed to get movie by external imdb ID: ${e.message}` ); } } @@ -596,11 +611,11 @@ class TheMovieDb extends ExternalAPI { } throw new Error( - `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` + `[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` ); } catch (e) { throw new Error( - `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` + `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } @@ -624,7 +639,7 @@ class TheMovieDb extends ExternalAPI { return data; } catch (e) { - throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`); } } @@ -640,7 +655,7 @@ class TheMovieDb extends ExternalAPI { return regions; } catch (e) { - throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`); } } @@ -656,7 +671,59 @@ class TheMovieDb extends ExternalAPI { return languages; } catch (e) { - throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`); + throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`); + } + } + + public async getStudio(studioId: number): Promise { + try { + const data = await this.get(`/company/${studioId}`); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`); + } + } + + public async getNetwork(networkId: number): Promise { + try { + const data = await this.get(`/network/${networkId}`); + + return data; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`); + } + } + + public async getMovieGenres(): Promise { + try { + const data = await this.get( + '/genre/movie/list', + {}, + 86400 // 24 hours + ); + + const movieGenres = sortBy(data.genres, 'name'); + + return movieGenres; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`); + } + } + + public async getTvGenres(): Promise { + try { + const data = await this.get( + '/genre/tv/list', + {}, + 86400 // 24 hours + ); + + const tvGenres = sortBy(data.genres, 'name'); + + return tvGenres; + } catch (e) { + throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`); } } } diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 1b0da07e..51ae3f27 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -381,3 +381,32 @@ export interface TmdbLanguage { english_name: string; name: string; } + +export interface TmdbGenresResult { + genres: TmdbGenre[]; +} + +export interface TmdbGenre { + id: number; + name: string; +} + +export interface TmdbStudio { + id: number; + name: string; + description?: string; + headquarters?: string; + homepage?: string; + logo_path?: string; + origin_country?: string; + parent_company?: TmdbStudio; +} + +export interface TmdbNetwork { + id: number; + name: string; + headquarters?: string; + homepage?: string; + logo_path?: string; + origin_country?: string; +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e248870a..4879b4b3 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -38,6 +38,8 @@ discoverRoutes.get('/movies', async (req, res) => { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + studio: req.query.studio ? Number(req.query.studio) : undefined, }); const media = await Media.getRelatedMedia( @@ -99,6 +101,8 @@ discoverRoutes.get('/tv', async (req, res) => { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: req.query.language as string, + genre: req.query.genre ? Number(req.query.genre) : undefined, + network: req.query.network ? Number(req.query.network) : undefined, }); const media = await Media.getRelatedMedia( diff --git a/server/routes/index.ts b/server/routes/index.ts index 7527c030..b4a41624 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -74,6 +74,38 @@ router.get('/languages', isAuthenticated(), async (req, res) => { return res.status(200).json(languages); }); +router.get<{ id: string }>('/studio/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const studio = await tmdb.getStudio(Number(req.params.id)); + + return res.status(200).json(studio); +}); + +router.get<{ id: string }>('/network/:id', async (req, res) => { + const tmdb = new TheMovieDb(); + + const network = await tmdb.getNetwork(Number(req.params.id)); + + return res.status(200).json(network); +}); + +router.get('/genres/movie', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getMovieGenres(); + + return res.status(200).json(genres); +}); + +router.get('/genres/tv', isAuthenticated(), async (req, res) => { + const tmdb = new TheMovieDb(); + + const genres = await tmdb.getTvGenres(); + + return res.status(200).json(genres); +}); + router.get('/', (_req, res) => { return res.status(200).json({ api: 'Overseerr API', diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index 4ebad143..fb96b740 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,16 +1,23 @@ import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import useSWR, { useSWRInfinite } from 'swr'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; import useSettings from '../../hooks/useSettings'; import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import { useRouter } from 'next/router'; +import { + TmdbStudio, + TmdbGenre, +} from '../../../server/api/themoviedb/interfaces'; const messages = defineMessages({ discovermovies: 'Popular Movies', + genreMovies: '{genre} Movies', + studioMovies: '{studio} Movies', }); interface SearchResult { @@ -21,16 +28,27 @@ interface SearchResult { } const DiscoverMovies: React.FC = () => { + const router = useRouter(); const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); + + const { data: genres } = useSWR('/api/v1/genres/movie'); + const genre = genres?.find((g) => g.id === Number(router.query.genreId)); + + const { data: studio } = useSWR( + `/api/v1/studio/${router.query.studioId}` + ); + const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`; + return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}${ + genre ? `&genre=${genre.id}` : '' + }${studio ? `&studio=${studio.id}` : ''}`; }, { initialSize: 3, @@ -68,13 +86,17 @@ const DiscoverMovies: React.FC = () => { const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = genre + ? intl.formatMessage(messages.genreMovies, { genre: genre.name }) + : studio + ? intl.formatMessage(messages.studioMovies, { studio: studio.name }) + : intl.formatMessage(messages.discovermovies); + return ( <> - +
-
- -
+
{title}
{ + const router = useRouter(); const intl = useIntl(); const settings = useSettings(); const { locale } = useContext(LanguageContext); + + const { data: genres } = useSWR('/api/v1/genres/tv'); + const genre = genres?.find((g) => g.id === Number(router.query.genreId)); + + const { data: network } = useSWR( + `/api/v1/network/${router.query.networkId}` + ); + const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; } - return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`; + return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}${ + genre ? `&genre=${genre.id}` : '' + }${network ? `&network=${network.id}` : ''}`; }, { initialSize: 3, @@ -67,13 +85,17 @@ const DiscoverTv: React.FC = () => { const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); + const title = genre + ? intl.formatMessage(messages.genreSeries, { genre: genre.name }) + : network + ? intl.formatMessage(messages.networkSeries, { network: network.name }) + : intl.formatMessage(messages.discovertv); + return ( <> - +
-
- -
+
{title}
{ <>
-
- -
+
{intl.formatMessage(messages.upcomingtv)}
{ <>
-
- -
+
{intl.formatMessage(messages.trending)}
{ <>
-
- -
+
{intl.formatMessage(messages.upcomingmovies)}
{
- - - + {intl.formatMessage(messages.recentlyAdded)}
@@ -64,9 +62,7 @@ const Discover: React.FC = () => { )} diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 204cd61b..5902da47 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -189,7 +189,19 @@ const TvDetails: React.FC = ({ tv }) => { } if (data.genres.length) { - seriesAttributes.push(data.genres.map((g) => g.name).join(', ')); + seriesAttributes.push( + data.genres + .map((g) => ( + + {g.name} + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + )) + ); } const isComplete = @@ -684,7 +696,20 @@ const TvDetails: React.FC = ({ tv }) => { {intl.formatMessage(messages.network)} - {data.networks.map((n) => n.name).join(', ')} + {data.networks + .map((n) => ( + + {n.name} + + )) + .reduce((prev, curr) => ( + <> + {prev}, {curr} + + ))}
)} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index edf19871..75f3ad26 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -17,11 +17,15 @@ "components.Discover.discover": "Discover", "components.Discover.discovermovies": "Popular Movies", "components.Discover.discovertv": "Popular Series", + "components.Discover.genreMovies": "{genre} Movies", + "components.Discover.genreSeries": "{genre} Series", + "components.Discover.networkSeries": "{network} Series", "components.Discover.nopending": "No Pending Requests", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", "components.Discover.recentrequests": "Recent Requests", + "components.Discover.studioMovies": "{studio} Movies", "components.Discover.trending": "Trending", "components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies", diff --git a/src/pages/discover/movies/genre/[genreId]/index.tsx b/src/pages/discover/movies/genre/[genreId]/index.tsx new file mode 100644 index 00000000..f49e8169 --- /dev/null +++ b/src/pages/discover/movies/genre/[genreId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; + +const DiscoverMoviesGenrePage: NextPage = () => { + return ; +}; + +export default DiscoverMoviesGenrePage; diff --git a/src/pages/discover/movies/studio/[studioId]/index.tsx b/src/pages/discover/movies/studio/[studioId]/index.tsx new file mode 100644 index 00000000..e1371e60 --- /dev/null +++ b/src/pages/discover/movies/studio/[studioId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; + +const DiscoverMoviesStudioPage: NextPage = () => { + return ; +}; + +export default DiscoverMoviesStudioPage; diff --git a/src/pages/discover/tv/genre/[genreId]/index.tsx b/src/pages/discover/tv/genre/[genreId]/index.tsx new file mode 100644 index 00000000..344e5d9c --- /dev/null +++ b/src/pages/discover/tv/genre/[genreId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; + +const DiscoverTvGenrePage: NextPage = () => { + return ; +}; + +export default DiscoverTvGenrePage; diff --git a/src/pages/discover/tv/network/[networkId]/index.tsx b/src/pages/discover/tv/network/[networkId]/index.tsx new file mode 100644 index 00000000..b30f5377 --- /dev/null +++ b/src/pages/discover/tv/network/[networkId]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { NextPage } from 'next'; +import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; + +const DiscoverTvNetworkPage: NextPage = () => { + return ; +}; + +export default DiscoverTvNetworkPage; From b57645d382361c856281e7a74295afe16c5390f2 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Wed, 3 Mar 2021 23:31:33 -0500 Subject: [PATCH 13/77] fix(lang): fix singular form of season count (#1080) --- src/components/TvDetails/index.tsx | 2 +- src/i18n/locale/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 5902da47..fcc12bbf 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -72,7 +72,7 @@ const messages = defineMessages({ markavailable: 'Mark as Available', mark4kavailable: 'Mark 4K as Available', allseasonsmarkedavailable: '* All seasons will be marked as available.', - seasons: '{seasonCount} Seasons', + seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}', }); interface TvDetailsProps { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 75f3ad26..e9b07de0 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -631,7 +631,7 @@ "components.TvDetails.playonplex": "Play on Plex", "components.TvDetails.recommendations": "Recommendations", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…", - "components.TvDetails.seasons": "{seasonCount} Seasons", + "components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}", "components.TvDetails.showtype": "Show Type", "components.TvDetails.similar": "Similar Series", "components.TvDetails.similarsubtext": "Other series similar to {title}", From ed0a7fbdf5122a26fa936e83b76a97c55781782d Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 4 Mar 2021 05:04:15 +0000 Subject: [PATCH 14/77] fix(sonarr): correctly search when updating existing sonarr series fixes #588 --- server/api/sonarr.ts | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 7369b0b6..e2e8bd19 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -207,11 +207,6 @@ class SonarrAPI extends ExternalAPI { if (series.id) { series.seasons = this.buildSeasonList(options.seasons, series.seasons); - series.addOptions = { - ignoreEpisodesWithFiles: true, - searchForMissingEpisodes: options.searchNow, - }; - const newSeriesResponse = await this.axios.put( '/series', series @@ -225,6 +220,9 @@ class SonarrAPI extends ExternalAPI { label: 'Sonarr', movie: newSeriesResponse.data, }); + if (options.searchNow) { + this.searchSeries(newSeriesResponse.data.id); + } } else { logger.error('Failed to update series in Sonarr', { label: 'Sonarr', @@ -350,6 +348,33 @@ class SonarrAPI extends ExternalAPI { } } + public async searchSeries(seriesId: number): Promise { + logger.info('Executing series search command', { + label: 'Sonarr API', + seriesId, + }); + await this.runCommand('SeriesSearch', { seriesId }); + } + + private async runCommand( + commandName: string, + options: Record + ): Promise { + try { + await this.axios.post(`/command`, { + name: commandName, + ...options, + }); + } catch (e) { + logger.error('Something went wrong attempting to run a Sonarr command.', { + label: 'Sonarr API', + message: e.message, + }); + + throw new Error('Failed to run Sonarr command.'); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] From 63c122e5e087a1d5baabe540bcd98acc3df1039b Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 4 Mar 2021 10:18:46 +0000 Subject: [PATCH 15/77] refactor: move genre/studio/network calls into their own endpoints this commit also adds a `useDiscover` hook to help with creating discover pages with less repeating code --- overseerr-api.yml | 205 +++++++++++++++++- server/api/themoviedb/index.ts | 32 ++- server/api/themoviedb/interfaces.ts | 35 +-- server/models/Movie.ts | 20 +- server/models/Tv.ts | 20 +- server/models/common.ts | 12 + server/routes/discover.ts | 152 +++++++++++++ server/routes/index.ts | 6 +- .../Discover/DiscoverMovieGenre/index.tsx | 62 ++++++ src/components/Discover/DiscoverMovies.tsx | 88 ++------ .../Discover/DiscoverNetwork/index.tsx | 75 +++++++ .../Discover/DiscoverStudio/index.tsx | 75 +++++++ src/components/Discover/DiscoverTv.tsx | 87 ++------ .../Discover/DiscoverTvGenre/index.tsx | 62 ++++++ .../Discover/DiscoverTvUpcoming.tsx | 67 ++---- src/components/Discover/Trending.tsx | 69 ++---- src/components/Discover/Upcoming.tsx | 67 ++---- src/hooks/useDiscover.ts | 99 +++++++++ src/i18n/globalMessages.ts | 1 + .../discover/movies/genre/[genreId]/index.tsx | 4 +- .../movies/studio/[studioId]/index.tsx | 4 +- .../discover/tv/genre/[genreId]/index.tsx | 4 +- .../discover/tv/network/[networkId]/index.tsx | 4 +- 23 files changed, 885 insertions(+), 365 deletions(-) create mode 100644 src/components/Discover/DiscoverMovieGenre/index.tsx create mode 100644 src/components/Discover/DiscoverNetwork/index.tsx create mode 100644 src/components/Discover/DiscoverStudio/index.tsx create mode 100644 src/components/Discover/DiscoverTvGenre/index.tsx create mode 100644 src/hooks/useDiscover.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c6502b3f..63c874e6 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -574,6 +574,19 @@ components: type: string name: type: string + Network: + type: object + properties: + id: + type: number + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string RelatedVideo: type: object properties: @@ -3227,12 +3240,12 @@ paths: name: genre schema: type: number - example: 10751 + example: 18 - in: query name: studio schema: type: number - example: 2 + example: 1 responses: '200': description: Results @@ -3254,6 +3267,100 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' /discover/movies/upcoming: get: summary: Upcoming movies @@ -3342,6 +3449,100 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' /discover/tv/upcoming: get: summary: Discover Upcoming TV shows diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 8fbedaa2..6a059520 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -3,7 +3,6 @@ import cacheManager from '../../lib/cache'; import ExternalAPI from '../externalapi'; import { TmdbCollection, - TmdbStudio, TmdbExternalIdResponse, TmdbGenre, TmdbGenresResult, @@ -19,6 +18,7 @@ import { TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, + TmdbProductionCompany, } from './interfaces'; interface SearchOptions { @@ -675,9 +675,11 @@ class TheMovieDb extends ExternalAPI { } } - public async getStudio(studioId: number): Promise { + public async getStudio(studioId: number): Promise { try { - const data = await this.get(`/company/${studioId}`); + const data = await this.get( + `/company/${studioId}` + ); return data; } catch (e) { @@ -695,11 +697,19 @@ class TheMovieDb extends ExternalAPI { } } - public async getMovieGenres(): Promise { + public async getMovieGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { try { const data = await this.get( '/genre/movie/list', - {}, + { + params: { + language, + }, + }, 86400 // 24 hours ); @@ -711,11 +721,19 @@ class TheMovieDb extends ExternalAPI { } } - public async getTvGenres(): Promise { + public async getTvGenres({ + language = 'en', + }: { + language?: string; + } = {}): Promise { try { const data = await this.get( '/genre/tv/list', - {}, + { + params: { + language, + }, + }, 86400 // 24 hours ); diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index 51ae3f27..ebe4088a 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -109,6 +109,16 @@ export interface TmdbExternalIds { twitter_id?: string; } +export interface TmdbProductionCompany { + id: number; + logo_path?: string; + name: string; + origin_country: string; + homepage?: string; + headquarters?: string; + description?: string; +} + export interface TmdbMovieDetails { id: number; imdb_id?: string; @@ -125,12 +135,7 @@ export interface TmdbMovieDetails { original_title: string; overview?: string; popularity: number; - production_companies: { - id: number; - name: string; - logo_path?: string; - origin_country: string; - }[]; + production_companies: TmdbProductionCompany[]; production_countries: { iso_3166_1: string; name: string; @@ -227,12 +232,7 @@ export interface TmdbTvDetails { last_episode_to_air?: TmdbTvEpisodeResult; name: string; next_episode_to_air?: TmdbTvEpisodeResult; - networks: { - id: number; - name: string; - logo_path: string; - origin_country: string; - }[]; + networks: TmdbNetwork[]; number_of_episodes: number; number_of_seasons: number; origin_country: string[]; @@ -391,17 +391,6 @@ export interface TmdbGenre { name: string; } -export interface TmdbStudio { - id: number; - name: string; - description?: string; - headquarters?: string; - homepage?: string; - logo_path?: string; - origin_country?: string; - parent_company?: TmdbStudio; -} - export interface TmdbNetwork { id: number; name: string; diff --git a/server/models/Movie.ts b/server/models/Movie.ts index be4828ec..58b4fff6 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -1,6 +1,7 @@ import type { TmdbMovieDetails, TmdbMovieReleaseResult, + TmdbProductionCompany, } from '../api/themoviedb/interfaces'; import { ProductionCompany, @@ -79,6 +80,18 @@ export interface MovieDetails { plexUrl?: string; } +export const mapProductionCompany = ( + company: TmdbProductionCompany +): ProductionCompany => ({ + id: company.id, + name: company.name, + originCountry: company.origin_country, + description: company.description, + headquarters: company.headquarters, + homepage: company.homepage, + logoPath: company.logo_path, +}); + export const mapMovieDetails = ( movie: TmdbMovieDetails, media?: Media @@ -91,12 +104,7 @@ export const mapMovieDetails = ( originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, - productionCompanies: movie.production_companies.map((company) => ({ - id: company.id, - logoPath: company.logo_path, - originCountry: company.origin_country, - name: company.name, - })), + productionCompanies: movie.production_companies.map(mapProductionCompany), productionCountries: movie.production_countries, releaseDate: movie.release_date, releases: movie.release_dates, diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 3631573e..438997c9 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -9,6 +9,7 @@ import { mapExternalIds, Keyword, mapVideos, + TvNetwork, } from './common'; import type { TmdbTvEpisodeResult, @@ -16,6 +17,7 @@ import type { TmdbTvDetails, TmdbSeasonWithEpisodes, TmdbTvRatingResult, + TmdbNetwork, } from '../api/themoviedb/interfaces'; import type Media from '../entity/Media'; import { Video } from './Movie'; @@ -77,7 +79,7 @@ export interface TvDetails { lastEpisodeToAir?: Episode; name: string; nextEpisodeToAir?: Episode; - networks: ProductionCompany[]; + networks: TvNetwork[]; numberOfEpisodes: number; numberOfSeasons: number; originCountry: string[]; @@ -139,6 +141,15 @@ export const mapSeasonWithEpisodes = ( posterPath: season.poster_path, }); +export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ + id: network.id, + name: network.name, + originCountry: network.origin_country, + headquarters: network.headquarters, + homepage: network.homepage, + logoPath: network.logo_path, +}); + export const mapTvDetails = ( show: TmdbTvDetails, media?: Media @@ -157,12 +168,7 @@ export const mapTvDetails = ( languages: show.languages, lastAirDate: show.last_air_date, name: show.name, - networks: show.networks.map((network) => ({ - id: network.id, - name: network.name, - originCountry: network.origin_country, - logoPath: network.logo_path, - })), + networks: show.networks.map(mapNetwork), numberOfEpisodes: show.number_of_episodes, numberOfSeasons: show.number_of_seasons, originCountry: show.origin_country, diff --git a/server/models/common.ts b/server/models/common.ts index d26cf637..be276562 100644 --- a/server/models/common.ts +++ b/server/models/common.ts @@ -14,6 +14,18 @@ export interface ProductionCompany { logoPath?: string; originCountry: string; name: string; + description?: string; + headquarters?: string; + homepage?: string; +} + +export interface TvNetwork { + id: number; + logoPath?: string; + originCountry?: string; + name: string; + headquarters?: string; + homepage?: string; } export interface Keyword { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 4879b4b3..88f0fce5 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,6 +6,8 @@ import { isMovie, isPerson } from '../utils/typeHelpers'; import { MediaType } from '../constants/media'; import { getSettings } from '../lib/settings'; import { User } from '../entity/User'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -61,6 +63,82 @@ discoverRoutes.get('/movies', async (req, res) => { }); }); +discoverRoutes.get<{ genreId: string }>( + '/movies/genre/:genreId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + 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, + genre, + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ studioId: string }>( + '/movies/studio/:studioId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const studio = await tmdb.getStudio(Number(req.params.studioId)); + + const data = await tmdb.getDiscoverMovies({ + page: Number(req.query.page), + language: req.query.language as string, + studio: Number(req.params.studioId), + }); + + 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, + studio: mapProductionCompany(studio), + results: data.results.map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) + ) + ), + }); + } +); + discoverRoutes.get('/movies/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); @@ -124,6 +202,80 @@ discoverRoutes.get('/tv', async (req, res) => { }); }); +discoverRoutes.get<{ genreId: string }>( + '/tv/genre/:genreId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + const genre = genres.find( + (genre) => genre.id === Number(req.params.genreId) + ); + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + genre: Number(req.params.genreId), + }); + + 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, + genre, + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + +discoverRoutes.get<{ networkId: string }>( + '/tv/network/:networkId', + async (req, res) => { + const tmdb = createTmdbWithRegionLanaguage(req.user); + + const network = await tmdb.getNetwork(Number(req.params.networkId)); + + const data = await tmdb.getDiscoverTv({ + page: Number(req.query.page), + language: req.query.language as string, + network: Number(req.params.networkId), + }); + + 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, + network: mapNetwork(network), + results: data.results.map((result) => + mapTvResult( + result, + media.find( + (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + ) + ) + ), + }); + } +); + discoverRoutes.get('/tv/upcoming', async (req, res) => { const tmdb = createTmdbWithRegionLanaguage(req.user); diff --git a/server/routes/index.ts b/server/routes/index.ts index b4a41624..91dcd9ef 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion'; import serviceRoutes from './service'; import { appDataStatus, appDataPath } from '../utils/appDataVolume'; import TheMovieDb from '../api/themoviedb'; +import { mapProductionCompany } from '../models/Movie'; +import { mapNetwork } from '../models/Tv'; const router = Router(); @@ -79,7 +81,7 @@ router.get<{ id: string }>('/studio/:id', async (req, res) => { const studio = await tmdb.getStudio(Number(req.params.id)); - return res.status(200).json(studio); + return res.status(200).json(mapProductionCompany(studio)); }); router.get<{ id: string }>('/network/:id', async (req, res) => { @@ -87,7 +89,7 @@ router.get<{ id: string }>('/network/:id', async (req, res) => { const network = await tmdb.getNetwork(Number(req.params.id)); - return res.status(200).json(network); + return res.status(200).json(mapNetwork(network)); }); router.get('/genres/movie', isAuthenticated(), async (req, res) => { diff --git a/src/components/Discover/DiscoverMovieGenre/index.tsx b/src/components/Discover/DiscoverMovieGenre/index.tsx new file mode 100644 index 00000000..e340f4eb --- /dev/null +++ b/src/components/Discover/DiscoverMovieGenre/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { MovieResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + genreMovies: '{genre} Movies', +}); + +const DiscoverMovieGenre: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreMovies, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
+
{title}
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieGenre; diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index fb96b740..a84e83ef 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -1,18 +1,11 @@ -import React, { useContext } from 'react'; -import useSWR, { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; -import { useRouter } from 'next/router'; -import { - TmdbStudio, - TmdbGenre, -} from '../../../server/api/themoviedb/interfaces'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovermovies: 'Popular Movies', @@ -20,77 +13,24 @@ const messages = defineMessages({ studioMovies: '{studio} Movies', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: MovieResult[]; -} - const DiscoverMovies: React.FC = () => { - const router = useRouter(); const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data: genres } = useSWR('/api/v1/genres/movie'); - const genre = genres?.find((g) => g.id === Number(router.query.genreId)); - - const { data: studio } = useSWR( - `/api/v1/studio/${router.query.studioId}` - ); - - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}${ - genre ? `&genre=${genre.id}` : '' - }${studio ? `&studio=${studio.id}` : ''}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - - const title = genre - ? intl.formatMessage(messages.genreMovies, { genre: genre.name }) - : studio - ? intl.formatMessage(messages.studioMovies, { studio: studio.name }) - : intl.formatMessage(messages.discovermovies); + const title = intl.formatMessage(messages.discovermovies); return ( <> diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx new file mode 100644 index 00000000..b73171c4 --- /dev/null +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type { TvResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; +import { TvNetwork } from '../../../../server/models/common'; + +const messages = defineMessages({ + networkSeries: '{network} Series', +}); + +const DiscoverTvNetwork: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/network/${router.query.networkId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.networkSeries, { + network: firstResultData?.network.name, + }); + + return ( + <> + +
+
+ {firstResultData?.network.logoPath ? ( +
+ +
+ ) : ( + title + )} +
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvNetwork; diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx new file mode 100644 index 00000000..beca4a3f --- /dev/null +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type { MovieResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; +import { ProductionCompany } from '../../../../server/models/common'; + +const messages = defineMessages({ + studioMovies: '{studio} Movies', +}); + +const DiscoverMovieStudio: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/movies/studio/${router.query.studioId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.studioMovies, { + studio: firstResultData?.studio.name, + }); + + return ( + <> + +
+
+ {firstResultData?.studio.logoPath ? ( +
+ +
+ ) : ( + title + )} +
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovieStudio; diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index 05243dfa..b7e31237 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -1,18 +1,11 @@ -import React, { useContext } from 'react'; -import useSWR, { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { defineMessages, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; -import { useRouter } from 'next/router'; -import { - TmdbGenre, - TmdbNetwork, -} from '../../../server/api/themoviedb/interfaces'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ discovertv: 'Popular Series', @@ -20,76 +13,24 @@ const messages = defineMessages({ networkSeries: '{network} Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTv: React.FC = () => { - const router = useRouter(); const intl = useIntl(); - const settings = useSettings(); - const { locale } = useContext(LanguageContext); - const { data: genres } = useSWR('/api/v1/genres/tv'); - const genre = genres?.find((g) => g.id === Number(router.query.genreId)); - - const { data: network } = useSWR( - `/api/v1/network/${router.query.networkId}` - ); - - const { data, error, size, setSize } = useSWRInfinite( - (pageIndex: number, previousPageData: SearchResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { - return null; - } - - return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}${ - genre ? `&genre=${genre.id}` : '' - }${network ? `&network=${network.id}` : ''}`; - }, - { - initialSize: 3, - } - ); - - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - - const title = genre - ? intl.formatMessage(messages.genreSeries, { genre: genre.name }) - : network - ? intl.formatMessage(messages.networkSeries, { network: network.name }) - : intl.formatMessage(messages.discovertv); + const title = intl.formatMessage(messages.discovertv); return ( <> diff --git a/src/components/Discover/DiscoverTvGenre/index.tsx b/src/components/Discover/DiscoverTvGenre/index.tsx new file mode 100644 index 00000000..d4b672a5 --- /dev/null +++ b/src/components/Discover/DiscoverTvGenre/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { TvResult } from '../../../../server/models/Search'; +import ListView from '../../Common/ListView'; +import { defineMessages, useIntl } from 'react-intl'; +import Header from '../../Common/Header'; +import PageTitle from '../../Common/PageTitle'; +import { useRouter } from 'next/router'; +import globalMessages from '../../../i18n/globalMessages'; +import useDiscover from '../../../hooks/useDiscover'; +import Error from '../../../pages/_error'; + +const messages = defineMessages({ + genreSeries: '{genre} Series', +}); + +const DiscoverTvGenre: React.FC = () => { + const router = useRouter(); + const intl = useIntl(); + + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + firstResultData, + } = useDiscover( + `/api/v1/discover/tv/genre/${router.query.genreId}` + ); + + if (error) { + return ; + } + + const title = isLoadingInitialData + ? intl.formatMessage(globalMessages.loading) + : intl.formatMessage(messages.genreSeries, { + genre: firstResultData?.genre.name, + }); + + return ( + <> + +
+
{title}
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTvGenre; diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index bd2c1316..5b59f26a 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -1,74 +1,33 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { TvResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; import { defineMessages, useIntl } from 'react-intl'; -import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ upcomingtv: 'Upcoming Series', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: TvResult[]; -} - const DiscoverTvUpcoming: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - 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/tv/upcoming?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); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/tv/upcoming'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as TvResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index 45359bf1..c0f2e222 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -1,79 +1,38 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult, TvResult, PersonResult, } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ trending: 'Trending', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: (MovieResult | TvResult | PersonResult)[]; -} - const Trending: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - 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/trending?page=${ - pageIndex + 1 - }&language=${locale}`; - }, - { - initialSize: 3, - } + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover( + '/api/v1/discover/trending' ); - const isLoadingInitialData = !data && !error; - const isLoadingMore = - isLoadingInitialData || - (size > 0 && data && typeof data[size - 1] === 'undefined'); - - const fetchMore = () => { - setSize(size + 1); - }; - if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as (MovieResult | TvResult | PersonResult)[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index e89eb995..1e14f73d 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -1,74 +1,33 @@ -import React, { useContext } from 'react'; -import { useSWRInfinite } from 'swr'; +import React from 'react'; import type { MovieResult } from '../../../server/models/Search'; import ListView from '../Common/ListView'; -import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, useIntl } from 'react-intl'; import Header from '../Common/Header'; -import useSettings from '../../hooks/useSettings'; -import { MediaStatus } from '../../../server/constants/media'; import PageTitle from '../Common/PageTitle'; +import useDiscover from '../../hooks/useDiscover'; +import Error from '../../pages/_error'; const messages = defineMessages({ upcomingmovies: 'Upcoming Movies', }); -interface SearchResult { - page: number; - totalResults: number; - totalPages: number; - results: MovieResult[]; -} - const UpcomingMovies: React.FC = () => { const intl = useIntl(); - const settings = useSettings(); - 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/movies/upcoming?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); - }; + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/movies/upcoming'); if (error) { - return
{error}
; + return ; } - let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], - [] as MovieResult[] - ); - - if (settings.currentSettings.hideAvailable) { - titles = titles.filter( - (i) => - i.mediaInfo?.status !== MediaStatus.AVAILABLE && - i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE - ); - } - - const isEmpty = !isLoadingInitialData && titles?.length === 0; - const isReachingEnd = - isEmpty || (data && data[data.length - 1]?.results.length < 20); - return ( <> diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts new file mode 100644 index 00000000..57bf26b0 --- /dev/null +++ b/src/hooks/useDiscover.ts @@ -0,0 +1,99 @@ +import { useContext } from 'react'; +import { useSWRInfinite } from 'swr'; +import { MediaStatus } from '../../server/constants/media'; +import { LanguageContext } from '../context/LanguageContext'; +import useSettings from './useSettings'; + +export interface BaseSearchResult { + page: number; + totalResults: number; + totalPages: number; + results: T[]; +} + +interface BaseMedia { + mediaType: string; + mediaInfo?: { + status: MediaStatus; + }; +} + +interface DiscoverResult { + isLoadingInitialData: boolean; + isLoadingMore: boolean; + fetchMore: () => void; + isEmpty: boolean; + isReachingEnd: boolean; + error: unknown; + titles: T[]; + firstResultData?: BaseSearchResult & S; +} + +const useDiscover = >( + endpoint: string, + options?: Record +): DiscoverResult => { + const settings = useSettings(); + const { locale } = useContext(LanguageContext); + const { data, error, size, setSize } = useSWRInfinite< + BaseSearchResult & S + >( + (pageIndex: number, previousPageData) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + const params: Record = { + page: pageIndex + 1, + language: locale, + ...options, + }; + + const finalQueryString = Object.keys(params) + .map((paramKey) => `${paramKey}=${params[paramKey]}`) + .join('&'); + + return `${endpoint}?${finalQueryString}`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && !!data && typeof data[size - 1] === 'undefined'); + + const fetchMore = () => { + setSize(size + 1); + }; + + let titles = (data ?? []).reduce((a, v) => [...a, ...v.results], [] as T[]); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + + const isEmpty = !isLoadingInitialData && titles?.length === 0; + const isReachingEnd = + isEmpty || (!!data && (data[data?.length - 1]?.results.length ?? 0) < 20); + + return { + isLoadingInitialData, + isLoadingMore, + fetchMore, + isEmpty, + isReachingEnd, + error, + titles, + firstResultData: data?.[0], + }; +}; + +export default useDiscover; diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 4c30d1ef..daaace5e 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -23,6 +23,7 @@ const globalMessages = defineMessages({ edit: 'Edit', experimental: 'Experimental', advanced: 'Advanced', + loading: 'Loading…', }); export default globalMessages; diff --git a/src/pages/discover/movies/genre/[genreId]/index.tsx b/src/pages/discover/movies/genre/[genreId]/index.tsx index f49e8169..71fd2b01 100644 --- a/src/pages/discover/movies/genre/[genreId]/index.tsx +++ b/src/pages/discover/movies/genre/[genreId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; +import DiscoverMovieGenre from '../../../../../components/Discover/DiscoverMovieGenre'; const DiscoverMoviesGenrePage: NextPage = () => { - return ; + return ; }; export default DiscoverMoviesGenrePage; diff --git a/src/pages/discover/movies/studio/[studioId]/index.tsx b/src/pages/discover/movies/studio/[studioId]/index.tsx index e1371e60..4756ffbf 100644 --- a/src/pages/discover/movies/studio/[studioId]/index.tsx +++ b/src/pages/discover/movies/studio/[studioId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverMovies from '../../../../../components/Discover/DiscoverMovies'; +import DiscoverMovieStudio from '../../../../../components/Discover/DiscoverStudio'; const DiscoverMoviesStudioPage: NextPage = () => { - return ; + return ; }; export default DiscoverMoviesStudioPage; diff --git a/src/pages/discover/tv/genre/[genreId]/index.tsx b/src/pages/discover/tv/genre/[genreId]/index.tsx index 344e5d9c..bb5cbd0e 100644 --- a/src/pages/discover/tv/genre/[genreId]/index.tsx +++ b/src/pages/discover/tv/genre/[genreId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; +import DiscoverTvGenre from '../../../../../components/Discover/DiscoverTvGenre'; const DiscoverTvGenrePage: NextPage = () => { - return ; + return ; }; export default DiscoverTvGenrePage; diff --git a/src/pages/discover/tv/network/[networkId]/index.tsx b/src/pages/discover/tv/network/[networkId]/index.tsx index b30f5377..d864a4f9 100644 --- a/src/pages/discover/tv/network/[networkId]/index.tsx +++ b/src/pages/discover/tv/network/[networkId]/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { NextPage } from 'next'; -import DiscoverTv from '../../../../../components/Discover/DiscoverTv'; +import DiscoverNetwork from '../../../../../components/Discover/DiscoverNetwork'; const DiscoverTvNetworkPage: NextPage = () => { - return ; + return ; }; export default DiscoverTvNetworkPage; From 6072e8aa9a0f84e50c44a92af303aad15b5f3021 Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 4 Mar 2021 10:21:34 +0000 Subject: [PATCH 16/77] fix(lang): add missing i18n strings --- src/components/Discover/DiscoverMovies.tsx | 2 -- src/components/Discover/DiscoverTv.tsx | 2 -- src/i18n/locale/en.json | 9 +++++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index a84e83ef..cef4c623 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -9,8 +9,6 @@ import Error from '../../pages/_error'; const messages = defineMessages({ discovermovies: 'Popular Movies', - genreMovies: '{genre} Movies', - studioMovies: '{studio} Movies', }); const DiscoverMovies: React.FC = () => { diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index b7e31237..60c29225 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -9,8 +9,6 @@ import Error from '../../pages/_error'; const messages = defineMessages({ discovertv: 'Popular Series', - genreSeries: '{genre} Series', - networkSeries: '{network} Series', }); const DiscoverTv: React.FC = () => { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index e9b07de0..4b2b5932 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -14,18 +14,18 @@ "components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:", "components.CollectionDetails.requestswillbecreated4k": "The following titles will have 4K requests created for them:", "components.Common.ListView.noresults": "No results.", + "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", + "components.Discover.DiscoverNetwork.networkSeries": "{network} Series", + "components.Discover.DiscoverStudio.studioMovies": "{studio} Movies", + "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.discover": "Discover", "components.Discover.discovermovies": "Popular Movies", "components.Discover.discovertv": "Popular Series", - "components.Discover.genreMovies": "{genre} Movies", - "components.Discover.genreSeries": "{genre} Series", - "components.Discover.networkSeries": "{network} Series", "components.Discover.nopending": "No Pending Requests", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", "components.Discover.recentrequests": "Recent Requests", - "components.Discover.studioMovies": "{studio} Movies", "components.Discover.trending": "Trending", "components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies", @@ -765,6 +765,7 @@ "i18n.edit": "Edit", "i18n.experimental": "Experimental", "i18n.failed": "Failed", + "i18n.loading": "Loading…", "i18n.movies": "Movies", "i18n.partiallyavailable": "Partially Available", "i18n.pending": "Pending", From f6d00d8d1559879189f83739193c6e2acafde51d Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Fri, 5 Mar 2021 01:18:56 +0100 Subject: [PATCH 17/77] feat(telegram): add support for individual chat notifications (#1027) --- .../using-overseerr/notifications/webhooks.md | 1 + overseerr-api.yml | 10 ++ server/entity/UserSettings.ts | 6 ++ .../interfaces/api/userSettingsInterfaces.ts | 3 + server/lib/notifications/agents/telegram.ts | 15 +++ server/lib/notifications/agents/webhook.ts | 1 + server/lib/settings.ts | 2 + ...95680-AddTelegramSettingsToUserSettings.ts | 32 ++++++ server/routes/user/usersettings.ts | 12 +++ .../Notifications/NotificationsTelegram.tsx | 31 +++++- .../UserNotificationSettings/index.tsx | 99 +++++++++++++++++++ src/i18n/locale/en.json | 7 ++ 12 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 7adbc24a..68f54683 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -35,6 +35,7 @@ These variables are usually the target user of the notification. - `{{notifyuser_email}}` Target user's email. - `{{notifyuser_avatar}}` Target user's avatar. - `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set). +- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set). ### Media diff --git a/overseerr-api.yml b/overseerr-api.yml index 63c874e6..03667870 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -97,6 +97,10 @@ components: default: true discordId: type: string + telegramChatId: + type: string + telegramSendSilently: + type: boolean required: - enableNotifications MainSettings: @@ -1545,6 +1549,12 @@ components: discordId: type: string nullable: true + telegramChatId: + type: string + nullable: true + telegramSendSilently: + type: boolean + nullable: true required: - enableNotifications securitySchemes: diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 163de134..d2fe3892 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -26,6 +26,12 @@ export class UserSettings { @Column({ nullable: true }) public discordId?: string; + @Column({ nullable: true }) + public telegramChatId?: string; + + @Column({ nullable: true }) + public telegramSendSilently?: boolean; + @Column({ nullable: true }) public region?: string; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 023b7631..99d01251 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -6,5 +6,8 @@ export interface UserSettingsGeneralResponse { export interface UserSettingsNotificationsResponse { enableNotifications: boolean; + telegramBotUsername?: string; discordId?: string; + telegramChatId?: string; + telegramSendSilently?: boolean; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index fd3b4dd9..1e82a04d 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -134,6 +134,21 @@ class TelegramAgent disable_notification: this.getSettings().options.sendSilently, } as TelegramPayload); + if ( + payload.notifyUser.settings?.enableNotifications && + payload.notifyUser.settings?.telegramChatId && + payload.notifyUser.settings?.telegramChatId !== + this.getSettings().options.chatId + ) { + await axios.post(endpoint, { + text: this.buildMessage(type, payload), + parse_mode: 'MarkdownV2', + chat_id: `${payload.notifyUser.settings.telegramChatId}`, + disable_notification: + payload.notifyUser.settings.telegramSendSilently, + } as TelegramPayload); + } + return true; } catch (e) { logger.error('Error sending Telegram notification', { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 6186be49..5b846d72 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -20,6 +20,7 @@ const KeyMap: Record = { notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', notifyuser_settings_discordId: 'notifyUser.settings.discordId', + notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId', media_tmdbid: 'media.tmdbId', media_imdbid: 'media.imdbId', media_tvdbid: 'media.tvdbId', diff --git a/server/lib/settings.ts b/server/lib/settings.ts index a65c5ffb..6d3e9536 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -120,6 +120,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { + botUsername: string; botAPI: string; chatId: string; sendSilently: boolean; @@ -242,6 +243,7 @@ class Settings { enabled: false, types: 0, options: { + botUsername: '', botAPI: '', chatId: '', sendSilently: false, diff --git a/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts new file mode 100644 index 00000000..1e0175cc --- /dev/null +++ b/server/migration/1614334195680-AddTelegramSettingsToUserSettings.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTelegramSettingsToUserSettings1614334195680 + implements MigrationInterface { + name = 'AddTelegramSettingsToUserSettings1614334195680'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index e102e2e2..e20ee375 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; import { canMakePermissionsChange } from '.'; import { User } from '../../entity/User'; +import { getSettings } from '../../lib/settings'; import { UserSettings } from '../../entity/UserSettings'; import { UserSettingsGeneralResponse, @@ -198,6 +199,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); + const settings = getSettings(); try { const user = await userRepository.findOne({ @@ -210,7 +212,11 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ enableNotifications: user.settings?.enableNotifications ?? true, + telegramBotUsername: + settings?.notifications.agents.telegram.options.botUsername, discordId: user.settings?.discordId, + telegramChatId: user.settings?.telegramChatId, + telegramSendSilently: user?.settings?.telegramSendSilently, }); } catch (e) { next({ status: 500, message: e.message }); @@ -239,10 +245,14 @@ userSettingsRoutes.post< user: req.user, enableNotifications: req.body.enableNotifications, discordId: req.body.discordId, + telegramChatId: req.body.telegramChatId, + telegramSendSilently: req.body.telegramSendSilently, }); } else { user.settings.enableNotifications = req.body.enableNotifications; user.settings.discordId = req.body.discordId; + user.settings.telegramChatId = req.body.telegramChatId; + user.settings.telegramSendSilently = req.body.telegramSendSilently; } userRepository.save(user); @@ -250,6 +260,8 @@ userSettingsRoutes.post< return res.status(200).json({ enableNotifications: user.settings.enableNotifications, discordId: user.settings.discordId, + telegramChatId: user.settings.telegramChatId, + telegramSendSilently: user.settings.telegramSendSilently, }); } catch (e) { next({ status: 500, message: e.message }); diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index abfd8b0a..b5382a5b 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -14,6 +14,7 @@ const messages = defineMessages({ save: 'Save Changes', saving: 'Saving…', agentenabled: 'Enable Agent', + botUsername: 'Bot Username', botAPI: 'Bot Authentication Token', chatId: 'Chat ID', validationBotAPIRequired: 'You must provide a bot authentication token', @@ -43,9 +44,12 @@ const NotificationsTelegram: React.FC = () => { botAPI: Yup.string().required( intl.formatMessage(messages.validationBotAPIRequired) ), - chatId: Yup.string().required( - intl.formatMessage(messages.validationChatIdRequired) - ), + chatId: Yup.string() + .required(intl.formatMessage(messages.validationChatIdRequired)) + .matches( + /^[-]?\d+$/, + intl.formatMessage(messages.validationChatIdRequired) + ), }); if (!data && !error) { @@ -57,6 +61,7 @@ const NotificationsTelegram: React.FC = () => { initialValues={{ enabled: data?.enabled, types: data?.types, + botUsername: data?.options.botUsername, botAPI: data?.options.botAPI, chatId: data?.options.chatId, sendSilently: data?.options.sendSilently, @@ -71,6 +76,7 @@ const NotificationsTelegram: React.FC = () => { botAPI: values.botAPI, chatId: values.chatId, sendSilently: values.sendSilently, + botUsername: values.botUsername, }, }); addToast(intl.formatMessage(messages.telegramsettingssaved), { @@ -96,6 +102,7 @@ const NotificationsTelegram: React.FC = () => { botAPI: values.botAPI, chatId: values.chatId, sendSilently: values.sendSilently, + botUsername: values.botUsername, }, }); @@ -147,6 +154,24 @@ const NotificationsTelegram: React.FC = () => { +
+ +
+
+ +
+ {errors.botUsername && touched.botUsername && ( +
{errors.botUsername}
+ )} +
+
+
+ +
+
+ +
+ {errors.telegramChatId && touched.telegramChatId && ( +
{errors.telegramChatId}
+ )} +
+
+
+ +
+ +
+
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 4b2b5932..695fbbe8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -306,6 +306,7 @@ "components.Settings.Notifications.authPass": "SMTP Password", "components.Settings.Notifications.authUser": "SMTP Username", "components.Settings.Notifications.botAPI": "Bot Authentication Token", + "components.Settings.Notifications.botUsername": "Bot Username", "components.Settings.Notifications.chatId": "Chat ID", "components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.", "components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved successfully!", @@ -718,9 +719,15 @@ "components.UserProfile.UserSettings.UserNotificationSettings.plexuser": "Plex User", "components.UserProfile.UserSettings.UserNotificationSettings.save": "Save Changes", "components.UserProfile.UserSettings.UserNotificationSettings.saving": "Saving…", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Send Silently", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Send telegram notifications silently", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Telegram Chat ID", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTip": "The Chat ID can be aquired by adding @get_id_bot to the chat.", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Start a chat by clicking here. Then get the group Chat ID by adding @get_id_bot to that chat and send /my_id to the chat", "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsFailure": "Something went wrong while saving settings.", "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsSuccess": "Settings successfully saved!", "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid Discord user ID", + "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid Telegram Chat ID", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", From bdf67e732b6c77cbae768a25edfc9a663ef0108b Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Thu, 4 Mar 2021 22:35:08 -0500 Subject: [PATCH 18/77] fix(lang): edit new Telegram-related strings to conform to style guide (#1093) --- .../UserNotificationSettings/index.tsx | 19 ++++++++++--------- src/i18n/locale/en.json | 10 +++++----- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx index 9e3c9f33..37213016 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -20,14 +20,12 @@ const messages = defineMessages({ 'The ID number for your Discord user account', validationDiscordId: 'You must provide a valid Discord user ID', telegramChatId: 'Telegram Chat ID', - telegramChatIdTip: - 'The Chat ID can be aquired by adding @get_id_bot to the chat.', + telegramChatIdTip: 'Add @get_id_bot to the chat', telegramChatIdTipLong: - 'Start a chat by clicking here.\ - Then get the group Chat ID by adding @get_id_bot to that chat and send /my_id to the chat', - sendSilently: 'Send Silently', - sendSilentlyDescription: 'Send telegram notifications silently', - validationTelegramChatId: 'You must provide a valid Telegram Chat ID', + 'Start a chat, add @get_id_bot, and issue the /my_id command', + sendSilently: 'Send Telegram Messages Silently', + sendSilentlyDescription: 'Send notifications with no sound', + validationTelegramChatId: 'You must provide a valid Telegram chat ID', save: 'Save Changes', saving: 'Saving…', plexuser: 'Plex User', @@ -47,10 +45,10 @@ const UserNotificationSettings: React.FC = () => { const UserNotificationSettingsSchema = Yup.object().shape({ discordId: Yup.string() - .optional() + .nullable() .matches(/^\d{17,18}$/, intl.formatMessage(messages.validationDiscordId)), telegramChatId: Yup.string() - .optional() + .nullable() .matches( /^[-]?\d+$/, intl.formatMessage(messages.validationTelegramChatId) @@ -184,6 +182,9 @@ const UserNotificationSettings: React.FC = () => { ); }, + code: function code(msg) { + return {msg}; + }, }) : intl.formatMessage(messages.telegramChatIdTip, { GetIdBotLink: function GetIdBotLink(msg) { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 695fbbe8..c3821d33 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -719,15 +719,15 @@ "components.UserProfile.UserSettings.UserNotificationSettings.plexuser": "Plex User", "components.UserProfile.UserSettings.UserNotificationSettings.save": "Save Changes", "components.UserProfile.UserSettings.UserNotificationSettings.saving": "Saving…", - "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Send Silently", - "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Send telegram notifications silently", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Send Telegram Messages Silently", + "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Send notifications with no sound", "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Telegram Chat ID", - "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTip": "The Chat ID can be aquired by adding @get_id_bot to the chat.", - "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Start a chat by clicking here. Then get the group Chat ID by adding @get_id_bot to that chat and send /my_id to the chat", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTip": "Add @get_id_bot to the chat", + "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Start a chat, add @get_id_bot, and issue the /my_id command", "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsFailure": "Something went wrong while saving settings.", "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsSuccess": "Settings successfully saved!", "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid Discord user ID", - "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid Telegram Chat ID", + "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid Telegram chat ID", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", From 420038d5ffdd4070df03e5c5cb6ef8d6208fddb5 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Fri, 5 Mar 2021 08:12:48 +0100 Subject: [PATCH 19/77] fix(requests): add plex url to request item (#1088) --- src/components/RequestList/RequestItem/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 59cdea66..73c60e98 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -209,6 +209,8 @@ const RequestItem: React.FC = ({ ).length > 0 } is4k={requestData.is4k} + plexUrl={requestData.media.plexUrl} + plexUrl4k={requestData.media.plexUrl4k} /> )} From 0c4637f779d8904037b9cbd5fe9166cf05a891c5 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 5 Mar 2021 03:33:20 -0500 Subject: [PATCH 20/77] fix(ui): add alt prop to studio/network logos & fix blinking text cursor (#1095) --- src/components/Discover/DiscoverNetwork/index.tsx | 6 +++--- src/components/Discover/DiscoverStudio/index.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx index b73171c4..66b8c1a6 100644 --- a/src/components/Discover/DiscoverNetwork/index.tsx +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -49,9 +49,9 @@ const DiscoverTvNetwork: React.FC = () => { {firstResultData?.network.logoPath ? (
) : ( diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx index beca4a3f..f7fd7f7a 100644 --- a/src/components/Discover/DiscoverStudio/index.tsx +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -49,9 +49,9 @@ const DiscoverMovieStudio: React.FC = () => { {firstResultData?.studio.logoPath ? (
) : ( From cd21865c4d5be00c13c372e0b7a058f61ec855a2 Mon Sep 17 00:00:00 2001 From: sct Date: Sat, 6 Mar 2021 00:46:53 +0900 Subject: [PATCH 21/77] feat(ui): request list redesign (#1099) --- .../RequestList/RequestItem/index.tsx | 440 +++++++++--------- src/components/RequestList/index.tsx | 227 ++++----- src/i18n/locale/en.json | 8 +- 3 files changed, 344 insertions(+), 331 deletions(-) diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 73c60e98..16a98dd3 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -1,12 +1,7 @@ import React, { useContext, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import type { MediaRequest } from '../../../../server/entity/MediaRequest'; -import { - useIntl, - FormattedDate, - FormattedRelativeTime, - defineMessages, -} from 'react-intl'; +import { useIntl, FormattedRelativeTime, defineMessages } from 'react-intl'; import { useUser, Permission } from '../../../hooks/useUser'; import { LanguageContext } from '../../../context/LanguageContext'; import type { MovieDetails } from '../../../../server/models/Movie'; @@ -14,7 +9,6 @@ import type { TvDetails } from '../../../../server/models/Tv'; import useSWR from 'swr'; import Badge from '../../Common/Badge'; import StatusBadge from '../../StatusBadge'; -import Table from '../../Common/Table'; import { MediaRequestStatus, MediaStatus, @@ -25,11 +19,16 @@ import globalMessages from '../../../i18n/globalMessages'; import Link from 'next/link'; import { useToasts } from 'react-toast-notifications'; import RequestModal from '../../RequestModal'; +import ConfirmButton from '../../Common/ConfirmButton'; const messages = defineMessages({ seasons: 'Seasons', notavailable: 'N/A', failedretry: 'Something went wrong while retrying the request.', + areyousure: 'Are you sure?', + status: 'Status', + requested: 'Requested', + modifiedby: 'Modified By', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -101,22 +100,24 @@ const RequestItem: React.FC = ({ if (!title && !error) { return ( - - - +
); } if (!title || !requestData) { return ( - - - +
); } return ( - + <> = ({ setShowEditModal(false); }} /> - -
- +
+
+ - - - - -
+ alt="" + className="h-full transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer w-14 lg:w-auto lg:h-full transform-gpu hover:scale-105 hover:shadow-md" + /> +
= ({ : `/tv/${requestData.media.tmdbId}` } > - + {isMovie(title) ? title.title : title.name} - + = ({ {requestData.seasons.length > 0 && ( -
+
{intl.formatMessage(messages.seasons)} @@ -188,191 +185,204 @@ const RequestItem: React.FC = ({ )}
- - - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN || - requestData.status === MediaRequestStatus.DECLINED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - - ) : ( - 0 - } - is4k={requestData.is4k} - plexUrl={requestData.media.plexUrl} - plexUrl4k={requestData.media.plexUrl4k} - /> - )} - - -
- - - -
-
- -
- {requestData.modifiedBy ? ( - -
- - - {requestData.modifiedBy.displayName} ( - - ) - -
+
+
+ {intl.formatMessage(messages.status)} + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN || + requestData.status === MediaRequestStatus.DECLINED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={requestData.media.plexUrl} + plexUrl4k={requestData.media.plexUrl4k} + /> + )} +
+
+ + {intl.formatMessage(messages.requested)} - ) : ( - N/A - )} + + {intl.formatDate(requestData.createdAt)} + +
+
+ + {intl.formatMessage(messages.modifiedby)} + + + {requestData.modifiedBy ? ( + + + + + + {requestData.modifiedBy.displayName} ( + + ) + + + + + ) : ( + N/A + )} + +
- - - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN && - requestData.status !== MediaRequestStatus.DECLINED && - hasPermission(Permission.MANAGE_REQUESTS) && ( - - )} - {requestData.status !== MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( - + )} + {requestData.status !== MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + deleteRequest()} + confirmText={intl.formatMessage(messages.areyousure)} + className="w-full" > - - - - {intl.formatMessage(globalMessages.delete)} - - - )} - {requestData.status === MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( - <> - - - -
- - - - - - - - - )} - - + + + + + {intl.formatMessage(globalMessages.edit)} + + + + + )} +
+
+ ); }; diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 0be3bb00..27db650c 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -1,20 +1,15 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import useSWR from 'swr'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; import LoadingSpinner from '../Common/LoadingSpinner'; import RequestItem from './RequestItem'; import Header from '../Common/Header'; -import Table from '../Common/Table'; import Button from '../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; import PageTitle from '../Common/PageTitle'; const messages = defineMessages({ requests: 'Requests', - mediaInfo: 'Media Info', - status: 'Status', - requestedAt: 'Requested At', - modifiedBy: 'Last Modified By', showingresults: 'Showing {from} to {to} of {total} results', resultsperpage: 'Display {pageSize} results per page', @@ -46,6 +41,32 @@ const RequestList: React.FC = () => { pageIndex * currentPageSize }&filter=${currentFilter}&sort=${currentSort}` ); + + // Restore last set filter values on component mount + useEffect(() => { + const filterString = window.localStorage.getItem('rl-filter-settings'); + + if (filterString) { + const filterSettings = JSON.parse(filterString); + + setCurrentFilter(filterSettings.currentFilter); + setCurrentSort(filterSettings.currentSort); + setCurrentPageSize(filterSettings.currentPageSize); + } + }, []); + + // Set fitler values to local storage any time they are changed + useEffect(() => { + window.localStorage.setItem( + 'rl-filter-settings', + JSON.stringify({ + currentFilter, + currentSort, + currentPageSize, + }) + ); + }, [currentFilter, currentSort, currentPageSize]); + if (!data && !error) { return ; } @@ -60,7 +81,7 @@ const RequestList: React.FC = () => { return ( <> -
+
{intl.formatMessage(messages.requests)}
@@ -140,114 +161,96 @@ const RequestList: React.FC = () => {
- - - - {intl.formatMessage(messages.mediaInfo)} - {intl.formatMessage(messages.status)} - {intl.formatMessage(messages.requestedAt)} - {intl.formatMessage(messages.modifiedBy)} - - - - - {data.results.map((request) => { - return ( - revalidate()} - /> - ); - })} + {data.results.map((request) => { + return ( +
+ revalidate()} + /> +
+ ); + })} - {data.results.length === 0 && ( -
- -
- - {intl.formatMessage(messages.noresults)} - - {currentFilter !== 'all' && ( -
- -
- )} -
-
- - )} - - - - -
+ + + + + + + ), + })} + +
+
+ + +
+ +
); }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index c3821d33..eafcb692 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -165,27 +165,27 @@ "components.RequestButton.viewrequest4k": "View 4K Request", "components.RequestCard.all": "All", "components.RequestCard.seasons": "Seasons", + "components.RequestList.RequestItem.areyousure": "Are you sure?", "components.RequestList.RequestItem.failedretry": "Something went wrong while retrying the request.", + "components.RequestList.RequestItem.modifiedby": "Modified By", "components.RequestList.RequestItem.notavailable": "N/A", + "components.RequestList.RequestItem.requested": "Requested", "components.RequestList.RequestItem.seasons": "Seasons", + "components.RequestList.RequestItem.status": "Status", "components.RequestList.filterAll": "All", "components.RequestList.filterApproved": "Approved", "components.RequestList.filterAvailable": "Available", "components.RequestList.filterPending": "Pending", "components.RequestList.filterProcessing": "Processing", - "components.RequestList.mediaInfo": "Media Info", - "components.RequestList.modifiedBy": "Last Modified By", "components.RequestList.next": "Next", "components.RequestList.noresults": "No results.", "components.RequestList.previous": "Previous", - "components.RequestList.requestedAt": "Requested At", "components.RequestList.requests": "Requests", "components.RequestList.resultsperpage": "Display {pageSize} results per page", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.showingresults": "Showing {from} to {to} of {total} results", "components.RequestList.sortAdded": "Request Date", "components.RequestList.sortModified": "Last Modified", - "components.RequestList.status": "Status", "components.RequestModal.AdvancedRequester.advancedoptions": "Advanced Options", "components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.", "components.RequestModal.AdvancedRequester.default": "(Default)", From 778dda67d54df87347dd79577ef1bdc88d3c1d3f Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 5 Mar 2021 10:52:43 -0500 Subject: [PATCH 22/77] fix(frontend): check for ID instead of email after initial setup Plex login (#1097) --- src/components/Setup/LoginWithPlex.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Setup/LoginWithPlex.tsx b/src/components/Setup/LoginWithPlex.tsx index 3dc0404e..d62db786 100644 --- a/src/components/Setup/LoginWithPlex.tsx +++ b/src/components/Setup/LoginWithPlex.tsx @@ -25,7 +25,7 @@ const LoginWithPlex: React.FC = ({ onComplete }) => { const login = async () => { const response = await axios.post('/api/v1/auth/plex', { authToken }); - if (response.data?.email) { + if (response.data?.id) { revalidate(); } }; From b5ce7f0cabd8c58a768c874b00a1b21c4ddf4a0f Mon Sep 17 00:00:00 2001 From: nuro <4991309+NuroDev@users.noreply.github.com> Date: Fri, 5 Mar 2021 16:04:51 +0000 Subject: [PATCH 23/77] docs: added Docker compose installation example (#1072) [skip ci] * Added Docker compose installation example * Update docs/getting-started/installation.md Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com> * Updated compose example formatting * Added complete docker-compose file example * Update docs/getting-started/installation.md Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com> * Added Docker compose installation example * Update docs/getting-started/installation.md Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com> * Updated compose example formatting * Added complete docker-compose file example * Update docs/getting-started/installation.md Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Co-authored-by: sct --- docs/getting-started/installation.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b0efe68d..29327b7c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -25,6 +25,30 @@ docker run -d \ {% endtab %} +{% tab title="Compose" %} + +**docker-compose.yml:** + +```yaml +--- +version: "3" + +services: + overseerr: + image: sctx/overseerr:latest + container_name: overseerr + environment: + - LOG_LEVEL=info + - TZ=Asia/Tokyo + ports: + - 5055:5055 + volumes: + - /path/to/appdata/config:/app/config + restart: unless-stopped +``` + +{% endtab %} + {% tab title="UID/GID" %} ```text From 4f1a8a0a7823cc385263dddb37c1861eb692482d Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 6 Mar 2021 01:05:34 +0900 Subject: [PATCH 24/77] docs: add NuroDev as a contributor (#1100) [skip ci] * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3671ef22..523764a9 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -312,6 +312,15 @@ "contributions": [ "code" ] + }, + { + "login": "NuroDev", + "name": "nuro", + "avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4", + "profile": "https://nuro.dev", + "contributions": [ + "doc" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 70bd55d5..5d0fe2f4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

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

💻
Douglas Parker

📖
Daniel Carter

💻 +
nuro

📖 From 92508b3f42aa445ccca82db7518e75f343a97ade Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 6 Mar 2021 01:15:23 +0900 Subject: [PATCH 25/77] docs: add onedr0p as a contributor (#1101) [skip ci] * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 523764a9..d693838e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -321,6 +321,15 @@ "contributions": [ "doc" ] + }, + { + "login": "onedr0p", + "name": "ᗪєνιη ᗷυнʟ", + "avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4", + "profile": "https://github.com/onedr0p", + "contributions": [ + "infra" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/README.md b/README.md index 5d0fe2f4..be86fdf2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

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

📖
Daniel Carter

💻
nuro

📖 +
ᗪєνιη ᗷυнʟ

🚇 From e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 5 Mar 2021 20:16:56 -0500 Subject: [PATCH 26/77] fix(ui): correct language usage re: "sync" vs. "scan" (#1079) --- overseerr-api.yml | 12 +++---- server/job/plexsync/index.ts | 10 +++--- server/job/radarrsync/index.ts | 6 ++-- server/job/schedule.ts | 34 ++++++++++--------- server/job/sonarrsync/index.ts | 6 ++-- src/components/MovieDetails/index.tsx | 3 +- src/components/Settings/RadarrModal/index.tsx | 2 +- .../Settings/SettingsJobsCache/index.tsx | 12 ++++--- src/components/Settings/SettingsPlex.tsx | 8 ++--- src/components/Settings/SonarrModal/index.tsx | 2 +- src/components/Setup/index.tsx | 7 ++-- src/components/TvDetails/index.tsx | 3 +- src/i18n/locale/en.json | 22 ++++++------ 13 files changed, 67 insertions(+), 60 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 03667870..fc96da70 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1716,13 +1716,13 @@ paths: $ref: '#/components/schemas/PlexLibrary' /settings/plex/sync: get: - summary: Get status of full Plex library sync - description: Returns sync progress in a JSON array. + summary: Get status of full Plex library scan + description: Returns scan progress in a JSON array. tags: - settings responses: '200': - description: Status of Plex sync + description: Status of Plex scan content: application/json: schema: @@ -1744,8 +1744,8 @@ paths: items: $ref: '#/components/schemas/PlexLibrary' post: - summary: Start full Plex library sync - description: Runs a full Plex library sync and returns the progress in a JSON array. + summary: Start full Plex library scan + description: Runs a full Plex library scan and returns the progress in a JSON array. tags: - settings requestBody: @@ -1762,7 +1762,7 @@ paths: example: false responses: '200': - description: Status of Plex sync + description: Status of Plex scan content: application/json: schema: diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index f4a57c62..60840e0b 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -77,7 +77,7 @@ class JobPlexSync { if (!metadata.Guid) { logger.debug('No Guid metadata for this title. Skipping', { - label: 'Plex Sync', + label: 'Plex Scan', ratingKey: plexitem.ratingKey, }); return; @@ -794,7 +794,7 @@ class JobPlexSync { level: 'info' | 'error' | 'debug' | 'warn' = 'debug', optional?: Record ): void { - logger[level](message, { label: 'Plex Sync', ...optional }); + logger[level](message, { label: 'Plex Scan', ...optional }); } // checks if any of this.libraries has Hama agent set in Plex @@ -812,7 +812,7 @@ class JobPlexSync { const settings = getSettings(); const sessionId = uuid(); this.sessionId = sessionId; - logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' }); + logger.info('Plex scan starting', { sessionId, label: 'Plex Scan' }); try { this.running = true; const userRepository = getRepository(User); @@ -822,7 +822,7 @@ class JobPlexSync { }); if (!admin) { - return this.log('No admin configured. Plex sync skipped.', 'warn'); + return this.log('No admin configured. Plex scan skipped.', 'warn'); } this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); @@ -896,7 +896,7 @@ class JobPlexSync { ); } catch (e) { logger.error('Sync interrupted', { - label: 'Plex Sync', + label: 'Plex Scan', errorMessage: e.message, }); } finally { diff --git a/server/job/radarrsync/index.ts b/server/job/radarrsync/index.ts index 57f88ee0..e8b0c890 100644 --- a/server/job/radarrsync/index.ts +++ b/server/job/radarrsync/index.ts @@ -32,7 +32,7 @@ class JobRadarrSync { const settings = getSettings(); const sessionId = uuid(); this.sessionId = sessionId; - this.log('Radarr sync starting', 'info', { sessionId }); + this.log('Radarr scan starting', 'info', { sessionId }); try { this.running = true; @@ -75,7 +75,7 @@ class JobRadarrSync { } } - this.log('Radarr sync complete', 'info'); + this.log('Radarr scan complete', 'info'); } catch (e) { this.log('Something went wrong.', 'error', { errorMessage: e.message }); } finally { @@ -241,7 +241,7 @@ class JobRadarrSync { level: 'info' | 'error' | 'debug' | 'warn' = 'debug', optional?: Record ): void { - logger[level](message, { label: 'Radarr Sync', ...optional }); + logger[level](message, { label: 'Radarr Scan', ...optional }); } } diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 342f54a1..7bbf580d 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -17,13 +17,13 @@ interface ScheduledJob { export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { - // Run recently added plex sync every 5 minutes + // Run recently added plex scan every 5 minutes scheduledJobs.push({ - id: 'plex-recently-added-sync', - name: 'Plex Recently Added Sync', + id: 'plex-recently-added-scan', + name: 'Plex Recently Added Scan', type: 'process', job: schedule.scheduleJob('0 */5 * * * *', () => { - logger.info('Starting scheduled job: Plex Recently Added Sync', { + logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); jobPlexRecentSync.run(); @@ -32,39 +32,41 @@ export const startJobs = (): void => { cancelFn: () => jobPlexRecentSync.cancel(), }); - // Run full plex sync every 24 hours + // Run full plex scan every 24 hours scheduledJobs.push({ - id: 'plex-full-sync', - name: 'Plex Full Library Sync', + id: 'plex-full-scan', + name: 'Plex Full Library Scan', type: 'process', job: schedule.scheduleJob('0 0 3 * * *', () => { - logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' }); + logger.info('Starting scheduled job: Plex Full Library Scan', { + label: 'Jobs', + }); jobPlexFullSync.run(); }), running: () => jobPlexFullSync.status().running, cancelFn: () => jobPlexFullSync.cancel(), }); - // Run full radarr sync every 24 hours + // Run full radarr scan every 24 hours scheduledJobs.push({ - id: 'radarr-sync', - name: 'Radarr Sync', + id: 'radarr-scan', + name: 'Radarr Scan', type: 'process', job: schedule.scheduleJob('0 0 4 * * *', () => { - logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' }); + logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); jobRadarrSync.run(); }), running: () => jobRadarrSync.status().running, cancelFn: () => jobRadarrSync.cancel(), }); - // Run full sonarr sync every 24 hours + // Run full sonarr scan every 24 hours scheduledJobs.push({ - id: 'sonarr-sync', - name: 'Sonarr Sync', + id: 'sonarr-scan', + name: 'Sonarr Scan', type: 'process', job: schedule.scheduleJob('0 30 4 * * *', () => { - logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' }); + logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); jobSonarrSync.run(); }), running: () => jobSonarrSync.status().running, diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts index 3685af48..affcdbb4 100644 --- a/server/job/sonarrsync/index.ts +++ b/server/job/sonarrsync/index.ts @@ -35,7 +35,7 @@ class JobSonarrSync { const settings = getSettings(); const sessionId = uuid(); this.sessionId = sessionId; - this.log('Sonarr sync starting', 'info', { sessionId }); + this.log('Sonarr scan starting', 'info', { sessionId }); try { this.running = true; @@ -78,7 +78,7 @@ class JobSonarrSync { } } - this.log('Sonarr sync complete', 'info'); + this.log('Sonarr scan complete', 'info'); } catch (e) { this.log('Something went wrong.', 'error', { errorMessage: e.message }); } finally { @@ -374,7 +374,7 @@ class JobSonarrSync { level: 'info' | 'error' | 'debug' | 'warn' = 'debug', optional?: Record ): void { - logger[level](message, { label: 'Sonarr Sync', ...optional }); + logger[level](message, { label: 'Sonarr Scan', ...optional }); } } diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index afedec95..c0049f8d 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -60,7 +60,8 @@ const messages = defineMessages({ manageModalNoRequests: 'No Requests', manageModalClearMedia: 'Clear All Media Data', manageModalClearMediaWarning: - 'This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.', + 'This will irreversibly remove all data for this movie, including any requests.\ + If this item exists in your Plex library, the media information will be recreated during the next scan.', approve: 'Approve', decline: 'Decline', studio: 'Studio', diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index 967bb7ee..7932447a 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -35,7 +35,7 @@ const messages = defineMessages({ apiKeyPlaceholder: 'Your Radarr API key', baseUrl: 'Base URL', baseUrlPlaceholder: 'Example: /radarr', - syncEnabled: 'Enable Sync', + syncEnabled: 'Enable Scan', externalUrl: 'External URL', externalUrlPlaceholder: 'External URL pointing to your Radarr server', qualityprofile: 'Quality Profile', diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 0e70b6b4..b327a187 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -19,7 +19,9 @@ import { formatBytes } from '../../../utils/numberHelpers'; const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ jobs: 'Jobs', jobsDescription: - 'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.', + 'Overseerr performs certain maintenance tasks as regularly-scheduled jobs,\ + but they can also be manually triggered below.\ + Manually running a job will not alter its schedule.', jobname: 'Job Name', jobtype: 'Type', nextexecution: 'Next Execution', @@ -41,10 +43,10 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ cachevsize: 'Value Size', flushcache: 'Flush Cache', unknownJob: 'Unknown Job', - 'plex-recently-added-sync': 'Plex Recently Added Sync', - 'plex-full-sync': 'Plex Full Library Sync', - 'radarr-sync': 'Radarr Sync', - 'sonarr-sync': 'Sonarr Sync', + 'plex-recently-added-scan': 'Plex Recently Added Scan', + 'plex-full-scan': 'Plex Full Library Scan', + 'radarr-scan': 'Radarr Scan', + 'sonarr-scan': 'Sonarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', }); diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 4fa58873..fb95aae8 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -49,8 +49,8 @@ const messages = defineMessages({ plexlibraries: 'Plex Libraries', plexlibrariesDescription: 'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.', - syncing: 'Syncing', - sync: 'Sync Plex Libraries', + scanning: 'Scanning…', + scan: 'Scan Plex Libraries', manualscan: 'Manual Library Scan', manualscanDescription: "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", @@ -563,8 +563,8 @@ const SettingsPlex: React.FC = ({ onComplete }) => { /> {isSyncing - ? intl.formatMessage(messages.syncing) - : intl.formatMessage(messages.sync)} + ? intl.formatMessage(messages.scanning) + : intl.formatMessage(messages.scan)}
    {data?.libraries.map((library) => ( diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 65d7a157..a41fead9 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -52,7 +52,7 @@ const messages = defineMessages({ testFirstRootFolders: 'Test connection to load root folders', loadinglanguageprofiles: 'Loading language profiles…', testFirstLanguageProfiles: 'Test connection to load language profiles', - syncEnabled: 'Enable Sync', + syncEnabled: 'Enable Scan', externalUrl: 'External URL', externalUrlPlaceholder: 'External URL pointing to your Sonarr server', preventSearch: 'Disable Auto-Search', diff --git a/src/components/Setup/index.tsx b/src/components/Setup/index.tsx index e0a21e5f..b174887f 100644 --- a/src/components/Setup/index.tsx +++ b/src/components/Setup/index.tsx @@ -22,8 +22,9 @@ const messages = defineMessages({ configureplex: 'Configure Plex', configureservices: 'Configure Services', tip: 'Tip', - syncingbackground: - 'Syncing will run in the background. You can continue the setup process in the meantime.', + scanbackground: + 'Scanning will run in the background.\ + You can continue the setup process in the meantime.', }); const Setup: React.FC = () => { @@ -104,7 +105,7 @@ const Setup: React.FC = () => { {intl.formatMessage(messages.tip)} - {intl.formatMessage(messages.syncingbackground)} + {intl.formatMessage(messages.scanbackground)}
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index fcc12bbf..e93fa94c 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -56,7 +56,8 @@ const messages = defineMessages({ manageModalNoRequests: 'No Requests', manageModalClearMedia: 'Clear All Media Data', manageModalClearMediaWarning: - 'This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.', + 'This will irreversibly remove all data for this TV series, including any requests.\ + If this item exists in your Plex library, the media information will be recreated during the next scan.', approve: 'Approve', decline: 'Decline', showtype: 'Show Type', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index eafcb692..16c261a6 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -63,7 +63,7 @@ "components.MovieDetails.decline": "Decline", "components.MovieDetails.downloadstatus": "Download Status", "components.MovieDetails.manageModalClearMedia": "Clear All Media Data", - "components.MovieDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", + "components.MovieDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", "components.MovieDetails.manageModalNoRequests": "No Requests", "components.MovieDetails.manageModalRequests": "Requests", "components.MovieDetails.manageModalTitle": "Manage Movie", @@ -366,7 +366,7 @@ "components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server", "components.Settings.RadarrModal.ssl": "SSL", - "components.Settings.RadarrModal.syncEnabled": "Enable Sync", + "components.Settings.RadarrModal.syncEnabled": "Enable Scan", "components.Settings.RadarrModal.test": "Test", "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", @@ -426,12 +426,12 @@ "components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.", "components.Settings.SettingsJobsCache.jobtype": "Type", "components.Settings.SettingsJobsCache.nextexecution": "Next Execution", - "components.Settings.SettingsJobsCache.plex-full-sync": "Plex Full Library Sync", - "components.Settings.SettingsJobsCache.plex-recently-added-sync": "Plex Recently Added Sync", + "components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan", + "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan", "components.Settings.SettingsJobsCache.process": "Process", - "components.Settings.SettingsJobsCache.radarr-sync": "Radarr Sync", + "components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan", "components.Settings.SettingsJobsCache.runnow": "Run Now", - "components.Settings.SettingsJobsCache.sonarr-sync": "Sonarr Sync", + "components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan", "components.Settings.SettingsJobsCache.unknownJob": "Unknown Job", "components.Settings.SonarrModal.add": "Add Server", "components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile", @@ -465,7 +465,7 @@ "components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server", "components.Settings.SonarrModal.ssl": "SSL", - "components.Settings.SonarrModal.syncEnabled": "Enable Sync", + "components.Settings.SonarrModal.syncEnabled": "Enable Scan", "components.Settings.SonarrModal.test": "Test", "components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles", "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", @@ -544,6 +544,8 @@ "components.Settings.regionTip": "Filter content by region (only applies to the \"Popular\" and \"Upcoming\" categories)", "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving…", + "components.Settings.scan": "Scan Plex Libraries", + "components.Settings.scanning": "Scanning…", "components.Settings.serverConnected": "connected", "components.Settings.serverLocal": "local", "components.Settings.serverRemote": "remote", @@ -562,8 +564,6 @@ "components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", - "components.Settings.sync": "Sync Plex Libraries", - "components.Settings.syncing": "Syncing…", "components.Settings.timeout": "Timeout", "components.Settings.toastApiKeyFailure": "Something went wrong while generating a new API key.", "components.Settings.toastApiKeySuccess": "New API key generated!", @@ -589,9 +589,9 @@ "components.Setup.finish": "Finish Setup", "components.Setup.finishing": "Finishing…", "components.Setup.loginwithplex": "Sign in with Plex", + "components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.", "components.Setup.setup": "Setup", "components.Setup.signinMessage": "Get started by signing in with your Plex account", - "components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.", "components.Setup.tip": "Tip", "components.Setup.welcome": "Welcome to Overseerr", "components.Slider.noresults": "No results.", @@ -614,7 +614,7 @@ "components.TvDetails.downloadstatus": "Download Status", "components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.manageModalClearMedia": "Clear All Media Data", - "components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", + "components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", "components.TvDetails.manageModalNoRequests": "No Requests", "components.TvDetails.manageModalRequests": "Requests", "components.TvDetails.manageModalTitle": "Manage Series", From 1f8b03ff6f67ce76051667de05166da54ed3dc89 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Fri, 5 Mar 2021 20:54:31 -0500 Subject: [PATCH 27/77] fix(ui): improve responsive design on new request list UI (#1105) --- src/components/RequestCard/index.tsx | 101 ++++---- .../RequestList/RequestItem/index.tsx | 241 ++++++++++-------- src/components/RequestList/index.tsx | 11 +- src/i18n/locale/en.json | 5 +- src/styles/globals.css | 16 +- 5 files changed, 213 insertions(+), 161 deletions(-) diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index b65df670..12692e74 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -5,7 +5,10 @@ import type { TvDetails } from '../../../server/models/Tv'; import type { MovieDetails } from '../../../server/models/Movie'; import useSWR from 'swr'; import { LanguageContext } from '../../context/LanguageContext'; -import { MediaRequestStatus } from '../../../server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../server/constants/media'; import Badge from '../Common/Badge'; import { useUser, Permission } from '../../hooks/useUser'; import axios from 'axios'; @@ -17,6 +20,7 @@ import globalMessages from '../../i18n/globalMessages'; import StatusBadge from '../StatusBadge'; const messages = defineMessages({ + status: 'Status', seasons: 'Seasons', all: 'All', }); @@ -100,39 +104,48 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { }} >
-

- + + {isMovie(title) ? title.title : title.name} - -

- - - - - {requestData.requestedBy.displayName} - - {requestData.media.status && ( -
+ +
+ + {intl.formatMessage(messages.status)} + + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN || + requestData.status === MediaRequestStatus.DECLINED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( = ({ request, onTitleData }) => { ] ?? [] ).length > 0 } + is4k={requestData.is4k} /> -
- )} + )} +
{request.seasons.length > 0 && ( -
- {intl.formatMessage(messages.seasons)} +
+ + {intl.formatMessage(messages.seasons)} + {!isMovie(title) && title.seasons.filter((season) => season.seasonNumber !== 0) .length === request.seasons.length ? ( @@ -215,15 +231,14 @@ const RequestCard: React.FC = ({ request, onTitleData }) => {
)}
- + +
); }; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 16a98dd3..190b8be2 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -23,12 +23,14 @@ import ConfirmButton from '../../Common/ConfirmButton'; const messages = defineMessages({ seasons: 'Seasons', + all: 'All', notavailable: 'N/A', failedretry: 'Something went wrong while retrying the request.', areyousure: 'Are you sure?', status: 'Status', requested: 'Requested', - modifiedby: 'Modified By', + modified: 'Modified', + modifieduserdate: '{date} by {user}', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -130,114 +132,122 @@ const RequestItem: React.FC = ({ setShowEditModal(false); }} /> -
+
-
- -
- +
+ - - {isMovie(title) ? title.title : title.name} - - - - - - - {requestData.requestedBy.displayName} - - - - {requestData.seasons.length > 0 && ( -
- - {intl.formatMessage(messages.seasons)} - - {requestData.seasons.map((season) => ( - - {season.seasonNumber} - - ))} + alt="" + className="h-full transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer w-14 lg:w-auto lg:h-full transform-gpu hover:scale-105 hover:shadow-md" + /> +
+ + + {isMovie(title) ? title.title : title.name} + + + - )} + {request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons)} + + {!isMovie(title) && + title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length ? ( + + {intl.formatMessage(messages.all)} + + ) : ( +
+ {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
+ )} +
+ )} +
-
-
-
- {intl.formatMessage(messages.status)} - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN || - requestData.status === MediaRequestStatus.DECLINED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - - ) : ( - 0 - } - is4k={requestData.is4k} - plexUrl={requestData.media.plexUrl} - plexUrl4k={requestData.media.plexUrl4k} - /> - )} -
-
- - {intl.formatMessage(messages.requested)} - - - {intl.formatDate(requestData.createdAt)} - -
-
- - {intl.formatMessage(messages.modifiedby)} - - - {requestData.modifiedBy ? ( - - - - - - {requestData.modifiedBy.displayName} ( +
+
+ + {intl.formatMessage(messages.status)} + + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN || + requestData.status === MediaRequestStatus.DECLINED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={requestData.media.plexUrl} + plexUrl4k={requestData.media.plexUrl4k} + /> + )} +
+
+ + {intl.formatMessage(messages.requested)} + + + {intl.formatDate(requestData.createdAt)} + +
+
diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 27db650c..126b0882 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -173,14 +173,13 @@ const RequestList: React.FC = () => { })} {data.results.length === 0 && ( -
- +
+ {intl.formatMessage(messages.noresults)} {currentFilter !== 'all' && (
)} -
+