diff --git a/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..35adf403ce 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -38,6 +38,8 @@ tags: description: Endpoints related to getting service (Radarr/Sonarr) details. - name: watchlist description: Collection of media to watch later + - name: vote + description: Vote media as interested or not interested - name: blocklist description: Blocklisted media from discovery page. servers: @@ -254,6 +256,9 @@ components: enableSpecialEpisodes: type: boolean example: false + enableVoting: + type: boolean + example: false NetworkSettings: type: object properties: @@ -710,6 +715,70 @@ components: initialized: type: boolean example: false + enableVoting: + type: boolean + example: false + Vote: + type: object + properties: + id: + type: number + example: 1 + tmdbId: + type: number + example: 872585 + mediaType: + type: string + enum: [movie, tv] + actionType: + type: string + enum: [interested, not_interested] + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + updatedAt: + type: string + example: '2020-09-12T10:05:27.000Z' + VoteLookupResponse: + type: object + properties: + vote: + nullable: true + allOf: + - $ref: '#/components/schemas/Vote' + VoteHistoryResponse: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/Vote' + VoteUpsertRequest: + type: object + properties: + tmdbId: + type: number + example: 872585 + mediaType: + type: string + enum: [movie, tv] + actionType: + type: string + enum: [interested, not_interested] + required: + - tmdbId + - mediaType + - actionType + VoteUpsertResponse: + allOf: + - $ref: '#/components/schemas/Vote' + - type: object + properties: + created: + type: boolean + example: true MovieResult: type: object required: @@ -4793,6 +4862,124 @@ paths: responses: '204': description: Succesfully removed watchlist item + /vote: + post: + summary: Create or update vote + description: Create a vote for the current user, or update an existing vote for the same media. + tags: + - vote + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VoteUpsertRequest' + responses: + '200': + description: Existing vote updated + content: + application/json: + schema: + $ref: '#/components/schemas/VoteUpsertResponse' + '201': + description: Vote created + content: + application/json: + schema: + $ref: '#/components/schemas/VoteUpsertResponse' + /vote/history: + get: + summary: Get current user vote history + tags: + - vote + parameters: + - in: query + name: take + schema: + type: number + default: 20 + minimum: 1 + maximum: 100 + - in: query + name: skip + schema: + type: number + default: 0 + minimum: 0 + - in: query + name: filter + schema: + type: string + enum: [all, interested, not_interested] + default: all + - in: query + name: mediaType + schema: + type: string + enum: [all, movie, tv] + default: all + - in: query + name: sort + schema: + type: string + enum: [added] + default: added + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: Vote history returned + content: + application/json: + schema: + $ref: '#/components/schemas/VoteHistoryResponse' + /vote/{mediaType}/{tmdbId}: + get: + summary: Get current user vote for media + tags: + - vote + parameters: + - in: path + name: mediaType + required: true + schema: + type: string + enum: [movie, tv] + - in: path + name: tmdbId + required: true + schema: + type: number + responses: + '200': + description: Vote lookup returned + content: + application/json: + schema: + $ref: '#/components/schemas/VoteLookupResponse' + delete: + summary: Delete current user vote for media + tags: + - vote + parameters: + - in: path + name: mediaType + required: true + schema: + type: string + enum: [movie, tv] + - in: path + name: tmdbId + required: true + schema: + type: number + responses: + '204': + description: Vote removed /user/{userId}/watchlist: get: summary: Get the Plex watchlist for a specific user @@ -6005,6 +6192,47 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + /discover/popular: + get: + summary: Popular on this server + description: Returns a list of popular movies and TV shows based on server vote totals. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 1 + totalResults: + type: number + example: 20 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' /discover/keyword/{keywordId}/movies: get: summary: Get movies from keyword diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..90e94c73a8 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -22,6 +22,7 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + POPULAR_ON_SERVER, } export const defaultSliders: Partial[] = [ @@ -50,51 +51,57 @@ export const defaultSliders: Partial[] = [ order: 3, }, { - type: DiscoverSliderType.POPULAR_MOVIES, + type: DiscoverSliderType.POPULAR_ON_SERVER, enabled: true, isBuiltIn: true, order: 4, }, { - type: DiscoverSliderType.MOVIE_GENRES, + type: DiscoverSliderType.POPULAR_MOVIES, enabled: true, isBuiltIn: true, order: 5, }, { - type: DiscoverSliderType.UPCOMING_MOVIES, + type: DiscoverSliderType.MOVIE_GENRES, enabled: true, isBuiltIn: true, order: 6, }, { - type: DiscoverSliderType.STUDIOS, + type: DiscoverSliderType.UPCOMING_MOVIES, enabled: true, isBuiltIn: true, order: 7, }, { - type: DiscoverSliderType.POPULAR_TV, + type: DiscoverSliderType.STUDIOS, enabled: true, isBuiltIn: true, order: 8, }, { - type: DiscoverSliderType.TV_GENRES, + type: DiscoverSliderType.POPULAR_TV, enabled: true, isBuiltIn: true, order: 9, }, { - type: DiscoverSliderType.UPCOMING_TV, + type: DiscoverSliderType.TV_GENRES, enabled: true, isBuiltIn: true, order: 10, }, { - type: DiscoverSliderType.NETWORKS, + type: DiscoverSliderType.UPCOMING_TV, enabled: true, isBuiltIn: true, order: 11, }, + { + type: DiscoverSliderType.NETWORKS, + enabled: true, + isBuiltIn: true, + order: 12, + }, ]; diff --git a/server/entity/DiscoverSlider.ts b/server/entity/DiscoverSlider.ts index 0de0aef81e..ece90edf18 100644 --- a/server/entity/DiscoverSlider.ts +++ b/server/entity/DiscoverSlider.ts @@ -23,6 +23,13 @@ class DiscoverSlider { slider, }); await sliderRepository.save(new DiscoverSlider(slider)); + } else if ( + existingSlider.isBuiltIn && + typeof slider.order === 'number' && + existingSlider.order !== slider.order + ) { + existingSlider.order = slider.order; + await sliderRepository.save(existingSlider); } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 445b9e6ac2..6a0ab02a01 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -8,6 +8,7 @@ import { } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import OverrideRule from '@server/entity/OverrideRule'; +import { Vote, VoteActionType } from '@server/entity/Vote'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; @@ -43,6 +44,37 @@ type MediaRequestOptions = { @Entity() export class MediaRequest { + private static async upsertInterestedVote( + user: User, + tmdbId: number, + mediaType: MediaType + ): Promise { + const voteRepository = getRepository(Vote); + const existingVote = await voteRepository.findOne({ + where: { + user: { id: user.id }, + tmdbId, + mediaType, + }, + }); + + if (existingVote) { + existingVote.actionType = VoteActionType.INTERESTED; + existingVote.updatedAt = new Date(); + await voteRepository.save(existingVote); + return; + } + + await voteRepository.save( + new Vote({ + user, + tmdbId, + mediaType, + actionType: VoteActionType.INTERESTED, + }) + ); + } + public static async request( requestBody: MediaRequestBody, user: User, @@ -375,6 +407,26 @@ export class MediaRequest { }); await requestRepository.save(request); + if (settings.main.enableVoting) { + try { + await MediaRequest.upsertInterestedVote( + requestUser, + requestBody.mediaId, + requestBody.mediaType + ); + } catch (e) { + logger.warn( + 'Failed to create or update interested vote from request.', + { + label: 'Media Request', + requestId: request.id, + tmdbId: requestBody.mediaId, + mediaType: requestBody.mediaType, + errorMessage: e instanceof Error ? e.message : undefined, + } + ); + } + } return request; } else { const tmdbMediaShow = tmdbMedia as Awaited< @@ -506,6 +558,26 @@ export class MediaRequest { }); await requestRepository.save(request); + if (settings.main.enableVoting) { + try { + await MediaRequest.upsertInterestedVote( + requestUser, + requestBody.mediaId, + requestBody.mediaType + ); + } catch (e) { + logger.warn( + 'Failed to create or update interested vote from request.', + { + label: 'Media Request', + requestId: request.id, + tmdbId: requestBody.mediaId, + mediaType: requestBody.mediaType, + errorMessage: e instanceof Error ? e.message : undefined, + } + ); + } + } return request; } } diff --git a/server/entity/User.ts b/server/entity/User.ts index 011b1be497..0cc2f74a20 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -29,6 +29,7 @@ import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; import { UserPushSubscription } from './UserPushSubscription'; import { UserSettings } from './UserSettings'; +import { Vote } from './Vote'; @Entity() export class User { @@ -121,6 +122,9 @@ export class User { @OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy) public watchlists: Watchlist[]; + @OneToMany(() => Vote, (vote) => vote.user) + public votes: Vote[]; + @Column({ nullable: true }) public movieQuotaLimit?: number; diff --git a/server/entity/Vote.ts b/server/entity/Vote.ts new file mode 100644 index 0000000000..1968710681 --- /dev/null +++ b/server/entity/Vote.ts @@ -0,0 +1,54 @@ +import type { MediaType } from '@server/constants/media'; +import { User } from '@server/entity/User'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export enum VoteActionType { + INTERESTED = 'interested', + NOT_INTERESTED = 'not_interested', +} + +@Entity() +@Index(['user', 'tmdbId', 'mediaType'], { unique: true }) +@Index(['tmdbId', 'mediaType']) +@Index(['user', 'createdAt']) +export class Vote { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => User, (user) => user.votes, { + onDelete: 'CASCADE', + nullable: false, + }) + @Index() + public user: User; + + @Column({ type: 'integer' }) + public tmdbId: number; + + @Column({ type: 'varchar' }) + public mediaType: MediaType; + + @Column({ type: 'varchar' }) + public actionType: VoteActionType; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt: Date; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index ea08d4e61d..535866d52b 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -41,6 +41,7 @@ export interface PublicSettingsResponse { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableVoting: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; diff --git a/server/interfaces/api/voteInterfaces.ts b/server/interfaces/api/voteInterfaces.ts new file mode 100644 index 0000000000..2696076e16 --- /dev/null +++ b/server/interfaces/api/voteInterfaces.ts @@ -0,0 +1,51 @@ +import { MediaType } from '@server/constants/media'; +import { VoteActionType } from '@server/entity/Vote'; +import { z } from 'zod'; +import type { PaginatedResponse } from './common'; + +export const voteCreate = z.object({ + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), + actionType: z.nativeEnum(VoteActionType), +}); + +export const votePathParams = z.object({ + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), +}); + +export const voteHistoryQuery = z.object({ + take: z.coerce.number().min(1).max(100).optional().default(20), + skip: z.coerce.number().min(0).optional().default(0), + filter: z + .enum(['all', VoteActionType.INTERESTED, VoteActionType.NOT_INTERESTED]) + .optional() + .default('all'), + mediaType: z + .enum(['all', MediaType.MOVIE, MediaType.TV]) + .optional() + .default('all'), + sort: z.enum(['added']).optional().default('added'), + sortDirection: z.enum(['asc', 'desc']).optional().default('desc'), +}); + +export interface VoteResponse { + id: number; + tmdbId: number; + mediaType: MediaType; + actionType: VoteActionType; + createdAt: Date; + updatedAt: Date; +} + +export interface VoteUpsertResponse extends VoteResponse { + created: boolean; +} + +export interface VoteLookupResponse { + vote: VoteResponse | null; +} + +export interface VoteHistoryResponse extends PaginatedResponse { + results: VoteResponse[]; +} diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7057cf2b01..e2ced743c1 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -145,6 +145,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableVoting: boolean; locale: string; youtubeUrl: string; } @@ -197,6 +198,7 @@ interface FullPublicSettings extends PublicSettings { jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableVoting: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -387,7 +389,7 @@ class Settings { applicationTitle: 'Seerr', applicationUrl: '', cacheImages: false, - defaultPermissions: Permission.REQUEST, + defaultPermissions: Permission.REQUEST | Permission.VOTE, defaultQuotas: { movie: {}, tv: {}, @@ -405,6 +407,7 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + enableVoting: false, locale: 'en', youtubeUrl: '', }, @@ -694,6 +697,7 @@ class Settings { mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, + enableVoting: this.data.main.enableVoting, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, diff --git a/server/migration/postgres/1772153885378-AddVoting.ts b/server/migration/postgres/1772153885378-AddVoting.ts new file mode 100644 index 0000000000..ed7edbfd84 --- /dev/null +++ b/server/migration/postgres/1772153885378-AddVoting.ts @@ -0,0 +1,39 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVoting1772153885378 implements MigrationInterface { + name = 'AddVoting1772153885378'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "vote" ("id" SERIAL NOT NULL, "tmdbId" integer NOT NULL, "mediaType" character varying NOT NULL, "actionType" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer NOT NULL, CONSTRAINT "UQ_c0f8905b5d8f510f95e41f4a6da" UNIQUE ("userId", "tmdbId", "mediaType"), CONSTRAINT "PK_121b3b79fce9d8f4f4859b0f5f8" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_4d7f0d2d4f5ee1739a637ec5a5" ON "vote" ("userId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6e84ec1f1d89da477fd3de362d" ON "vote" ("tmdbId", "mediaType") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_33f4c9ebf3ad06ec3156fbe5f4" ON "vote" ("userId", "createdAt") ` + ); + await queryRunner.query( + `ALTER TABLE "vote" ADD CONSTRAINT "FK_4d7f0d2d4f5ee1739a637ec5a5a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "vote" DROP CONSTRAINT "FK_4d7f0d2d4f5ee1739a637ec5a5a"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_33f4c9ebf3ad06ec3156fbe5f4"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6e84ec1f1d89da477fd3de362d"` + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_4d7f0d2d4f5ee1739a637ec5a5"` + ); + await queryRunner.query(`DROP TABLE "vote"`); + } +} diff --git a/server/migration/sqlite/1772153871569-AddVoting.ts b/server/migration/sqlite/1772153871569-AddVoting.ts new file mode 100644 index 0000000000..0dc2d1c811 --- /dev/null +++ b/server/migration/sqlite/1772153871569-AddVoting.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVoting1772153871569 implements MigrationInterface { + name = 'AddVoting1772153871569'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "vote" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "tmdbId" integer NOT NULL, "mediaType" varchar NOT NULL, "actionType" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer NOT NULL, CONSTRAINT "UQ_c0f8905b5d8f510f95e41f4a6da" UNIQUE ("userId", "tmdbId", "mediaType"), CONSTRAINT "FK_4d7f0d2d4f5ee1739a637ec5a5a" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_4d7f0d2d4f5ee1739a637ec5a5" ON "vote" ("userId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6e84ec1f1d89da477fd3de362d" ON "vote" ("tmdbId", "mediaType") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_33f4c9ebf3ad06ec3156fbe5f4" ON "vote" ("userId", "createdAt") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_33f4c9ebf3ad06ec3156fbe5f4"`); + await queryRunner.query(`DROP INDEX "IDX_6e84ec1f1d89da477fd3de362d"`); + await queryRunner.query(`DROP INDEX "IDX_4d7f0d2d4f5ee1739a637ec5a5"`); + await queryRunner.query(`DROP TABLE "vote"`); + } +} diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 87ea793608..752387c62b 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -4,6 +4,7 @@ import type { TmdbProductionCompany, } from '@server/api/themoviedb/interfaces'; import type Media from '@server/entity/Media'; +import type { VoteActionType } from '@server/entity/Vote'; import type { Cast, Crew, @@ -86,6 +87,7 @@ export interface MovieDetails { watchProviders?: WatchProviders[]; keywords: Keyword[]; onUserWatchlist?: boolean; + userVote?: VoteActionType; } export const mapProductionCompany = ( @@ -103,7 +105,8 @@ export const mapProductionCompany = ( export const mapMovieDetails = ( movie: TmdbMovieDetails, media?: Media, - userWatchlist?: boolean + userWatchlist?: boolean, + userVote?: VoteActionType ): MovieDetails => ({ id: movie.id, adult: movie.adult, @@ -151,4 +154,5 @@ export const mapMovieDetails = ( name: keyword.name, })), onUserWatchlist: userWatchlist, + userVote, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index ef676e4a8d..2e174365d8 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -7,6 +7,7 @@ import type { TmdbTvSeasonResult, } from '@server/api/themoviedb/interfaces'; import type Media from '@server/entity/Media'; +import type { VoteActionType } from '@server/entity/Vote'; import type { Video } from './Movie'; import type { Cast, @@ -112,6 +113,7 @@ export interface TvDetails { mediaInfo?: Media; watchProviders?: WatchProviders[]; onUserWatchlist?: boolean; + userVote?: VoteActionType; } const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ @@ -163,7 +165,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ export const mapTvDetails = ( show: TmdbTvDetails, media?: Media, - userWatchlist?: boolean + userWatchlist?: boolean, + userVote?: VoteActionType ): TvDetails => ({ createdBy: show.created_by, episodeRunTime: show.episode_run_time, @@ -226,4 +229,5 @@ export const mapTvDetails = ( mediaInfo: media, watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), onUserWatchlist: userWatchlist, + userVote, }); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index d467976e21..384d71ccd6 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 { Vote, VoteActionType } from '@server/entity/Vote'; import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, @@ -16,8 +17,10 @@ import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; import { mapCollectionResult, + mapMovieDetailsToResult, mapMovieResult, mapPersonResult, + mapTvDetailsToResult, mapTvResult, } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; @@ -84,6 +87,7 @@ export type FilterOptions = z.infer; const ApiQuerySchema = QueryFilterOptions.omit({ certificationMode: true, }); +const NOT_INTERESTED_PENALTY_WEIGHT = 0.5; discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); @@ -741,6 +745,121 @@ discoverRoutes.get('/trending', async (req, res, next) => { } }); +discoverRoutes.get('/popular', async (req, res, next) => { + const tmdb = createTmdbWithRegionLanguage(req.user); + const itemsPerPage = 20; + const page = Number(req.query.page) || 1; + const skip = (page - 1) * itemsPerPage; + + try { + const voteRepository = getRepository(Vote); + const groupedVotes = voteRepository + .createQueryBuilder('vote') + .select('vote.tmdbId', 'tmdbId') + .addSelect('vote.mediaType', 'mediaType') + .addSelect('MAX(vote.createdAt)', 'lastVotedAt') + .addSelect( + `SUM(CASE + WHEN vote.actionType = :interestedAction THEN 1 + WHEN vote.actionType = :notInterestedAction THEN -${NOT_INTERESTED_PENALTY_WEIGHT} + ELSE 0 + END)`, + 'score' + ) + .where('vote.actionType IN (:...actionTypes)', { + actionTypes: [VoteActionType.INTERESTED, VoteActionType.NOT_INTERESTED], + interestedAction: VoteActionType.INTERESTED, + notInterestedAction: VoteActionType.NOT_INTERESTED, + }) + .groupBy('vote.tmdbId') + .addGroupBy('vote.mediaType') + .having( + `SUM(CASE + WHEN vote.actionType = :interestedAction THEN 1 + WHEN vote.actionType = :notInterestedAction THEN -${NOT_INTERESTED_PENALTY_WEIGHT} + ELSE 0 + END) > 0` + ); + + const totalResults = (await groupedVotes.clone().getRawMany()).length; + const voteRows = await groupedVotes + .orderBy('score', 'DESC') + .addOrderBy('lastVotedAt', 'DESC') + .offset(skip) + .limit(itemsPerPage) + .getRawMany<{ + tmdbId: string; + mediaType: MediaType; + }>(); + + const media = await Media.getRelatedMedia( + req.user, + voteRows.map((item) => Number(item.tmdbId)) + ); + + const mappedResults = await Promise.all( + voteRows.map(async (voteRow) => { + try { + if (voteRow.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ + movieId: Number(voteRow.tmdbId), + language: (req.query.language as string) ?? req.locale, + }); + + return mapMovieResult( + mapMovieDetailsToResult(movie), + media.find( + (med) => + med.tmdbId === movie.id && med.mediaType === MediaType.MOVIE + ) + ); + } + + const tv = await tmdb.getTvShow({ + tvId: Number(voteRow.tmdbId), + language: (req.query.language as string) ?? req.locale, + }); + + return mapTvResult( + mapTvDetailsToResult(tv), + media.find( + (med) => med.tmdbId === tv.id && med.mediaType === MediaType.TV + ) + ); + } catch (error) { + logger.debug('Unable to map popular-on-server title from votes.', { + label: 'API', + tmdbId: Number(voteRow.tmdbId), + mediaType: voteRow.mediaType, + errorMessage: error instanceof Error ? error.message : undefined, + }); + return null; + } + }) + ); + + const results = mappedResults.filter( + (result): result is NonNullable => result !== null + ); + + return res.status(200).json({ + page, + totalPages: Math.ceil(totalResults / itemsPerPage), + totalResults, + results, + }); + } catch (e) { + logger.debug('Something went wrong retrieving popular titles from votes', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve popular titles from votes.', + }); + } +}); + discoverRoutes.get<{ keywordId: string }>( '/keyword/:keywordId/movies', async (req, res, next) => { diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf968..d041e3b0d9 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -42,6 +42,7 @@ import searchRoutes from './search'; import serviceRoutes from './service'; import tvRoutes from './tv'; import user from './user'; +import voteRoutes from './vote'; const router = Router(); @@ -151,6 +152,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('/vote', isAuthenticated(Permission.VOTE), voteRoutes); router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/blocklist', isAuthenticated(), blocklistRoutes); router.use( diff --git a/server/routes/movie.ts b/server/routes/movie.ts index 80b8a30058..30a708734c 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -5,6 +5,7 @@ import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Vote } from '@server/entity/Vote'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; @@ -33,7 +34,22 @@ movieRoutes.get('/:id', async (req, res, next) => { }, }); - const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist); + const userVote = await getRepository(Vote).findOne({ + where: { + tmdbId: Number(req.params.id), + mediaType: MediaType.MOVIE, + user: { + id: req.user?.id, + }, + }, + }); + + const data = mapMovieDetails( + tmdbMovie, + media, + onUserWatchlist, + userVote?.actionType + ); // TMDB issue where it doesnt fallback to English when no overview is available in requested locale. if (!data.overview) { diff --git a/server/routes/tv.ts b/server/routes/tv.ts index f5632398e6..709582ea7a 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -6,6 +6,7 @@ import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Vote } from '@server/entity/Vote'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; @@ -41,7 +42,17 @@ tvRoutes.get('/:id', async (req, res, next) => { }, }); - const data = mapTvDetails(tv, media, onUserWatchlist); + const userVote = await getRepository(Vote).findOne({ + where: { + tmdbId: Number(req.params.id), + mediaType: MediaType.TV, + user: { + id: req.user?.id, + }, + }, + }); + + const data = mapTvDetails(tv, media, onUserWatchlist, userVote?.actionType); // TMDB issue where it doesnt fallback to English when no overview is available in requested locale. if (!data.overview) { diff --git a/server/routes/vote.ts b/server/routes/vote.ts new file mode 100644 index 0000000000..faf03d2add --- /dev/null +++ b/server/routes/vote.ts @@ -0,0 +1,210 @@ +import { getRepository } from '@server/datasource'; +import { Vote } from '@server/entity/Vote'; +import type { + VoteHistoryResponse, + VoteLookupResponse, + VoteUpsertResponse, +} from '@server/interfaces/api/voteInterfaces'; +import { + voteCreate, + voteHistoryQuery, + votePathParams, +} from '@server/interfaces/api/voteInterfaces'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { ZodError } from 'zod'; + +const voteRoutes = Router(); + +voteRoutes.use((_req, res, next) => { + if (!getSettings().main.enableVoting) { + return next({ + status: 403, + message: 'Voting is currently disabled.', + }); + } + + return next(); +}); + +voteRoutes.get( + '/history', + async (req, res, next) => { + // Satisfy typescript here. User is set by router middleware. + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const { take, skip, filter, mediaType, sortDirection } = + voteHistoryQuery.parse(req.query); + const voteRepository = getRepository(Vote); + const [votes, total] = await voteRepository.findAndCount({ + where: { + user: { id: req.user.id }, + ...(filter !== 'all' ? { actionType: filter } : {}), + ...(mediaType !== 'all' ? { mediaType } : {}), + }, + order: { createdAt: sortDirection.toUpperCase() as 'ASC' | 'DESC' }, + take, + skip, + }); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(total / take), + pageSize: take, + results: total, + page: Math.ceil(skip / take) + 1, + }, + results: votes, + }); + } catch (e) { + if (e instanceof ZodError) { + return next({ status: 400, message: 'Invalid vote history query.' }); + } + logger.debug('Something went wrong retrieving vote history.', { + label: 'API', + errorMessage: e instanceof Error ? e.message : undefined, + }); + + return next({ status: 500, message: 'Unable to retrieve vote history.' }); + } + } +); + +voteRoutes.get<{ mediaType: string; tmdbId: string }, VoteLookupResponse>( + '/:mediaType/:tmdbId', + async (req, res, next) => { + // Satisfy typescript here. User is set by router middleware. + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const { mediaType, tmdbId } = votePathParams.parse(req.params); + const voteRepository = getRepository(Vote); + const vote = await voteRepository.findOne({ + where: { + user: { id: req.user.id }, + tmdbId, + mediaType, + }, + }); + + return res.status(200).json({ vote }); + } catch (e) { + if (e instanceof ZodError) { + return next({ + status: 400, + message: 'Invalid vote lookup parameters.', + }); + } + logger.debug('Something went wrong retrieving vote.', { + label: 'API', + errorMessage: e instanceof Error ? e.message : undefined, + }); + + return next({ status: 500, message: 'Unable to retrieve vote.' }); + } + } +); + +voteRoutes.post('/', async (req, res, next) => { + // Satisfy typescript here. User is set by router middleware. + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const { tmdbId, mediaType, actionType } = voteCreate.parse(req.body); + const voteRepository = getRepository(Vote); + const existingVote = await voteRepository.findOne({ + where: { + user: { id: req.user.id }, + tmdbId, + mediaType, + }, + }); + + if (existingVote) { + existingVote.actionType = actionType; + existingVote.updatedAt = new Date(); + const updatedVote = await voteRepository.save(existingVote); + + return res.status(200).json({ + ...updatedVote, + created: false, + }); + } + + const vote = await voteRepository.save( + new Vote({ + user: req.user, + tmdbId, + mediaType, + actionType, + }) + ); + + return res.status(201).json({ + ...vote, + created: true, + }); + } catch (e) { + if (e instanceof ZodError) { + return next({ status: 400, message: 'Invalid vote payload.' }); + } + logger.debug('Something went wrong creating or updating a vote.', { + label: 'API', + errorMessage: e instanceof Error ? e.message : undefined, + }); + + return next({ status: 500, message: 'Unable to submit vote.' }); + } +}); + +voteRoutes.delete<{ mediaType: string; tmdbId: string }>( + '/:mediaType/:tmdbId', + async (req, res, next) => { + // Satisfy typescript here. User is set by router middleware. + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const { mediaType, tmdbId } = votePathParams.parse(req.params); + const voteRepository = getRepository(Vote); + const existingVote = await voteRepository.findOne({ + where: { + user: { id: req.user.id }, + tmdbId, + mediaType, + }, + }); + + if (!existingVote) { + return next({ status: 404, message: 'Vote not found.' }); + } + + await voteRepository.remove(existingVote); + return res.status(204).send(); + } catch (e) { + if (e instanceof ZodError) { + return next({ + status: 400, + message: 'Invalid vote delete parameters.', + }); + } + logger.debug('Something went wrong deleting vote.', { + label: 'API', + errorMessage: e instanceof Error ? e.message : undefined, + }); + + return next({ status: 500, message: 'Unable to delete vote.' }); + } + } +); + +export default voteRoutes; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index 219a0eed51..955a327837 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -169,6 +169,8 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices); case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: return intl.formatMessage(sliderTitles.tmdbtvstreamingservices); + case DiscoverSliderType.POPULAR_ON_SERVER: + return intl.formatMessage(sliderTitles.popularonserver); default: return 'Unknown Slider'; } diff --git a/src/components/Discover/PopularOnServer.tsx b/src/components/Discover/PopularOnServer.tsx new file mode 100644 index 0000000000..5d9699ece3 --- /dev/null +++ b/src/components/Discover/PopularOnServer.tsx @@ -0,0 +1,49 @@ +import Header from '@app/components/Common/Header'; +import ListView from '@app/components/Common/ListView'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDiscover from '@app/hooks/useDiscover'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import type { MovieResult, TvResult } from '@server/models/Search'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.PopularOnServer', { + popularOnServer: 'Popular On This Server', +}); + +const PopularOnServer = () => { + const intl = useIntl(); + const { + isLoadingInitialData, + isEmpty, + isLoadingMore, + isReachingEnd, + titles, + fetchMore, + error, + } = useDiscover('/api/v1/discover/popular'); + + if (error) { + return ; + } + + return ( + <> + +
+
{intl.formatMessage(messages.popularOnServer)}
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default PopularOnServer; diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..bbdb65b91b 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -69,6 +69,7 @@ export const genreColorMap: Record = { export const sliderTitles = defineMessages('components.Discover', { recentrequests: 'Recent Requests', popularmovies: 'Popular Movies', + popularonserver: 'Popular On This Server', populartv: 'Popular Series', upcomingtv: 'Upcoming Series', recentlyAdded: 'Recently Added', diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 97583d7552..9d52d96f64 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -266,6 +266,17 @@ const Discover = () => { /> ); break; + case DiscoverSliderType.POPULAR_ON_SERVER: + sliderComponent = ( + + ); + break; case DiscoverSliderType.TV_GENRES: sliderComponent = ; break; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 9baae60aca..34c3f91b3b 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -38,6 +38,8 @@ import { ExclamationTriangleIcon, EyeSlashIcon, FilmIcon, + HandThumbDownIcon, + HandThumbUpIcon, MinusCircleIcon, PlayIcon, StarIcon, @@ -106,6 +108,11 @@ const messages = defineMessages('components.MovieDetails', { watchlistError: 'Something went wrong. Please try again.', removefromwatchlist: 'Remove From Watchlist', addtowatchlist: 'Add To Watchlist', + voteInterested: 'Vote interested', + voteNotInterested: 'Vote not interested', + removeInterestedVote: 'Remove interested vote', + removeNotInterestedVote: 'Remove not interested vote', + voteError: 'Unable to update vote.', }); interface MovieDetailsProps { @@ -151,6 +158,10 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const { data: ratingData } = useSWR( `/api/v1/movie/${router.query.movieId}/ratingscombined` ); + const canVote = + settings.currentSettings.enableVoting && hasPermission(Permission.VOTE); + + const [isVoteUpdating, setIsVoteUpdating] = useState(false); const sortedCrew = useMemo( () => sortCrewPriority(data?.credits.crew ?? []), @@ -425,6 +436,37 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { closeBlocklistModal(); }; + const onClickVoteBtn = async ( + actionType: 'interested' | 'not_interested' + ): Promise => { + if (!canVote || !data?.id) { + return; + } + + setIsVoteUpdating(true); + + try { + if (data?.userVote === actionType) { + await axios.delete(`/api/v1/vote/movie/${data.id}`); + } else { + await axios.post('/api/v1/vote', { + tmdbId: data.id, + mediaType: MediaType.MOVIE, + actionType, + }); + } + + await revalidate(); + } catch { + addToast(intl.formatMessage(messages.voteError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsVoteUpdating(false); + } + }; + const showHideButton = hasPermission([Permission.MANAGE_BLOCKLIST], { type: 'or', }); @@ -571,7 +613,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { > + + + + + + )} +
{ blocklistedTagsLimit: data?.blocklistedTagsLimit || 50, partialRequestsEnabled: data?.partialRequestsEnabled, enableSpecialEpisodes: data?.enableSpecialEpisodes, + enableVoting: data?.enableVoting, cacheImages: data?.cacheImages, youtubeUrl: data?.youtubeUrl, }} @@ -193,6 +197,7 @@ const SettingsMain = () => { blocklistedTagsLimit: values.blocklistedTagsLimit, partialRequestsEnabled: values.partialRequestsEnabled, enableSpecialEpisodes: values.enableSpecialEpisodes, + enableVoting: values.enableVoting, cacheImages: values.cacheImages, youtubeUrl: values.youtubeUrl, }); @@ -489,6 +494,26 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue('enableVoting', !values.enableVoting); + }} + /> +
+