Skip to content

Commit

Permalink
Merge pull request #374 from yalagin/add-watchlist
Browse files Browse the repository at this point in the history
feat(watchlist): Add media to watchlist
  • Loading branch information
fallenbagel authored Jun 5, 2023
2 parents 53f6a89 + 03316c6 commit 21c1bbe
Show file tree
Hide file tree
Showing 31 changed files with 566 additions and 21 deletions.
4 changes: 2 additions & 2 deletions cypress/e2e/discover.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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()
Expand Down
66 changes: 66 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -3962,13 +3992,49 @@ 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
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
- users
- watchlist
parameters:
- in: path
name: userId
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 16 additions & 4 deletions server/entity/Media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,7 +14,6 @@ import {
Column,
CreateDateColumn,
Entity,
In,
Index,
OneToMany,
PrimaryGeneratedColumn,
Expand All @@ -25,6 +26,7 @@ import Season from './Season';
@Entity()
class Media {
public static async getRelatedMedia(
user: User | undefined,
tmdbIds: number | number[]
): Promise<Media[]> {
const mediaRepository = getRepository(Media);
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions server/entity/User.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Expand Down
157 changes: 157 additions & 0 deletions server/entity/Watchlist.ts
Original file line number Diff line number Diff line change
@@ -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<Watchlist>) {
Object.assign(this, init);
}

public static async createWatchlist({
watchlistRequest,
user,
}: {
watchlistRequest: {
mediaType: MediaType;
ratingKey?: ZodOptional<ZodString>['_output'];
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
user: User;
}): Promise<Watchlist> {
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<Watchlist | null> {
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;
}
}
9 changes: 9 additions & 0 deletions server/interfaces/api/watchlistCreate.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
1 change: 1 addition & 0 deletions server/lib/watchlistsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand Down
19 changes: 19 additions & 0 deletions server/migration/1682608634546-AddWatchlists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddWatchlists1682608634546 implements MigrationInterface {
name = 'AddWatchlists1682608634546';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
await queryRunner.query(`DROP TABLE "watchlist"`);
}
}
Loading

0 comments on commit 21c1bbe

Please sign in to comment.