diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index c08615f5..b2dea0c6 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -19,26 +19,156 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => { describe('Discover', () => { beforeEach(() => { cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); - cy.visit('/'); }); it('loads a trending item', () => { + cy.intercept('/api/v1/discover/trending*').as('getTrending'); + cy.visit('/'); + cy.wait('@getTrending'); clickFirstTitleCardInSlider('Trending'); }); it('loads popular movies', () => { + cy.intercept('/api/v1/discover/movies*').as('getPopularMovies'); + cy.visit('/'); + cy.wait('@getPopularMovies'); clickFirstTitleCardInSlider('Popular Movies'); }); it('loads upcoming movies', () => { + cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies'); + cy.visit('/'); + cy.wait('@getUpcomingMovies'); clickFirstTitleCardInSlider('Upcoming Movies'); }); it('loads popular series', () => { + cy.intercept('/api/v1/discover/tv*').as('getPopularTv'); + cy.visit('/'); + cy.wait('@getPopularTv'); clickFirstTitleCardInSlider('Popular Series'); }); it('loads upcoming series', () => { + cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries'); + cy.visit('/'); + cy.wait('@getUpcomingSeries'); clickFirstTitleCardInSlider('Upcoming Series'); }); + + it('displays error for media with invalid TMDb ID', () => { + cy.intercept('GET', '/api/v1/media?*', { + pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 }, + results: [ + { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 5, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T19:56:41.000Z', + lastSeasonChange: '2022-08-18T19:56:41.000Z', + mediaAddedAt: '2022-08-18T19:56:41.000Z', + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + seasons: [], + }, + ], + }).as('getMedia'); + + cy.visit('/'); + cy.wait('@getMedia'); + cy.contains('.slider-header', 'Recently Added') + .next('[data-testid=media-slider]') + .find('[data-testid=title-card]') + .first() + .find('[data-testid=title-card-title]') + .contains('Movie Not Found'); + }); + + it('displays error for request with invalid TMDb ID', () => { + cy.intercept('GET', '/api/v1/request?*', { + pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 }, + results: [ + { + id: 582, + status: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + type: 'movie', + is4k: false, + serverId: null, + profileId: null, + rootFolder: null, + languageProfileId: null, + tags: null, + media: { + downloadStatus: [], + downloadStatus4k: [], + id: 1922, + mediaType: 'movie', + tmdbId: 998814, + tvdbId: null, + imdbId: null, + status: 2, + status4k: 1, + createdAt: '2022-08-18T18:11:13.000Z', + updatedAt: '2022-08-18T18:11:13.000Z', + lastSeasonChange: '2022-08-18T18:11:13.000Z', + mediaAddedAt: null, + serviceId: null, + serviceId4k: null, + externalServiceId: null, + externalServiceId4k: null, + externalServiceSlug: null, + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + }, + seasons: [], + modifiedBy: null, + requestedBy: { + permissions: 4194336, + id: 18, + email: 'friend@seerr.dev', + plexUsername: null, + username: '', + recoveryLinkExpirationDate: null, + userType: 2, + avatar: + 'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200', + movieQuotaLimit: null, + movieQuotaDays: null, + tvQuotaLimit: null, + tvQuotaDays: null, + createdAt: '2022-08-17T04:55:28.000Z', + updatedAt: '2022-08-17T04:55:28.000Z', + requestCount: 1, + displayName: 'friend@seerr.dev', + }, + seasonCount: 0, + }, + ], + }).as('getRequests'); + + cy.visit('/'); + cy.wait('@getRequests'); + cy.contains('.slider-header', 'Recent Requests') + .next('[data-testid=media-slider]') + .find('[data-testid=request-card]') + .first() + .find('[data-testid=request-card-title]') + .contains('Movie Not Found'); + }); }); diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 7773b75e..8e19bd0b 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -39,7 +39,9 @@ const Discover = () => { const { data: requests, error: requestError } = useSWR( '/api/v1/request?filter=all&take=10&sort=modified&skip=0', - { revalidateOnMount: true } + { + revalidateOnMount: true, + } ); return ( @@ -61,7 +63,9 @@ const Discover = () => { items={media?.results?.map((item) => ( ))} diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index d9f1dbaf..4bbdb108 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -28,7 +28,9 @@ import StatusBadge from '../StatusBadge'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', failedretry: 'Something went wrong while retrying the request.', - mediaerror: 'The associated title for this request is no longer available.', + mediaerror: '{mediaType} Not Found', + tmdbid: 'TMDb ID', + tvdbid: 'TVDB ID', deleterequest: 'Delete Request', }); @@ -47,37 +49,123 @@ const RequestCardPlaceholder = () => { }; interface RequestCardErrorProps { - mediaId?: number; + requestData?: MediaRequest; } -const RequestCardError = ({ mediaId }: RequestCardErrorProps) => { +const RequestCardError = ({ requestData }: RequestCardErrorProps) => { const { hasPermission } = useUser(); const intl = useIntl(); const deleteRequest = async () => { - await axios.delete(`/api/v1/media/${mediaId}`); + await axios.delete(`/api/v1/media/${requestData?.media.id}`); + mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); }; return ( -
+
-
-
- {intl.formatMessage(messages.mediaerror)} +
+
+ {intl.formatMessage(messages.mediaerror, { + mediaType: intl.formatMessage( + requestData?.type + ? requestData?.type === 'movie' + ? globalMessages.movie + : globalMessages.tvshow + : globalMessages.request + ), + })}
- {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( - + {requestData && ( + <> + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) && ( + + )} +
+ + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED || + requestData.status === MediaRequestStatus.FAILED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={ + requestData.is4k + ? requestData.media.plexUrl4k + : requestData.media.plexUrl + } + serviceUrl={ + hasPermission(Permission.ADMIN) + ? requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl + : undefined + } + /> + )} +
+ )} +
+ {hasPermission(Permission.MANAGE_REQUESTS) && + requestData?.media.id && ( + + )} +
@@ -165,7 +253,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { } if (!title || !requestData) { - return ; + return ; } return ( @@ -182,7 +270,10 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { setShowEditModal(false); }} /> -
+
{title.backdropPath && (
{ />
)} -
+
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( 0, diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 4a5e396a..aef96529 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -32,50 +32,229 @@ const messages = defineMessages({ requesteddate: 'Requested', modified: 'Modified', modifieduserdate: '{date} by {user}', - mediaerror: 'The associated title for this request is no longer available.', + mediaerror: '{mediaType} Not Found', editrequest: 'Edit Request', deleterequest: 'Delete Request', cancelRequest: 'Cancel Request', + tmdbid: 'TMDb ID', + tvdbid: 'TVDB ID', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; -interface RequestItemErroProps { - mediaId?: number; +interface RequestItemErrorProps { + requestData?: MediaRequest; revalidateList: () => void; } const RequestItemError = ({ - mediaId, + requestData, revalidateList, -}: RequestItemErroProps) => { +}: RequestItemErrorProps) => { const intl = useIntl(); const { hasPermission } = useUser(); const deleteRequest = async () => { - await axios.delete(`/api/v1/media/${mediaId}`); + await axios.delete(`/api/v1/media/${requestData?.media.id}`); revalidateList(); }; return ( -
- - {intl.formatMessage(messages.mediaerror)} - - {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( -
+
+
+
+
+ {intl.formatMessage(messages.mediaerror, { + mediaType: intl.formatMessage( + requestData?.type + ? requestData?.type === 'movie' + ? globalMessages.movie + : globalMessages.tvshow + : globalMessages.request + ), + })} +
+ {requestData && hasPermission(Permission.MANAGE_REQUESTS) && ( + <> +
+ + {intl.formatMessage(messages.tmdbid)} + + + {requestData.media.tmdbId} + +
+ {requestData.media.tvdbId && ( +
+ + {intl.formatMessage(messages.tvdbid)} + + + {requestData?.media.tvdbId} + +
+ )} + + )} +
+
+ {requestData && ( + <> +
+ + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED || + requestData.status === MediaRequestStatus.FAILED ? ( + + {requestData.status === MediaRequestStatus.DECLINED + ? intl.formatMessage(globalMessages.declined) + : intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + plexUrl={ + requestData.is4k + ? requestData.media.plexUrl4k + : requestData.media.plexUrl + } + serviceUrl={ + hasPermission(Permission.ADMIN) + ? requestData.is4k + ? requestData.media.serviceUrl4k + : requestData.media.serviceUrl + : undefined + } + /> + )} +
+
+ {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) ? ( + <> + + {intl.formatMessage(messages.requested)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.requestedBy.displayName} + + + + ), + })} + + + ) : ( + <> + + {intl.formatMessage(messages.requesteddate)} + + + + + + )} +
+ {requestData.modifiedBy && ( +
+ + {intl.formatMessage(messages.modified)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.modifiedBy.displayName} + + + + ), + })} + +
+ )} + + )} +
+
+
+ {hasPermission(Permission.MANAGE_REQUESTS) && requestData?.media.id && ( -
- )} + )} +
); }; @@ -151,7 +330,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { if (!title || !requestData) { return ( ); diff --git a/src/components/TitleCard/ErrorCard.tsx b/src/components/TitleCard/ErrorCard.tsx new file mode 100644 index 00000000..df619289 --- /dev/null +++ b/src/components/TitleCard/ErrorCard.tsx @@ -0,0 +1,131 @@ +import { defineMessages, useIntl } from 'react-intl'; +import globalMessages from '../../i18n/globalMessages'; +import Button from '../Common/Button'; +import { CheckIcon, TrashIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { mutate } from 'swr'; + +interface ErrorCardProps { + id: number; + tmdbId: number; + tvdbId?: number; + type: 'movie' | 'tv'; + canExpand?: boolean; +} + +const messages = defineMessages({ + mediaerror: '{mediaType} Not Found', + tmdbid: 'TMDb ID', + tvdbid: 'TVDB ID', + cleardata: 'Clear Data', +}); + +const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => { + const intl = useIntl(); + + const deleteMedia = async () => { + await axios.delete(`/api/v1/media/${id}`); + mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + }; + + return ( +
+
+
+
+
+
+ {type === 'movie' + ? intl.formatMessage(globalMessages.movie) + : intl.formatMessage(globalMessages.tvshow)} +
+
+
+
+ +
+
+
+ +
+
+

+ {intl.formatMessage(messages.mediaerror, { + mediaType: intl.formatMessage( + type === 'movie' + ? globalMessages.movie + : globalMessages.tvshow + ), + })} +

+
+
+ + {intl.formatMessage(messages.tmdbid)} + + {tmdbId} +
+ {!!tvdbId && ( +
+ + {intl.formatMessage(messages.tvdbid)} + + {tvdbId} +
+ )} +
+
+
+ +
+ +
+
+
+
+ ); +}; +export default Error; diff --git a/src/components/TitleCard/TmdbTitleCard.tsx b/src/components/TitleCard/TmdbTitleCard.tsx index 827db1bf..16cb699f 100644 --- a/src/components/TitleCard/TmdbTitleCard.tsx +++ b/src/components/TitleCard/TmdbTitleCard.tsx @@ -3,9 +3,12 @@ import useSWR from 'swr'; import TitleCard from '.'; import type { MovieDetails } from '../../../server/models/Movie'; import type { TvDetails } from '../../../server/models/Tv'; +import { Permission, useUser } from '../../hooks/useUser'; -interface TmdbTitleCardProps { +export interface TmdbTitleCardProps { + id: number; tmdbId: number; + tvdbId?: number; type: 'movie' | 'tv'; } @@ -13,7 +16,9 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; -const TmdbTitleCard = ({ tmdbId, type }: TmdbTitleCardProps) => { +const TmdbTitleCard = ({ id, tmdbId, tvdbId, type }: TmdbTitleCardProps) => { + const { hasPermission } = useUser(); + const { ref, inView } = useInView({ triggerOnce: true, }); @@ -32,7 +37,14 @@ const TmdbTitleCard = ({ tmdbId, type }: TmdbTitleCardProps) => { } if (!title) { - return ; + return hasPermission(Permission.ADMIN) ? ( + + ) : null; } return isMovie(title) ? ( diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 455c75c4..8437707a 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -15,6 +15,7 @@ import CachedImage from '../Common/CachedImage'; import RequestModal from '../RequestModal'; import Transition from '../Transition'; import Placeholder from './Placeholder'; +import ErrorCard from './ErrorCard'; interface TitleCardProps { id: number; @@ -266,4 +267,4 @@ const TitleCard = ({ ); }; -export default withProperties(TitleCard, { Placeholder }); +export default withProperties(TitleCard, { Placeholder, ErrorCard }); diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index a6b4e1e9..3fef28fc 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -293,7 +293,9 @@ const UserProfile = () => { items={watchData.recentlyWatched.map((item) => ( ))} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 54043de4..ca405970 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -292,18 +292,22 @@ "components.RequestButton.viewrequest4k": "View 4K Request", "components.RequestCard.deleterequest": "Delete Request", "components.RequestCard.failedretry": "Something went wrong while retrying the request.", - "components.RequestCard.mediaerror": "The associated title for this request is no longer available.", + "components.RequestCard.mediaerror": "{mediaType} Not Found", "components.RequestCard.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", + "components.RequestCard.tmdbid": "TMDb ID", + "components.RequestCard.tvdbid": "TVDB ID", "components.RequestList.RequestItem.cancelRequest": "Cancel Request", "components.RequestList.RequestItem.deleterequest": "Delete Request", "components.RequestList.RequestItem.editrequest": "Edit Request", "components.RequestList.RequestItem.failedretry": "Something went wrong while retrying the request.", - "components.RequestList.RequestItem.mediaerror": "The associated title for this request is no longer available.", + "components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found", "components.RequestList.RequestItem.modified": "Modified", "components.RequestList.RequestItem.modifieduserdate": "{date} by {user}", "components.RequestList.RequestItem.requested": "Requested", "components.RequestList.RequestItem.requesteddate": "Requested", "components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}", + "components.RequestList.RequestItem.tmdbid": "TMDb ID", + "components.RequestList.RequestItem.tvdbid": "TVDB ID", "components.RequestList.requests": "Requests", "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.sortAdded": "Most Recent", @@ -827,6 +831,10 @@ "components.StatusChecker.reloadApp": "Reload {applicationTitle}", "components.StatusChecker.restartRequired": "Server Restart Required", "components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.", + "components.TitleCard.cleardata": "Clear Data", + "components.TitleCard.mediaerror": "{mediaType} Not Found", + "components.TitleCard.tmdbid": "TMDb ID", + "components.TitleCard.tvdbid": "TVDB ID", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", "components.TvDetails.anime": "Anime",