Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(watchlist): Add media to watchlist #374

Merged
merged 5 commits into from
Jun 5, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion 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 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
148 changes: 148 additions & 0 deletions server/entity/Watchlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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';

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: Watchlist,
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,
},
});
Comment on lines +112 to +117

Check failure

Code scanning / CodeQL

NoSQL database query built from user-controlled sources (experimental)

(Experimental) This may be a database query that depends on [a user-provided value](1). Identified using machine learning.

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;
}
}
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"`);
}
}
11 changes: 11 additions & 0 deletions server/repositories/watchlist.repository.ts
Original file line number Diff line number Diff line change
@@ -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()
// },
});
1 change: 1 addition & 0 deletions server/routes/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);

Expand Down
Loading