mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(api): plex tv sync and recently added sync
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ config/logs/*.log
|
||||
|
||||
# dist files
|
||||
dist
|
||||
|
||||
# sqlite journal
|
||||
config/db/db.sqlite3-journal
|
||||
|
@@ -1155,6 +1155,29 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PublicSettings'
|
||||
/settings/jobs:
|
||||
get:
|
||||
summary: Returns list of scheduled jobs
|
||||
description: Returns list of all scheduled jobs and details about their next execution time
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Scheduled jobs returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: A Job Name
|
||||
nextExecutionTime:
|
||||
type: string
|
||||
example: '2020-09-02T05:02:23.000Z'
|
||||
|
||||
/auth/me:
|
||||
get:
|
||||
summary: Returns the currently logged in user
|
||||
|
@@ -3,9 +3,11 @@ import { getSettings } from '../lib/settings';
|
||||
|
||||
export interface PlexLibraryItem {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
title: string;
|
||||
guid: string;
|
||||
type: 'movie' | 'show';
|
||||
parentGuid?: string;
|
||||
type: 'movie' | 'show' | 'season';
|
||||
}
|
||||
|
||||
interface PlexLibraryResponse {
|
||||
@@ -28,12 +30,21 @@ interface PlexLibrariesResponse {
|
||||
|
||||
export interface PlexMetadata {
|
||||
ratingKey: string;
|
||||
parentRatingKey?: string;
|
||||
guid: string;
|
||||
type: 'movie' | 'show';
|
||||
type: 'movie' | 'show' | 'season';
|
||||
title: string;
|
||||
Guid: {
|
||||
id: string;
|
||||
}[];
|
||||
Children?: {
|
||||
size: 12;
|
||||
Metadata: PlexMetadata[];
|
||||
};
|
||||
index: number;
|
||||
parentIndex?: number;
|
||||
leafCount: number;
|
||||
viewedLeafCount: number;
|
||||
}
|
||||
|
||||
interface PlexMetadataResponse {
|
||||
@@ -63,6 +74,9 @@ class PlexAPI {
|
||||
cb(undefined, plexToken);
|
||||
},
|
||||
},
|
||||
// requestOptions: {
|
||||
// includeChildren: 1,
|
||||
// },
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Overseerr',
|
||||
@@ -92,18 +106,25 @@ class PlexAPI {
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
|
||||
public async getMetadata(key: string): Promise<PlexMetadata> {
|
||||
public async getMetadata(
|
||||
key: string,
|
||||
options: { includeChildren?: boolean } = {}
|
||||
): Promise<PlexMetadata> {
|
||||
const response = await this.plexClient.query<PlexMetadataResponse>(
|
||||
`/library/metadata/${key}`
|
||||
`/library/metadata/${key}${
|
||||
options.includeChildren ? '?includeChildren=1' : ''
|
||||
}`
|
||||
);
|
||||
|
||||
return response.MediaContainer.Metadata[0];
|
||||
}
|
||||
|
||||
public async getRecentlyAdded() {
|
||||
const response = await this.plexClient.query('/library/recentlyAdded');
|
||||
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
|
||||
const response = await this.plexClient.query<PlexLibraryResponse>(
|
||||
'/library/recentlyAdded'
|
||||
);
|
||||
|
||||
return response;
|
||||
return response.MediaContainer.Metadata;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -649,6 +649,38 @@ class TheMovieDb {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getShowByTvdbId({
|
||||
tvdbId,
|
||||
language = 'en-US',
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const extResponse = await this.getByExternalId({
|
||||
externalId: tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
|
||||
if (extResponse.tv_results[0]) {
|
||||
const tvshow = await this.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
language,
|
||||
});
|
||||
|
||||
return tvshow;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to get tv show by external tvdb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TheMovieDb;
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -79,6 +80,12 @@ class Media {
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@OneToMany(() => Season, (season) => season.media, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
})
|
||||
public seasons: Season[];
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
|
37
server/entity/Season.ts
Normal file
37
server/entity/Season.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
|
||||
@Entity()
|
||||
class Season {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column()
|
||||
public seasonNumber: number;
|
||||
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.seasons)
|
||||
public media: Media;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(init?: Partial<Season>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
export default Season;
|
@@ -1,16 +1,19 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem } from '../api/plexapi';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import Media from '../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import { getSettings, Library } from '../lib/settings';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
|
||||
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
|
||||
const BUNDLE_SIZE = 10;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
|
||||
interface SyncStatus {
|
||||
@@ -29,9 +32,11 @@ class JobPlexSync {
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private running = false;
|
||||
private isRecentOnly = false;
|
||||
|
||||
constructor() {
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number) {
|
||||
@@ -107,11 +112,116 @@ class JobPlexSync {
|
||||
}
|
||||
}
|
||||
|
||||
private async processShow(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
try {
|
||||
const metadata = await this.plexClient.getMetadata(
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey,
|
||||
{ includeChildren: true }
|
||||
);
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
const matchedtvdb = metadata.guid.match(tvdbRegex);
|
||||
|
||||
// If we can find a tvdb Id, use it to get the full tmdb show details
|
||||
if (matchedtvdb?.[1]) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
}
|
||||
} else if (metadata.guid.match(tmdbShowRegex)) {
|
||||
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
|
||||
|
||||
if (matchedtmdb?.[1]) {
|
||||
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow && metadata) {
|
||||
// Lets get the available seasons from plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: tvShow.id, mediaType: MediaType.TV },
|
||||
});
|
||||
|
||||
const availableSeasons: Season[] = [];
|
||||
|
||||
seasons.forEach((season) => {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (
|
||||
matchedPlexSeason &&
|
||||
Number(matchedPlexSeason.leafCount) === season.episode_count
|
||||
) {
|
||||
availableSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
status: MediaStatus.AVAILABLE,
|
||||
})
|
||||
);
|
||||
} else if (matchedPlexSeason) {
|
||||
availableSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
status: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
|
||||
const isAllSeasons = availableSeasons.length >= filteredSeasons.length;
|
||||
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = availableSeasons;
|
||||
media.status = isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PARTIALLY_AVAILABLE;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${tvShow.name}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: availableSeasons,
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
status: isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PARTIALLY_AVAILABLE,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
} else {
|
||||
this.log(`failed show: ${plexitem.guid}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process plex item. ratingKey: ${
|
||||
plexitem.parentRatingKey ?? plexitem.ratingKey
|
||||
}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: PlexLibraryItem[]) {
|
||||
await Promise.all(
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (plexitem.type === 'show') {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -159,6 +269,16 @@ class JobPlexSync {
|
||||
});
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
if (this.isRecentOnly) {
|
||||
this.currentLibrary = {
|
||||
id: '0',
|
||||
name: 'Recently Added',
|
||||
enabled: true,
|
||||
};
|
||||
this.log(`Beginning to process recently added`, 'info');
|
||||
this.items = await this.plexClient.getRecentlyAdded();
|
||||
await this.loop();
|
||||
} else {
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
@@ -169,6 +289,7 @@ class JobPlexSync {
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop();
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
this.log('complete');
|
||||
}
|
||||
@@ -189,6 +310,5 @@ class JobPlexSync {
|
||||
}
|
||||
}
|
||||
|
||||
const jobPlexSync = new JobPlexSync();
|
||||
|
||||
export default jobPlexSync;
|
||||
export const jobPlexFullSync = new JobPlexSync();
|
||||
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });
|
@@ -1,14 +1,32 @@
|
||||
import schedule from 'node-schedule';
|
||||
import jobPlexSync from './plexsync';
|
||||
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
|
||||
import logger from '../logger';
|
||||
|
||||
export const scheduledJobs: Record<string, schedule.Job> = {};
|
||||
interface ScheduledJob {
|
||||
job: schedule.Job;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const scheduledJobs: ScheduledJob[] = [];
|
||||
|
||||
export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Recently Added Sync',
|
||||
job: schedule.scheduleJob('0 */10 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
jobPlexRecentSync.run();
|
||||
}),
|
||||
});
|
||||
// Run full plex sync every 6 hours
|
||||
scheduledJobs.plexFullSync = schedule.scheduleJob('* */6 * * *', () => {
|
||||
scheduledJobs.push({
|
||||
name: 'Plex Full Library Sync',
|
||||
job: schedule.scheduleJob('* * */6 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexSync.run();
|
||||
jobPlexFullSync.run();
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
|
@@ -8,10 +8,11 @@ import {
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import PlexAPI, { PlexLibrary } from '../api/plexapi';
|
||||
import jobPlexSync from '../job/plexsync';
|
||||
import { jobPlexFullSync } from '../job/plexsync';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import logger from '../logger';
|
||||
import { scheduledJobs } from '../job/schedule';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
@@ -108,12 +109,12 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
|
||||
settingsRoutes.get('/plex/sync', (req, res) => {
|
||||
if (req.query.cancel) {
|
||||
jobPlexSync.cancel();
|
||||
jobPlexFullSync.cancel();
|
||||
} else if (req.query.start) {
|
||||
jobPlexSync.run();
|
||||
jobPlexFullSync.run();
|
||||
}
|
||||
|
||||
return res.status(200).json(jobPlexSync.status());
|
||||
return res.status(200).json(jobPlexFullSync.status());
|
||||
});
|
||||
|
||||
settingsRoutes.get('/radarr', (req, res) => {
|
||||
@@ -324,4 +325,13 @@ settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
|
||||
return res.status(200).json(removed[0]);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/jobs', (req, res) => {
|
||||
return res.status(200).json(
|
||||
scheduledJobs.map((job) => ({
|
||||
name: job.name,
|
||||
nextExecutionTime: job.job.nextInvocation(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
export default settingsRoutes;
|
||||
|
1
server/types/plex-api.d.ts
vendored
1
server/types/plex-api.d.ts
vendored
@@ -16,6 +16,7 @@ declare module 'plex-api' {
|
||||
deviceName: string;
|
||||
platform: string;
|
||||
};
|
||||
requestOptions?: Record<string, string | number>;
|
||||
});
|
||||
|
||||
query: <T extends Record<string, any>>(endpoint: string) => Promise<T>;
|
||||
|
@@ -50,20 +50,6 @@ const UserDropdown: React.FC = () => {
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition ease-in-out duration-150"
|
||||
role="menuitem"
|
||||
>
|
||||
Your Profile
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition ease-in-out duration-150"
|
||||
role="menuitem"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition ease-in-out duration-150"
|
||||
|
@@ -71,18 +71,32 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getAllRequestedSeasons = (): number[] =>
|
||||
(data?.mediaInfo?.requests ?? []).reduce((requestedSeasons, request) => {
|
||||
const getAllRequestedSeasons = (): number[] => {
|
||||
const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce(
|
||||
(requestedSeasons, request) => {
|
||||
return [
|
||||
...requestedSeasons,
|
||||
...request.seasons.map((sr) => sr.seasonNumber),
|
||||
];
|
||||
}, [] as number[]);
|
||||
},
|
||||
[] as number[]
|
||||
);
|
||||
|
||||
const isSelectedSeason = (seasonNumber: number): boolean => {
|
||||
return selectedSeasons.includes(seasonNumber);
|
||||
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
|
||||
.filter(
|
||||
(season) =>
|
||||
(season.status === MediaStatus.AVAILABLE ||
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE) &&
|
||||
!requestedSeasons.includes(season.seasonNumber)
|
||||
)
|
||||
.map((season) => season.seasonNumber);
|
||||
|
||||
return [...requestedSeasons, ...availableSeasons];
|
||||
};
|
||||
|
||||
const isSelectedSeason = (seasonNumber: number): boolean =>
|
||||
selectedSeasons.includes(seasonNumber);
|
||||
|
||||
const toggleSeason = (seasonNumber: number): void => {
|
||||
// If this season already has a pending request, don't allow it to be toggled
|
||||
if (getAllRequestedSeasons().includes(seasonNumber)) {
|
||||
@@ -241,6 +255,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
const seasonRequest = getSeasonRequest(
|
||||
season.seasonNumber
|
||||
);
|
||||
const mediaSeason = data?.mediaInfo?.seasons.find(
|
||||
(sn) => sn.seasonNumber === season.seasonNumber
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
<td className="px-4 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100">
|
||||
@@ -248,6 +265,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
}
|
||||
@@ -258,12 +276,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
}}
|
||||
className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
seasonRequest ? 'opacity-50' : ''
|
||||
mediaSeason || seasonRequest ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'bg-indigo-500'
|
||||
@@ -273,6 +292,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!mediaSeason ||
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'translate-x-5'
|
||||
@@ -290,19 +310,27 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
{season.episodeCount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
|
||||
{!seasonRequest && <Badge>Not Requested</Badge>}
|
||||
{seasonRequest?.status ===
|
||||
{!seasonRequest && !mediaSeason && (
|
||||
<Badge>Not Requested</Badge>
|
||||
)}
|
||||
{!mediaSeason &&
|
||||
seasonRequest?.status ===
|
||||
MediaRequestStatus.PENDING && (
|
||||
<Badge badgeType="warning">Pending</Badge>
|
||||
)}
|
||||
{seasonRequest?.status ===
|
||||
{!mediaSeason &&
|
||||
seasonRequest?.status ===
|
||||
MediaRequestStatus.APPROVED && (
|
||||
<Badge badgeType="danger">Unavailable</Badge>
|
||||
)}
|
||||
{seasonRequest?.status ===
|
||||
{!mediaSeason &&
|
||||
seasonRequest?.status ===
|
||||
MediaRequestStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">Available</Badge>
|
||||
)}
|
||||
{mediaSeason?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">Available</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
@@ -18,7 +18,6 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
{name}
|
||||
</div>
|
||||
<div className="flex-shrink-0 pr-2">
|
||||
{/* <!-- On: "bg-indigo-600", Off: "bg-gray-200" --> */}
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
@@ -33,14 +32,12 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
isEnabled ? 'bg-indigo-600' : 'bg-cool-gray-700'
|
||||
} relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:shadow-outline`}
|
||||
>
|
||||
{/* <!-- On: "translate-x-5", Off: "translate-x-0" --> */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
} relative inline-block h-5 w-5 rounded-full bg-white shadow transform transition ease-in-out duration-200`}
|
||||
>
|
||||
{/* <!-- On: "opacity-0 ease-out duration-100", Off: "opacity-100 ease-in duration-200" --> */}
|
||||
<span
|
||||
className={`${
|
||||
isEnabled
|
||||
@@ -62,7 +59,6 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/* <!-- On: "opacity-100 ease-in duration-200", Off: "opacity-0 ease-out duration-100" --> */}
|
||||
<span
|
||||
className={`${
|
||||
isEnabled
|
||||
|
70
src/components/Settings/SettingsJobs.tsx
Normal file
70
src/components/Settings/SettingsJobs.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import Badge from '../Common/Badge';
|
||||
import { FormattedDate, FormattedRelativeTime } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
|
||||
const SettingsJobs: React.FC = () => {
|
||||
const { data, error } = useSWR<{ name: string; nextExecutionTime: string }[]>(
|
||||
'/api/v1/settings/jobs'
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2 overflow-x-auto -mx-6 sm:-mx-6 md:mx-4 lg:mx-4">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow overflow-hidden sm:rounded-lg">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 bg-cool-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
|
||||
Job Name
|
||||
</th>
|
||||
<th className="px-6 py-3 bg-cool-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
|
||||
Next Execution
|
||||
</th>
|
||||
<th className="px-6 py-3 bg-cool-gray-500"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-cool-gray-600 divide-y divide-cool-gray-700">
|
||||
{data?.map((job, index) => (
|
||||
<tr key={`job-list-${index}`}>
|
||||
<td className="px-6 py-4 whitespace-no-wrap">
|
||||
<div className="text-sm leading-5 text-white">
|
||||
{job.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap">
|
||||
<div className="text-sm leading-5 text-white">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(job.nextExecutionTime).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-right text-sm leading-5 font-medium">
|
||||
<Button buttonType="primary">Run Now</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsJobs;
|
@@ -7,8 +7,10 @@ import { FormattedDate } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const UserList: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<User[]>('/api/v1/user');
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -94,7 +96,13 @@ const UserList: React.FC = () => {
|
||||
<FormattedDate value={user.updatedAt} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-right text-sm leading-5 font-medium">
|
||||
<Button buttonType="warning" className="mr-2">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
className="mr-2"
|
||||
onClick={() =>
|
||||
router.push('/users/[userId]', `/users/${user.id}`)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button buttonType="danger">Delete</Button>
|
||||
|
96
src/components/UserProfile/index.tsx
Normal file
96
src/components/UserProfile/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { User } from '../../../server/entity/User';
|
||||
|
||||
const UserProfile: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<User>(`/api/v1/user/${router.query.userId}`);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 px-4 space-y-6 sm:p-6 lg:pb-8">
|
||||
<div className="md:flex md:items-center md:justify-between mt-8 mb-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold leading-7 text-cool-gray-100 sm:text-3xl sm:leading-9 sm:truncate">
|
||||
User Profile
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6 lg:flex-row lg:space-y-0 lg:space-x-6 text-white">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="username"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={data?.username}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium leading-5 text-cool-gray-400"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="rounded-md shadow-sm flex">
|
||||
<input
|
||||
id="email"
|
||||
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-cool-gray-700 border border-cool-gray-500"
|
||||
value={data?.email}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow space-y-1 lg:flex-grow-0 lg:flex-shrink-0">
|
||||
<p
|
||||
className="block text-sm leading-5 font-medium text-cool-gray-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Avatar
|
||||
</p>
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
className="rounded-full h-full w-full"
|
||||
src={data?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden relative rounded-full overflow-hidden lg:block transition duration-150 ease-in-out">
|
||||
<img
|
||||
className="relative rounded-full w-40 h-40"
|
||||
src={data?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
@@ -32,11 +32,10 @@ export const useUser = ({
|
||||
id,
|
||||
initialData,
|
||||
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
||||
const initialRef = useRef(initialData);
|
||||
const { data, error, revalidate } = useSwr<User>(
|
||||
id ? `/api/v1/user/${id}` : `/api/v1/auth/me`,
|
||||
{
|
||||
initialData: initialRef.current,
|
||||
initialData,
|
||||
refreshInterval: 30000,
|
||||
errorRetryInterval: 30000,
|
||||
shouldRetryOnError: false,
|
||||
|
14
src/pages/settings/jobs.tsx
Normal file
14
src/pages/settings/jobs.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import SettingsLayout from '../../components/Settings/SettingsLayout';
|
||||
import SettingsJobs from '../../components/Settings/SettingsJobs';
|
||||
|
||||
const SettingsMainPage: NextPage = () => {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsJobs />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMainPage;
|
9
src/pages/users/[userId].tsx
Normal file
9
src/pages/users/[userId].tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { NextPage } from 'next';
|
||||
import UserProfile from '../../components/UserProfile';
|
||||
|
||||
const UserProfilePage: NextPage = () => {
|
||||
return <UserProfile />;
|
||||
};
|
||||
|
||||
export default UserProfilePage;
|
Reference in New Issue
Block a user