diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 0b627859f..87ea79360 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -85,6 +85,7 @@ export interface MovieDetails { mediaUrl?: string; watchProviders?: WatchProviders[]; keywords: Keyword[]; + onUserWatchlist?: boolean; } export const mapProductionCompany = ( @@ -101,7 +102,8 @@ export const mapProductionCompany = ( export const mapMovieDetails = ( movie: TmdbMovieDetails, - media?: Media + media?: Media, + userWatchlist?: boolean ): MovieDetails => ({ id: movie.id, adult: movie.adult, @@ -148,4 +150,5 @@ export const mapMovieDetails = ( id: keyword.id, name: keyword.name, })), + onUserWatchlist: userWatchlist, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 24362b504..c79f93117 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -111,6 +111,7 @@ export interface TvDetails { keywords: Keyword[]; mediaInfo?: Media; watchProviders?: WatchProviders[]; + onUserWatchlist?: boolean; } const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ @@ -161,7 +162,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ export const mapTvDetails = ( show: TmdbTvDetails, - media?: Media + media?: Media, + userWatchlist?: boolean ): TvDetails => ({ createdBy: show.created_by, episodeRunTime: show.episode_run_time, @@ -223,4 +225,5 @@ export const mapTvDetails = ( })), mediaInfo: media, watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), + onUserWatchlist: userWatchlist, }); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index b48ae9ea8..833e92554 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -3,7 +3,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieResult } from '@server/models/Search'; @@ -22,7 +24,18 @@ movieRoutes.get('/:id', async (req, res, next) => { const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); - return res.status(200).json(mapMovieDetails(tmdbMovie, media)); + const onUserWatchlist = await getRepository(Watchlist).exist({ + where: { + tmdbId: Number(req.params.id), + requestedBy: { + id: req.user?.id, + }, + }, + }); + + return res + .status(200) + .json(mapMovieDetails(tmdbMovie, media, onUserWatchlist)); } catch (e) { logger.debug('Something went wrong retrieving movie', { label: 'API', diff --git a/server/routes/tv.ts b/server/routes/tv.ts index cd69c13a9..2f42c0dc6 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,7 +1,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -19,7 +21,16 @@ tvRoutes.get('/:id', async (req, res, next) => { const media = await Media.getMedia(tv.id, MediaType.TV); - return res.status(200).json(mapTvDetails(tv, media)); + const onUserWatchlist = await getRepository(Watchlist).exist({ + where: { + tmdbId: Number(req.params.id), + requestedBy: { + id: req.user?.id, + }, + }, + }); + + return res.status(200).json(mapTvDetails(tv, media, onUserWatchlist)); } catch (e) { logger.debug('Something went wrong retrieving series', { label: 'API', diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 1c53ac0b4..a60a745a1 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; +import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -41,11 +42,14 @@ import { import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, + MinusCircleIcon, + StarIcon, } from '@heroicons/react/24/solid'; import { type RatingResponse } from '@server/api/ratings'; import { IssueStatus } from '@server/constants/issue'; -import { MediaStatus } from '@server/constants/media'; +import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; +import type { Watchlist } from '@server/entity/Watchlist'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; @@ -56,6 +60,8 @@ import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; const messages = defineMessages('components.MovieDetails', { originaltitle: 'Original Title', @@ -94,6 +100,13 @@ const messages = defineMessages('components.MovieDetails', { rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', imdbuserscore: 'IMDB User Score', + watchlistSuccess: + '{title} added to watchlist successfully!', + watchlistDeleted: + '{title} Removed from watchlist successfully!', + watchlistError: 'Something went wrong try again.', + removefromwatchlist: 'Remove From Watchlist', + addtowatchlist: 'Add To Watchlist', }); interface MovieDetailsProps { @@ -112,7 +125,12 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const minStudios = 3; const [showMoreStudios, setShowMoreStudios] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [toggleWatchlist, setToggleWatchlist] = useState( + !movie?.onUserWatchlist + ); const { publicRuntimeConfig } = getConfig(); + const { addToast } = useToasts(); const { data, @@ -202,8 +220,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const region = user?.settings?.region ? user.settings.region : settings.currentSettings.region - ? settings.currentSettings.region - : 'US'; + ? settings.currentSettings.region + : 'US'; const releases = data.releases.results.find( (r) => r.iso_3166_1 === region @@ -287,6 +305,65 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); } + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.post('/api/v1/watchlist', { + tmdbId: movie?.id, + mediaType: MediaType.MOVIE, + title: movie?.title, + }); + if (response.data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + + const onClickDeleteWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.delete( + '/api/v1/watchlist/' + movie?.id + ); + + if (response.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + return (
{
+ <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + { (ratingData?.rt?.audienceRating && !!ratingData?.rt?.audienceScore) || ratingData?.imdb?.criticsScore) && ( -
- {ratingData?.rt?.criticsRating && - !!ratingData?.rt?.criticsScore && ( - +
+ {ratingData?.rt?.criticsRating && + !!ratingData?.rt?.criticsScore && ( + + + {ratingData.rt.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + {ratingData.rt.criticsScore}% + + + )} + {ratingData?.rt?.audienceRating && + !!ratingData?.rt?.audienceScore && ( + + + {ratingData.rt.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + {ratingData.rt.audienceScore}% + + + )} + {ratingData?.imdb?.criticsScore && ( + - {ratingData.rt.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - {ratingData.rt.criticsScore}% + + {ratingData.imdb.criticsScore} )} - {ratingData?.rt?.audienceRating && - !!ratingData?.rt?.audienceScore && ( - + {!!data.voteCount && ( + - {ratingData.rt.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - {ratingData.rt.audienceScore}% + + {Math.round(data.voteAverage * 10)}% )} - {ratingData?.imdb?.criticsScore && ( - - - - {ratingData.imdb.criticsScore} - - - )} - {!!data.voteCount && ( - - - - {Math.round(data.voteAverage * 10)}% - - - )} -
- )} +
+ )} {data.originalTitle && data.originalLanguage !== locale.slice(0, 2) && (
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 1b21a4a3f..c90e16a25 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; +import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; @@ -40,12 +41,21 @@ import { FilmIcon, PlayIcon, } from '@heroicons/react/24/outline'; -import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { + ChevronDownIcon, + MinusCircleIcon, + StarIcon, +} from '@heroicons/react/24/solid'; import type { RTRating } from '@server/api/rating/rottentomatoes'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; -import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; +import type { Watchlist } from '@server/entity/Watchlist'; import type { Crew } from '@server/models/common'; import type { TvDetails as TvDetailsType } from '@server/models/Tv'; import { countries } from 'country-flag-icons'; @@ -56,6 +66,8 @@ import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; const messages = defineMessages('components.TvDetails', { firstAirDate: 'First Air Date', @@ -89,6 +101,13 @@ const messages = defineMessages('components.TvDetails', { rtcriticsscore: 'Rotten Tomatoes Tomatometer', rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', + watchlistSuccess: + '{title} added to watchlist successfully!', + watchlistDeleted: + '{title} Removed from watchlist successfully!', + watchlistError: 'Something went wrong try again.', + removefromwatchlist: 'Remove From Watchlist', + addtowatchlist: 'Add To Watchlist', }); interface TvDetailsProps { @@ -106,7 +125,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => { router.query.manage == '1' ? true : false ); const [showIssueModal, setShowIssueModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [toggleWatchlist, setToggleWatchlist] = useState( + !tv?.onUserWatchlist + ); const { publicRuntimeConfig } = getConfig(); + const { addToast } = useToasts(); const { data, @@ -196,8 +220,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => { const region = user?.settings?.region ? user.settings.region : settings.currentSettings.region - ? settings.currentSettings.region - : 'US'; + ? settings.currentSettings.region + : 'US'; const seriesAttributes: React.ReactNode[] = []; const contentRating = data.contentRatings.results.find( @@ -261,7 +285,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { (season) => (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE || + MediaStatus.PARTIALLY_AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) && !requestedSeasons.includes(season.seasonNumber) ) @@ -302,6 +326,65 @@ const TvDetails = ({ tv }: TvDetailsProps) => { return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); } + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.post('/api/v1/watchlist', { + tmdbId: tv?.id, + mediaType: MediaType.TV, + title: tv?.name, + }); + if (response.data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + + const onClickDeleteWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.delete( + '/api/v1/watchlist/' + tv?.id + ); + + if (response.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + return (
{
+ <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + { }) && (data.mediaInfo?.status4k === MediaStatus.AVAILABLE || data?.mediaInfo?.status4k === - MediaStatus.PARTIALLY_AVAILABLE))) && + MediaStatus.PARTIALLY_AVAILABLE))) && hasPermission( [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], { @@ -510,15 +627,15 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
    {(data.createdBy.length > 0 ? [ - ...data.createdBy.map( - (person): Partial => ({ - id: person.id, - job: 'Creator', - name: person.name, - }) - ), - ...sortedCrew, - ] + ...data.createdBy.map( + (person): Partial => ({ + id: person.id, + job: 'Creator', + name: person.name, + }) + ), + ...sortedCrew, + ] : sortedCrew ) .slice(0, 6) @@ -606,11 +723,10 @@ const TvDetails = ({ tv }: TvDetailsProps) => { {({ open }) => ( <>
    @@ -627,50 +743,50 @@ const TvDetails = ({ tv }: TvDetailsProps) => { {((!mSeason && request?.status === MediaRequestStatus.APPROVED) || mSeason?.status === MediaStatus.PROCESSING) && ( - <> -
    - - {intl.formatMessage(globalMessages.requested)} - -
    -
    - -
    - - )} + <> +
    + + {intl.formatMessage(globalMessages.requested)} + +
    +
    + +
    + + )} {((!mSeason && request?.status === MediaRequestStatus.PENDING) || mSeason?.status === MediaStatus.PENDING) && ( - <> -
    - - {intl.formatMessage(globalMessages.pending)} - -
    -
    - -
    - - )} + <> +
    + + {intl.formatMessage(globalMessages.pending)} + +
    +
    + +
    + + )} {mSeason?.status === MediaStatus.PARTIALLY_AVAILABLE && ( - <> -
    - - {intl.formatMessage( - globalMessages.partiallyavailable - )} - -
    -
    - -
    - - )} + <> +
    + + {intl.formatMessage( + globalMessages.partiallyavailable + )} + +
    +
    + +
    + + )} {mSeason?.status === MediaStatus.AVAILABLE && ( <>
    @@ -687,7 +803,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { )} {((!mSeason4k && request4k?.status === - MediaRequestStatus.APPROVED) || + MediaRequestStatus.APPROVED) || mSeason4k?.status4k === MediaStatus.PROCESSING) && show4k && ( <> @@ -772,9 +888,8 @@ const TvDetails = ({ tv }: TvDetailsProps) => { )} { {(!!data.voteCount || (ratingData?.criticsRating && !!ratingData?.criticsScore) || (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( -
    - {ratingData?.criticsRating && !!ratingData?.criticsScore && ( - - + {ratingData?.criticsRating && !!ratingData?.criticsScore && ( + - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && !!ratingData?.audienceScore && ( - - - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - {ratingData.audienceScore}% - - - )} - {!!data.voteCount && ( - - + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && !!ratingData?.audienceScore && ( + - - {Math.round(data.voteAverage * 10)}% - - - )} -
    - )} + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + {ratingData.audienceScore}% + + + )} + {!!data.voteCount && ( + + + + {Math.round(data.voteAverage * 10)}% + + + )} +
    + )} {data.originalName && data.originalLanguage !== locale.slice(0, 2) && (
    @@ -871,13 +986,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => { {data.keywords.some( (keyword) => keyword.id === ANIME_KEYWORD_ID ) && ( -
    - {intl.formatMessage(messages.showtype)} - - {intl.formatMessage(messages.anime)} - -
    - )} +
    + {intl.formatMessage(messages.showtype)} + + {intl.formatMessage(messages.anime)} + +
    + )}
    {intl.formatMessage(globalMessages.status)} {data.status} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 390a7e1bb..208f1a842 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -286,6 +286,7 @@ "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", + "components.MovieDetails.addtowatchlist": "Add To Watchlist", "components.MovieDetails.budget": "Budget", "components.MovieDetails.cast": "Cast", "components.MovieDetails.digitalrelease": "Digital Release", @@ -306,6 +307,7 @@ "components.MovieDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}", "components.MovieDetails.recommendations": "Recommendations", "components.MovieDetails.releasedate": "{releaseCount, plural, one {Release Date} other {Release Dates}}", + "components.MovieDetails.removefromwatchlist": "Remove From Watchlist", "components.MovieDetails.reportissue": "Report an Issue", "components.MovieDetails.revenue": "Revenue", "components.MovieDetails.rtaudiencescore": "Rotten Tomatoes Audience Score", @@ -319,6 +321,9 @@ "components.MovieDetails.theatricalrelease": "Theatrical Release", "components.MovieDetails.tmdbuserscore": "TMDB User Score", "components.MovieDetails.viewfullcrew": "View Full Crew", + "components.MovieDetails.watchlistDeleted": "{title} Removed from watchlist successfully!", + "components.MovieDetails.watchlistError": "Something went wrong try again.", + "components.MovieDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.MovieDetails.watchtrailer": "Watch Trailer", "components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.", "components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.", @@ -986,7 +991,7 @@ "components.Settings.plexlibraries": "Plex Libraries", "components.Settings.plexlibrariesDescription": "The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.", "components.Settings.plexsettings": "Plex Settings", - "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Jellyseerr scans your Plex libraries to determine content availability.", + "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.", "components.Settings.port": "Port", "components.Settings.radarrsettings": "Radarr Settings", "components.Settings.restartrequiredTooltip": "Jellyseerr must be restarted for changes to this setting to take effect", @@ -1070,6 +1075,7 @@ "components.TvDetails.Season.somethingwentwrong": "Something went wrong while retrieving season data.", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", + "components.TvDetails.addtowatchlist": "Add To Watchlist", "components.TvDetails.anime": "Anime", "components.TvDetails.cast": "Cast", "components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}", @@ -1087,6 +1093,7 @@ "components.TvDetails.play4k": "Play 4K on {mediaServerName}", "components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}", "components.TvDetails.recommendations": "Recommendations", + "components.TvDetails.removefromwatchlist": "Remove From Watchlist", "components.TvDetails.reportissue": "Report an Issue", "components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Score", "components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer", @@ -1099,6 +1106,9 @@ "components.TvDetails.streamingproviders": "Currently Streaming On", "components.TvDetails.tmdbuserscore": "TMDB User Score", "components.TvDetails.viewfullcrew": "View Full Crew", + "components.TvDetails.watchlistDeleted": "{title} Removed from watchlist successfully!", + "components.TvDetails.watchlistError": "Something went wrong try again.", + "components.TvDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.TvDetails.watchtrailer": "Watch Trailer", "components.UserList.accounttype": "Type", "components.UserList.admin": "Admin",