diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index 545f25658..50dacde1a 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -187,7 +187,7 @@ describe('Discover', () => { cy.wait('@getWatchlist'); - const sliderHeader = cy.contains('.slider-header', 'Your Plex Watchlist'); + const sliderHeader = cy.contains('.slider-header', 'Your Watchlist'); sliderHeader.scrollIntoView(); @@ -203,7 +203,7 @@ describe('Discover', () => { .find('[data-testid=title-card-title]') .invoke('text') .then((text) => { - cy.contains('.slider-header', 'Plex Watchlist') + cy.contains('.slider-header', 'Watchlist') .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() diff --git a/overseerr-api.yml b/overseerr-api.yml index 20bc21849..013344eb6 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -36,6 +36,8 @@ tags: description: Endpoints related to retrieving collection details. - name: service description: Endpoints related to getting service (Radarr/Sonarr) details. + - name: watchlist + description: Collection of media to watch later servers: - url: '{server}/api/v1' variables: @@ -44,6 +46,34 @@ servers: components: schemas: + Watchlist: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + tmdbId: + type: number + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' User: type: object properties: @@ -3962,6 +3992,41 @@ paths: restricted: type: boolean example: false + /watchlist: + post: + summary: Add media to watchlist + tags: + - watchlist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + /watchlist/{tmdbId}: + delete: + summary: Delete watchlist item + description: Removes a watchlist item. + tags: + - watchlist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed watchlist item /user/{userId}/watchlist: get: summary: Get the Plex watchlist for a specific user @@ -3969,6 +4034,7 @@ paths: Retrieves a user's Plex Watchlist in a JSON object. tags: - users + - watchlist parameters: - in: path name: userId diff --git a/package.json b/package.json index f1f2de39e..280d6b690 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:next": "next build", "build": "yarn build:next && yarn build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", + "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 47217aa09..68a5622c0 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -3,6 +3,8 @@ import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import type { User } from '@server/entity/User'; +import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; @@ -12,7 +14,6 @@ import { Column, CreateDateColumn, Entity, - In, Index, OneToMany, PrimaryGeneratedColumn, @@ -25,6 +26,7 @@ import Season from './Season'; @Entity() class Media { public static async getRelatedMedia( + user: User | undefined, tmdbIds: number | number[] ): Promise { const mediaRepository = getRepository(Media); @@ -37,9 +39,16 @@ class Media { finalIds = tmdbIds; } - const media = await mediaRepository.find({ - where: { tmdbId: In(finalIds) }, - }); + const media = await mediaRepository + .createQueryBuilder('media') + .leftJoinAndSelect( + 'media.watchlists', + 'watchlist', + 'media.id= watchlist.media and watchlist.requestedBy = :userId', + { userId: user?.id } + ) //, + .where(' media.tmdbId in (:...finalIds)', { finalIds }) + .getMany(); return media; } catch (e) { @@ -94,6 +103,9 @@ class Media { @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) public requests: MediaRequest[]; + @OneToMany(() => Watchlist, (watchlist) => watchlist.media) + public watchlists: null | Watchlist[]; + @OneToMany(() => Season, (season) => season.media, { cascade: true, eager: true, diff --git a/server/entity/User.ts b/server/entity/User.ts index 8780e2d8a..e4c8314c3 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,6 +1,7 @@ import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; +import { Watchlist } from '@server/entity/Watchlist'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import PreparedEmail from '@server/lib/email'; import type { PermissionCheckOptions } from '@server/lib/permissions'; @@ -103,6 +104,9 @@ export class User { @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; + @OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy) + public watchlists: Watchlist[]; + @Column({ nullable: true }) public movieQuotaLimit?: number; diff --git a/server/entity/Watchlist.ts b/server/entity/Watchlist.ts new file mode 100644 index 000000000..df820120e --- /dev/null +++ b/server/entity/Watchlist.ts @@ -0,0 +1,157 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { User } from '@server/entity/User'; +import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; +import logger from '@server/logger'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import type { ZodNumber, ZodOptional, ZodString } from 'zod'; + +export class DuplicateWatchlistRequestError extends Error {} +export class NotFoundError extends Error { + constructor(message = 'Not found') { + super(message); + this.name = 'NotFoundError'; + } +} + +@Entity() +@Unique('UNIQUE_USER_DB', ['tmdbId', 'requestedBy']) +export class Watchlist implements WatchlistItem { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar' }) + public ratingKey = ''; + + @Column({ type: 'varchar' }) + public mediaType: MediaType; + + @Column({ type: 'varchar' }) + title = ''; + + @Column() + @Index() + public tmdbId: number; + + @ManyToOne(() => User, (user) => user.watchlists, { + eager: true, + onDelete: 'CASCADE', + }) + public requestedBy: User; + + @ManyToOne(() => Media, (media) => media.watchlists, { + eager: true, + onDelete: 'CASCADE', + }) + public media: Media; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + public static async createWatchlist({ + watchlistRequest, + user, + }: { + watchlistRequest: { + mediaType: MediaType; + ratingKey?: ZodOptional['_output']; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + }; + user: User; + }): Promise { + const watchlistRepository = getRepository(this); + const mediaRepository = getRepository(Media); + const tmdb = new TheMovieDb(); + + const tmdbMedia = + watchlistRequest.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId }) + : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId }); + + const existing = await watchlistRepository + .createQueryBuilder('watchlist') + .leftJoinAndSelect('watchlist.requestedBy', 'user') + .where('user.id = :userId', { userId: user.id }) + .andWhere('watchlist.tmdbId = :tmdbId', { + tmdbId: watchlistRequest.tmdbId, + }) + .andWhere('watchlist.mediaType = :mediaType', { + mediaType: watchlistRequest.mediaType, + }) + .getMany(); + + if (existing && existing.length > 0) { + logger.warn('Duplicate request for watchlist blocked', { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + label: 'Watchlist', + }); + + throw new DuplicateWatchlistRequestError(); + } + + let media = await mediaRepository.findOne({ + where: { + tmdbId: watchlistRequest.tmdbId, + mediaType: watchlistRequest.mediaType, + }, + }); + + if (!media) { + media = new Media({ + tmdbId: tmdbMedia.id, + tvdbId: tmdbMedia.external_ids.tvdb_id, + mediaType: watchlistRequest.mediaType, + }); + } + + const watchlist = new this({ + ...watchlistRequest, + requestedBy: user, + media, + }); + + await mediaRepository.save(media); + await watchlistRepository.save(watchlist); + return watchlist; + } + + public static async deleteWatchlist( + tmdbId: Watchlist['tmdbId'], + user: User + ): Promise { + const watchlistRepository = getRepository(this); + const watchlist = await watchlistRepository.findOneBy({ + tmdbId, + requestedBy: { id: user.id }, + }); + if (!watchlist) { + throw new NotFoundError('not Found'); + } + + if (watchlist) { + await watchlistRepository.delete(watchlist.id); + } + + return watchlist; + } +} diff --git a/server/interfaces/api/watchlistCreate.ts b/server/interfaces/api/watchlistCreate.ts new file mode 100644 index 000000000..6cc6af3bb --- /dev/null +++ b/server/interfaces/api/watchlistCreate.ts @@ -0,0 +1,9 @@ +import { MediaType } from '@server/constants/media'; +import { z } from 'zod'; + +export const watchlistCreate = z.object({ + ratingKey: z.coerce.string().optional(), + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), + title: z.coerce.string().optional(), +}); diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 46147f3fc..b4a072970 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -65,6 +65,7 @@ class WatchlistSync { const response = await plexTvApi.getWatchlist({ size: 200 }); const mediaItems = await Media.getRelatedMedia( + user, response.items.map((i) => i.tmdbId) ); diff --git a/server/migration/1682608634546-AddWatchlists.ts b/server/migration/1682608634546-AddWatchlists.ts new file mode 100644 index 000000000..492fb183e --- /dev/null +++ b/server/migration/1682608634546-AddWatchlists.ts @@ -0,0 +1,19 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWatchlists1682608634546 implements MigrationInterface { + name = 'AddWatchlists1682608634546'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); + await queryRunner.query(`DROP TABLE "watchlist"`); + } +} diff --git a/server/repositories/watchlist.repository.ts b/server/repositories/watchlist.repository.ts new file mode 100644 index 000000000..128e64f5a --- /dev/null +++ b/server/repositories/watchlist.repository.ts @@ -0,0 +1,11 @@ +import { getRepository } from '@server/datasource'; +import { Watchlist } from '@server/entity/Watchlist'; + +export const UserRepository = getRepository(Watchlist).extend({ + // findByName(firstName: string, lastName: string) { + // return this.createQueryBuilder("user") + // .where("user.firstName = :firstName", { firstName }) + // .andWhere("user.lastName = :lastName", { lastName }) + // .getMany() + // }, +}); diff --git a/server/routes/collection.ts b/server/routes/collection.ts index d58b0357d..cc2a36e76 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -16,6 +16,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, collection.parts.map((part) => part.id) ); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index f032fa66b..640572f0c 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -6,6 +6,7 @@ import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; +import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, WatchlistResponse, @@ -100,6 +101,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -164,6 +166,7 @@ discoverRoutes.get<{ language: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -221,6 +224,7 @@ discoverRoutes.get<{ genreId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -268,6 +272,7 @@ discoverRoutes.get<{ studioId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -317,6 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -375,6 +381,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -438,6 +445,7 @@ discoverRoutes.get<{ language: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -495,6 +503,7 @@ discoverRoutes.get<{ genreId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -542,6 +551,7 @@ discoverRoutes.get<{ networkId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -591,6 +601,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -629,6 +640,7 @@ discoverRoutes.get('/trending', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -681,6 +693,7 @@ discoverRoutes.get<{ keywordId: string }>( }); const media = await Media.getRelatedMedia( + req.user, data.results.map((result) => result.id) ); @@ -813,6 +826,25 @@ discoverRoutes.get, WatchlistResponse>( select: ['id', 'plexToken'], }); + if (activeUser) { + const [result, total] = await getRepository(Watchlist).findAndCount({ + where: { requestedBy: { id: activeUser?.id } }, + relations: { + /*requestedBy: true,media:true*/ + }, + // loadRelationIds: true, + take: itemsPerPage, + skip: offset, + }); + if (total) { + return res.json({ + page: page, + totalPages: total / itemsPerPage, + totalResults: total, + results: result, + }); + } + } if (!activeUser?.plexToken) { // We will just return an empty array if the user has no Plex token return res.json({ diff --git a/server/routes/index.ts b/server/routes/index.ts index f76f09fa0..552ca78d0 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import { mapWatchProviderDetails } from '@server/models/common'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import settingsRoutes from '@server/routes/settings'; +import watchlistRoutes from '@server/routes/watchlist'; import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; @@ -116,6 +117,7 @@ router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); +router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index f11cead8c..8d609262b 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -45,6 +45,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); @@ -86,6 +87,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/person.ts b/server/routes/person.ts index 7f5d62236..d5cb89867 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -42,10 +42,12 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => { }); const castMedia = await Media.getRelatedMedia( + req.user, combinedCredits.cast.map((result) => result.id) ); const crewMedia = await Media.getRelatedMedia( + req.user, combinedCredits.crew.map((result) => result.id) ); diff --git a/server/routes/search.ts b/server/routes/search.ts index b9254221a..0de090cad 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -34,6 +34,7 @@ searchRoutes.get('/', async (req, res, next) => { } const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index d45e40620..1d2b4deed 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -69,6 +69,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); @@ -109,6 +110,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => { }); const media = await Media.getRelatedMedia( + req.user, results.results.map((result) => result.id) ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 55a912f36..ada7df393 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -8,6 +8,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import { Watchlist } from '@server/entity/Watchlist'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, @@ -699,8 +700,7 @@ router.get<{ id: string }, WatchlistResponse>( ) { return next({ status: 403, - message: - "You do not have permission to view this user's Plex Watchlist.", + message: "You do not have permission to view this user's Watchlist.", }); } @@ -714,6 +714,24 @@ router.get<{ id: string }, WatchlistResponse>( }); if (!user?.plexToken) { + if (user) { + const [result, total] = await getRepository(Watchlist).findAndCount({ + where: { requestedBy: { id: user?.id } }, + relations: { requestedBy: true }, + // loadRelationIds: true, + take: itemsPerPage, + skip: offset, + }); + if (total) { + return res.json({ + page: page, + totalPages: total / itemsPerPage, + totalResults: total, + results: result, + }); + } + } + // We will just return an empty array if the user has no Plex token return res.json({ page: 1, diff --git a/server/routes/watchlist.ts b/server/routes/watchlist.ts new file mode 100644 index 000000000..bbb44da01 --- /dev/null +++ b/server/routes/watchlist.ts @@ -0,0 +1,73 @@ +import { + DuplicateWatchlistRequestError, + NotFoundError, + Watchlist, +} from '@server/entity/Watchlist'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { QueryFailedError } from 'typeorm'; + +import { watchlistCreate } from '@server/interfaces/api/watchlistCreate'; + +const watchlistRoutes = Router(); + +watchlistRoutes.post( + '/', + async (req, res, next) => { + try { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to add watchlist.', + }); + } + const values = watchlistCreate.parse(req.body); + + const request = await Watchlist.createWatchlist({ + watchlistRequest: values, + user: req.user, + }); + return res.status(201).json(request); + } catch (error) { + if (!(error instanceof Error)) { + return; + } + switch (error.constructor) { + case QueryFailedError: + logger.warn('Something wrong with data watchlist', { + tmdbId: req.body.tmdbId, + mediaType: req.body.mediaType, + label: 'Watchlist', + }); + return next({ status: 409, message: 'Something wrong' }); + case DuplicateWatchlistRequestError: + return next({ status: 409, message: error.message }); + default: + return next({ status: 500, message: error.message }); + } + } + } +); + +watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to delete watchlist data.', + }); + } + try { + await Watchlist.deleteWatchlist(Number(req.params.tmdbId), req.user); + return res.status(204).send(); + } catch (e) { + if (e instanceof NotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } +}); + +export default watchlistRoutes; diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 34b379e24..4b3b0fe4d 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -338,6 +338,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { Plex Watchlist will appear here.', }); @@ -22,12 +22,11 @@ const PlexWatchlistSlider = () => { totalPages: number; totalResults: number; results: WatchlistItem[]; - }>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, { + }>('/api/v1/discover/watchlist', { revalidateOnMount: true, }); if ( - user?.userType !== UserType.PLEX || (watchlistItems && watchlistItems.results.length === 0 && !user?.settings?.watchlistSyncMovies && @@ -69,6 +68,7 @@ const PlexWatchlistSlider = () => { key={`watchlist-slider-item-${item.ratingKey}`} tmdbId={item.tmdbId} type={item.mediaType} + isAddedToWatchlist={true} /> ))} /> diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 6fcbe43cb..c58eba7fa 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -74,7 +74,7 @@ export const sliderTitles = defineMessages({ recentlyAdded: 'Recently Added', upcoming: 'Upcoming Movies', trending: 'Trending', - plexwatchlist: 'Your Plex Watchlist', + plexwatchlist: 'Your Watchlist', moviegenres: 'Movie Genres', tvgenres: 'Series Genres', studios: 'Studios', diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 54b5cc801..4ca34d8f1 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -95,7 +95,9 @@ const MediaSlider = ({ case 'movie': return ( { return (
  • { return (
  • { @@ -23,6 +24,7 @@ const TmdbTitleCard = ({ tvdbId, type, canExpand, + isAddedToWatchlist = false, }: TmdbTitleCardProps) => { const { hasPermission } = useUser(); @@ -56,7 +58,11 @@ const TmdbTitleCard = ({ return isMovie(title) ? ( ) : ( {title} added to watchlist successfully!', + watchlistDeleted: + '{title} Removed from watchlist successfully!', + watchlistCancel: 'watchlist for {title} canceled.', + watchlistError: 'Something went wrong try again.', +}); + const TitleCard = ({ id, image, @@ -38,6 +58,7 @@ const TitleCard = ({ title, status, mediaType, + isAddedToWatchlist = false, inProgress = false, canExpand = false, }: TitleCardProps) => { @@ -48,6 +69,10 @@ const TitleCard = ({ const [currentStatus, setCurrentStatus] = useState(status); const [showDetail, setShowDetail] = useState(false); const [showRequestModal, setShowRequestModal] = useState(false); + const { addToast } = useToasts(); + const [toggleWatchlist, setToggleWatchlist] = useState( + !isAddedToWatchlist + ); // Just to get the year from the date if (year) { @@ -68,6 +93,65 @@ const TitleCard = ({ [] ); + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.post('/api/v1/watchlist', { + tmdbId: id, + mediaType, + title, + }); + mutate('/api/v1/discover/watchlist'); + if (response.data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + 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/' + id); + + if (response.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + mutate('/api/v1/discover/watchlist'); + setToggleWatchlist((prevState) => !prevState); + } + }; + const closeModal = useCallback(() => setShowRequestModal(false), []); const showRequestButton = hasPermission( @@ -141,6 +225,28 @@ const TitleCard = ({ : intl.formatMessage(globalMessages.tvshow)} + {showDetail && ( + <> + {toggleWatchlist ? ( + + ) : ( + + )} + + )} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
    Plex Watchlist will appear here.", "components.Discover.noRequests": "No requests.", - "components.Discover.plexwatchlist": "Your Plex Watchlist", + "components.Discover.plexwatchlist": "Your Watchlist", "components.Discover.popularmovies": "Popular Movies", "components.Discover.populartv": "Popular Series", "components.Discover.recentlyAdded": "Recently Added", @@ -210,7 +210,7 @@ "components.NotificationTypeSelector.mediaapproved": "Request Approved", "components.NotificationTypeSelector.mediaapprovedDescription": "Send notifications when media requests are manually approved.", "components.NotificationTypeSelector.mediaautorequested": "Request Automatically Submitted", - "components.NotificationTypeSelector.mediaautorequestedDescription": "Get notified when new media requests are automatically submitted for items on your Plex Watchlist.", + "components.NotificationTypeSelector.mediaautorequestedDescription": "Get notified when new media requests are automatically submitted for items on Your Watchlist.", "components.NotificationTypeSelector.mediaavailable": "Request Available", "components.NotificationTypeSelector.mediaavailableDescription": "Send notifications when media requests become available.", "components.NotificationTypeSelector.mediadeclined": "Request Declined", @@ -1080,6 +1080,11 @@ "components.UserProfile.seriesrequest": "Series Requests", "components.UserProfile.totalrequests": "Total Requests", "components.UserProfile.unlimited": "Unlimited", + "components.TitleCard.addToWatchList": "Add to watchlist", + "components.TitleCard.watchlistCancel": "watchlist for {title} canceled.", + "components.TitleCard.watchlistDeleted": "{title} Removed from watchlist successfully!", + "components.TitleCard.watchlistError": "Something went wrong try again.", + "components.TitleCard.watchlistSuccess": "{title} added to watchlist successfully!", "i18n.advanced": "Advanced", "i18n.all": "All", "i18n.approve": "Approve", diff --git a/src/i18n/locale/ua.json b/src/i18n/locale/ua.json index af02cc6d1..9963798e0 100644 --- a/src/i18n/locale/ua.json +++ b/src/i18n/locale/ua.json @@ -12,7 +12,7 @@ "components.Discover.DiscoverStudio.studioMovies": "Фільми {studio}", "components.Discover.DiscoverTvGenre.genreSeries": "Серіали в жанрі \"{genre}\"", "components.Discover.DiscoverTvLanguage.languageSeries": "Серіали мовою \"{language}\"", - "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist", + "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist", "components.Discover.DiscoverWatchlist.watchlist": "Список спостереження Plex", "components.Discover.MovieGenreList.moviegenres": "Фільми за жанрами", "components.Discover.MovieGenreSlider.moviegenres": "Фільми за жанрами",