feat: plex watchlist sync integration (#2885)
parent
7943e0c339
commit
301f2bf7ab
@ -0,0 +1,74 @@
|
|||||||
|
const visitUserEditPage = (email: string): void => {
|
||||||
|
cy.visit('/users');
|
||||||
|
|
||||||
|
cy.contains('[data-testid=user-list-row]', email).contains('Edit').click();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Auto Request Settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not see watchlist sync settings on an account without permissions', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('not.exist');
|
||||||
|
cy.contains('Auto-Request Series').should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see watchlist sync settings on an admin account', () => {
|
||||||
|
visitUserEditPage(Cypress.env('ADMIN_EMAIL'));
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should see auto-request settings after being given permission', () => {
|
||||||
|
visitUserEditPage(Cypress.env('USER_EMAIL'));
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userPermissions');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked');
|
||||||
|
cy.get('#autorequestmovies').should('be.checked');
|
||||||
|
cy.get('#autorequesttv').should('be.checked');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('General').click();
|
||||||
|
|
||||||
|
cy.contains('Auto-Request Movies').should('exist');
|
||||||
|
cy.contains('Auto-Request Series').should('exist');
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('not.be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('not.be.checked').click();
|
||||||
|
|
||||||
|
cy.intercept('/api/v1/user/*/settings/main').as('userMain');
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get('#watchlistSyncMovies').should('be.checked').click();
|
||||||
|
cy.get('#watchlistSyncTv').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
|
||||||
|
cy.wait('@userMain');
|
||||||
|
|
||||||
|
cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click();
|
||||||
|
|
||||||
|
cy.get('#autorequest').should('be.checked').click();
|
||||||
|
|
||||||
|
cy.contains('Save Changes').click();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"totalPages": 1,
|
||||||
|
"totalResults": 20,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"ratingKey": "5d776be17a53e9001e732ab9",
|
||||||
|
"title": "Top Gun: Maverick",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 361743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5e16338fbc1372003ea68ab3",
|
||||||
|
"title": "Nope",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 762504
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ratingKey": "5f409b8452f200004161e126",
|
||||||
|
"title": "Hocus Pocus 2",
|
||||||
|
"mediaType": "movie",
|
||||||
|
"tmdbId": 642885
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,6 +1,21 @@
|
|||||||
import type { PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
import type { MediaRequest } from '../../entity/MediaRequest';
|
import type { MediaRequest } from '../../entity/MediaRequest';
|
||||||
|
import type { MediaType } from '../../constants/media';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: MediaRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaRequestBody = {
|
||||||
|
mediaType: MediaType;
|
||||||
|
mediaId: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
seasons?: number[] | 'all';
|
||||||
|
is4k?: boolean;
|
||||||
|
serverId?: number;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
languageProfileId?: number;
|
||||||
|
userId?: number;
|
||||||
|
tags?: number[];
|
||||||
|
};
|
||||||
|
@ -0,0 +1,165 @@
|
|||||||
|
import { Not } from 'typeorm';
|
||||||
|
import PlexTvAPI from '../api/plextv';
|
||||||
|
import { User } from '../entity/User';
|
||||||
|
import Media from '../entity/Media';
|
||||||
|
import logger from '../logger';
|
||||||
|
import { MediaType } from '../constants/media';
|
||||||
|
import { MediaStatus } from '../constants/media';
|
||||||
|
import {
|
||||||
|
DuplicateMediaRequestError,
|
||||||
|
MediaRequest,
|
||||||
|
NoSeasonsAvailableError,
|
||||||
|
QuotaRestrictedError,
|
||||||
|
RequestPermissionError,
|
||||||
|
} from '../entity/MediaRequest';
|
||||||
|
import { Permission } from './permissions';
|
||||||
|
import { getRepository } from '../datasource';
|
||||||
|
|
||||||
|
class WatchlistSync {
|
||||||
|
public async syncWatchlist() {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
// Get users who actually have plex tokens
|
||||||
|
const users = await userRepository.find({
|
||||||
|
select: { id: true, plexToken: true, permissions: true },
|
||||||
|
where: {
|
||||||
|
plexToken: Not(''),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await this.syncUserWatchlist(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncUserWatchlist(user: User) {
|
||||||
|
if (!user.plexToken) {
|
||||||
|
logger.warn('Skipping user watchlist sync for user without plex token', {
|
||||||
|
label: 'Plex Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user.hasPermission(
|
||||||
|
[
|
||||||
|
Permission.AUTO_REQUEST,
|
||||||
|
Permission.AUTO_REQUEST_MOVIE,
|
||||||
|
Permission.AUTO_APPROVE_TV,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user.settings?.watchlistSyncMovies &&
|
||||||
|
!user.settings?.watchlistSyncTv
|
||||||
|
) {
|
||||||
|
// Skip sync if user settings have it disabled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plexTvApi = new PlexTvAPI(user.plexToken);
|
||||||
|
|
||||||
|
const response = await plexTvApi.getWatchlist({ size: 200 });
|
||||||
|
|
||||||
|
const mediaItems = await Media.getRelatedMedia(
|
||||||
|
response.items.map((i) => i.tmdbId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const unavailableItems = response.items.filter(
|
||||||
|
// If we can find watchlist items in our database that are also available, we should exclude them
|
||||||
|
(i) =>
|
||||||
|
!mediaItems.find(
|
||||||
|
(m) =>
|
||||||
|
m.tmdbId === i.tmdbId &&
|
||||||
|
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
|
||||||
|
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
unavailableItems.map(async (mediaItem) => {
|
||||||
|
try {
|
||||||
|
logger.info("Creating media request from user's Plex Watchlist", {
|
||||||
|
label: 'Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
mediaTitle: mediaItem.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
|
||||||
|
throw new Error('Missing TVDB ID from Plex Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if they have auto-request permissons and watchlist sync
|
||||||
|
// enabled for the media type
|
||||||
|
if (
|
||||||
|
((!user.hasPermission(
|
||||||
|
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
|
||||||
|
{ type: 'or' }
|
||||||
|
) ||
|
||||||
|
!user.settings?.watchlistSyncMovies) &&
|
||||||
|
mediaItem.type === 'movie') ||
|
||||||
|
((!user.hasPermission(
|
||||||
|
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
|
||||||
|
{ type: 'or' }
|
||||||
|
) ||
|
||||||
|
!user.settings?.watchlistSyncTv) &&
|
||||||
|
mediaItem.type === 'show')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MediaRequest.request(
|
||||||
|
{
|
||||||
|
mediaId: mediaItem.tmdbId,
|
||||||
|
mediaType:
|
||||||
|
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
|
||||||
|
seasons: mediaItem.type === 'show' ? 'all' : undefined,
|
||||||
|
tvdbId: mediaItem.tvdbId,
|
||||||
|
is4k: false,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
{ isAutoRequest: true }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.constructor) {
|
||||||
|
// During watchlist sync, these errors aren't necessarily
|
||||||
|
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
||||||
|
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||||
|
// instead log these as debug messages.
|
||||||
|
case RequestPermissionError:
|
||||||
|
case DuplicateMediaRequestError:
|
||||||
|
case QuotaRestrictedError:
|
||||||
|
case NoSeasonsAvailableError:
|
||||||
|
logger.debug('Failed to create media request from watchlist', {
|
||||||
|
label: 'Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
mediaTitle: mediaItem.title,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.error('Failed to create media request from watchlist', {
|
||||||
|
label: 'Watchlist Sync',
|
||||||
|
userId: user.id,
|
||||||
|
mediaTitle: mediaItem.title,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchlistSync = new WatchlistSync();
|
||||||
|
|
||||||
|
export default watchlistSync;
|
@ -0,0 +1,33 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddWatchlistSyncUserSetting1660632269368
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddWatchlistSyncUserSetting1660632269368';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddMediaRequestIsAutoRequestedField1660714479373
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
import ListView from '../../Common/ListView';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import Header from '../../Common/Header';
|
||||||
|
import PageTitle from '../../Common/PageTitle';
|
||||||
|
import useDiscover from '../../../hooks/useDiscover';
|
||||||
|
import Error from '../../../pages/_error';
|
||||||
|
import type { WatchlistItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
discoverwatchlist: 'Your Plex Watchlist',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DiscoverWatchlist = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingInitialData,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
titles,
|
||||||
|
fetchMore,
|
||||||
|
error,
|
||||||
|
} = useDiscover<WatchlistItem>('/api/v1/discover/watchlist');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Error statusCode={500} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = intl.formatMessage(messages.discoverwatchlist);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={title} />
|
||||||
|
<div className="mt-1 mb-5">
|
||||||
|
<Header>{title}</Header>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
plexItems={titles}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoading={
|
||||||
|
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
onScrollBottom={fetchMore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverWatchlist;
|
@ -0,0 +1,8 @@
|
|||||||
|
import type { NextPage } from 'next';
|
||||||
|
import DiscoverWatchlist from '../../components/Discover/DiscoverWatchlist';
|
||||||
|
|
||||||
|
const WatchlistPage: NextPage = () => {
|
||||||
|
return <DiscoverWatchlist />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WatchlistPage;
|
Loading…
Reference in new issue