Compare commits

...

16 Commits

Author SHA1 Message Date
Yalagin
03316c642d fix(watchlist): add validation for creation request 2023-05-15 23:25:32 +07:00
Yalagin
c08897bdc1 fix(watchlist): fix github code scanning 2023-05-04 22:36:36 +07:00
Yalagin
469f64d484 test(watchlist): fix broken test 2023-05-02 21:13:18 +07:00
Yalagin
b7e3d285ed feat(watchlist): add translation for en 2023-04-27 22:46:46 +07:00
Yalagin
5f1c10d50a feat(add watchlist): adding midding functionality from overserr
feat(add watchlist): adding missing functionality from overserr
2023-04-27 22:27:23 +07:00
Fallenbagel
53f6a890b9 Merge pull request #356 from GeoffreyCoulaud/geoffrey-fix
Fixed french locale: using "de " instead of "d'" before Jellyseerr
2023-03-25 16:43:51 +05:00
GeoffreyCoulaud
7dbe6f61d0 Fixed d' before Jellyseerr 2023-03-25 00:49:59 +01:00
Fallenbagel
fd460df243 Merge pull request #348 from Fallenbagel/temp-disable-media-availability
fix: disable availability sync temporarily
2023-03-18 04:26:14 +05:00
Fallenbagel
2e5cf22626 fix: disable availability sync temporarily
This PR disables availability sync temporarily as the current one does not have jellyfin/emby sync
ported into it. This would ensure that jellyseerr does not bug out and start removing availability
from media despite it being available on jellyfin/emby/*arr as well as their requests.
2023-03-18 04:08:54 +05:00
Fallenbagel
092d639dd9 Merge pull request #347 from Fallenbagel/temp-fix-display-specials-within-season
fix(jellyfin sync): temporary workaround fix for jellyfin scan when display specials within season
2023-03-18 03:43:06 +05:00
Fallenbagel
fc1f3202e8 Merge pull request #338 from Fallenbagel/fix-4k-detection-series
fixes 4k detection of series
2023-03-18 03:42:48 +05:00
Fallenbagel
38fb66d31e fix(jellyfin scan): temporary workaround fix for jellyfin scan when display specials within season
Currently when display specials within season is enabled, it increases the indexed episode count of
the season. This is a problem due to the way our jellyfin sync works as it requires total standard
episodes to be equal to season episode count for the `AVAILABLE` badge for that season. However,
when the display specials within season is enabled, the scan sets that season as `PARTIALLY
AVAILABLE`. This workaround fixes this behaviour. In addition, this fix **might** also fix the
recurring availability notifications (recurring notifications might be occurring due to the scan
initially setting the season as available, thus the series is set as available and sends the
notification, but then it sets the season as partially available due to the aforementioned sync flow
until next scan and repeats.)

fix #215 #176 #246
2023-03-17 04:19:16 +05:00
Fallenbagel
8b3801539e Merge pull request #321 from andrey4korop/Lang_changes
Update Ukrainian language
2023-03-09 03:43:13 +05:00
Fallenbagel
101ffae641 style: ran prettier on ua locale 2023-03-04 01:17:26 +05:00
Fallenbagel
b90dedfafc fix merge conflict lines which were present during last merge 2023-03-02 19:32:34 +05:00
andrey
862cd2d6ac Update Ukrainian language 2023-02-02 19:04:29 +02:00
35 changed files with 1657 additions and 982 deletions

View File

@@ -2,24 +2,9 @@
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
</p>
<p align="center">
<<<<<<< HEAD
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
=======
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20Release/badge.svg?branch=master" alt="Overseerr Release" />
<img src="https://github.com/sct/overseerr/workflows/Overseerr%20CI/badge.svg" alt="Overseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-88-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
>>>>>>> upstream/develop
</p>
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
@@ -154,10 +139,7 @@ Our [Code of Conduct](https://github.com/fallenbagel/jellyseerr/blob/develop/COD
## Contributing
<<<<<<< HEAD
You can help improve Jellyseerr too! Check out our [Contribution Guide](https://github.com/fallenbagel/jellyseerr/blob/develop/CONTRIBUTING.md) to get started.
=======
You can help improve Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨

View File

@@ -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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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<Media[]> {
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,

View File

@@ -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;

157
server/entity/Watchlist.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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(),
});

View File

@@ -311,13 +311,13 @@ class JobJellyfinSync {
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard === season.episode_count
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
this.enable4kShow && total4k === season.episode_count
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
@@ -329,13 +329,13 @@ class JobJellyfinSync {
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard === season.episode_count
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k === season.episode_count
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE

View File

@@ -1,5 +1,4 @@
import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
@@ -154,7 +153,7 @@ export const startJobs = (): void => {
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
/* scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
@@ -169,6 +168,7 @@ export const startJobs = (): void => {
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
*/
// Run download sync every minute
scheduledJobs.push({

View File

@@ -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)
);

View File

@@ -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"`);
}
}

View File

@@ -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()
// },
});

View File

@@ -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)
);

View File

@@ -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<Record<string, unknown>, 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({

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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)
);

View File

@@ -34,6 +34,7 @@ searchRoutes.get('/', async (req, res, next) => {
}
const media = await Media.getRelatedMedia(
req.user,
results.results.map((result) => result.id)
);

View File

@@ -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)
);

View File

@@ -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,

View File

@@ -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<never, Watchlist, Watchlist>(
'/',
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;

View File

@@ -338,6 +338,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<TitleCard
key={`collection-movie-${title.id}`}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}

View File

@@ -57,7 +57,9 @@ const ListView = ({
case 'movie':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
@@ -75,7 +77,9 @@ const ListView = ({
case 'tv':
titleCard = (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}

View File

@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discoverwatchlist: 'Your Plex Watchlist',
discoverwatchlist: 'Your Watchlist',
watchlist: 'Plex Watchlist',
});

View File

@@ -1,6 +1,6 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
@@ -8,7 +8,7 @@ import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
plexwatchlist: 'Your Plex Watchlist',
plexwatchlist: 'Your Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> 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}
/>
))}
/>

View File

@@ -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',

View File

@@ -95,7 +95,9 @@ const MediaSlider = ({
case 'movie':
return (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
@@ -109,7 +111,9 @@ const MediaSlider = ({
case 'tv':
return (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}

View File

@@ -62,7 +62,7 @@ const messages = defineMessages({
'Get notified when issues are reopened by other users.',
mediaautorequested: 'Request Automatically Submitted',
mediaautorequestedDescription:
'Get notified when new media requests are automatically submitted for items on your Plex Watchlist.',
'Get notified when new media requests are automatically submitted for items on Your Watchlist.',
});
export const hasNotificationType = (

View File

@@ -133,6 +133,7 @@ const PersonDetails = () => {
return (
<li key={`list-cast-item-${media.id}-${index}`}>
<TitleCard
key={media.id}
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
@@ -173,6 +174,7 @@ const PersonDetails = () => {
return (
<li key={`list-crew-item-${media.id}-${index}`}>
<TitleCard
key={media.id}
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}

View File

@@ -11,6 +11,7 @@ export interface TmdbTitleCardProps {
tvdbId?: number;
type: 'movie' | 'tv';
canExpand?: boolean;
isAddedToWatchlist?: boolean;
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -23,6 +24,7 @@ const TmdbTitleCard = ({
tvdbId,
type,
canExpand,
isAddedToWatchlist = false,
}: TmdbTitleCardProps) => {
const { hasPermission } = useUser();
@@ -56,7 +58,11 @@ const TmdbTitleCard = ({
return isMovie(title) ? (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
@@ -68,7 +74,11 @@ const TmdbTitleCard = ({
/>
) : (
<TitleCard
key={title.id}
id={title.id}
isAddedToWatchlist={
title.mediaInfo?.watchlists?.length || isAddedToWatchlist
}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}

View File

@@ -10,12 +10,21 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import {
ArrowDownTrayIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Watchlist } from '@server/entity/Watchlist';
import type { MediaType } from '@server/models/Search';
import axios from 'axios';
import Link from 'next/link';
import type React from 'react';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import { mutate } from 'swr';
interface TitleCardProps {
id: number;
@@ -28,8 +37,19 @@ interface TitleCardProps {
status?: MediaStatus;
canExpand?: boolean;
inProgress?: boolean;
isAddedToWatchlist?: number | boolean;
}
const messages = defineMessages({
addToWatchList: 'Add to watchlist',
watchlistSuccess:
'<strong>{title}</strong> added to watchlist successfully!',
watchlistDeleted:
'<strong>{title}</strong> Removed from watchlist successfully!',
watchlistCancel: 'watchlist for <strong>{title}</strong> 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<boolean>(
!isAddedToWatchlist
);
// Just to get the year from the date
if (year) {
@@ -68,6 +93,65 @@ const TitleCard = ({
[]
);
const onClickWatchlistBtn = async (): Promise<void> => {
setIsUpdating(true);
try {
const response = await axios.post<Watchlist>('/api/v1/watchlist', {
tmdbId: id,
mediaType,
title,
});
mutate('/api/v1/discover/watchlist');
if (response.data) {
addToast(
<span>
{intl.formatMessage(messages.watchlistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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<void> => {
setIsUpdating(true);
try {
const response = await axios.delete<Watchlist>('/api/v1/watchlist/' + id);
if (response.status === 204) {
addToast(
<span>
{intl.formatMessage(messages.watchlistDeleted, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ 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)}
</div>
</div>
{showDetail && (
<>
{toggleWatchlist ? (
<Button
buttonType={'ghost'}
className="z-40"
buttonSize={'sm'}
onClick={onClickWatchlistBtn}
>
<StarIcon className={'h-3 text-amber-300'} />
</Button>
) : (
<Button
className="z-40"
buttonSize={'sm'}
onClick={onClickDeleteWatchlistBtn}
>
<MinusCircleIcon className={'h-3'} />
</Button>
)}
</>
)}
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
<div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini

View File

@@ -12,7 +12,7 @@
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist",
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist",
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
@@ -25,7 +25,7 @@
"components.Discover.discovertv": "Popular Series",
"components.Discover.emptywatchlist": "Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> 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 <strong>{title}</strong> canceled.",
"components.TitleCard.watchlistDeleted": "<strong>{title}</strong> Removed from watchlist successfully!",
"components.TitleCard.watchlistError": "Something went wrong try again.",
"components.TitleCard.watchlistSuccess": "<strong>{title}</strong> added to watchlist successfully!",
"i18n.advanced": "Advanced",
"i18n.all": "All",
"i18n.approve": "Approve",

View File

@@ -125,7 +125,7 @@
"components.Layout.UserDropdown.signout": "Se déconnecter",
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} en retard",
"components.Layout.VersionStatus.outofdate": "Obsolète",
"components.Layout.VersionStatus.streamdevelop": "Développement d'Jellyseerr",
"components.Layout.VersionStatus.streamdevelop": "Développement de Jellyseerr",
"components.Layout.VersionStatus.streamstable": "Jellyseerr stable",
"components.Login.email": "Adresse e-mail",
"components.Login.forgotpassword": "Mot de passe oublié ?",
@@ -624,7 +624,7 @@
"components.Settings.SettingsAbout.outofdate": "Obsolète",
"components.Settings.SettingsAbout.overseerrinformation": "À propos de Jellyseerr",
"components.Settings.SettingsAbout.preferredmethod": "Préféré",
"components.Settings.SettingsAbout.runningDevelop": "Vous utilisez la branche <code>develop</code> d'Jellyseerr, qui n'est recommandée que pour ceux qui contribuent au développement ou qui aident aux tests et correctifs.",
"components.Settings.SettingsAbout.runningDevelop": "Vous utilisez la branche <code>develop</code> de Jellyseerr, qui n'est recommandée que pour ceux qui contribuent au développement ou qui aident aux tests et correctifs.",
"components.Settings.SettingsAbout.supportoverseerr": "Soutenez Jellyseerr",
"components.Settings.SettingsAbout.timezone": "Fuseau horaire",
"components.Settings.SettingsAbout.totalmedia": "Total des médias",

File diff suppressed because it is too large Load Diff