feat(api): plex tv sync and recently added sync

pull/42/head
sct 4 years ago
parent 16221a46a7
commit 1390cc1f13

3
.gitignore vendored

@ -40,3 +40,6 @@ config/logs/*.log
# dist files # dist files
dist dist
# sqlite journal
config/db/db.sqlite3-journal

@ -1155,6 +1155,29 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PublicSettings' $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: /auth/me:
get: get:
summary: Returns the currently logged in user summary: Returns the currently logged in user

@ -3,9 +3,11 @@ import { getSettings } from '../lib/settings';
export interface PlexLibraryItem { export interface PlexLibraryItem {
ratingKey: string; ratingKey: string;
parentRatingKey?: string;
title: string; title: string;
guid: string; guid: string;
type: 'movie' | 'show'; parentGuid?: string;
type: 'movie' | 'show' | 'season';
} }
interface PlexLibraryResponse { interface PlexLibraryResponse {
@ -28,12 +30,21 @@ interface PlexLibrariesResponse {
export interface PlexMetadata { export interface PlexMetadata {
ratingKey: string; ratingKey: string;
parentRatingKey?: string;
guid: string; guid: string;
type: 'movie' | 'show'; type: 'movie' | 'show' | 'season';
title: string; title: string;
Guid: { Guid: {
id: string; id: string;
}[]; }[];
Children?: {
size: 12;
Metadata: PlexMetadata[];
};
index: number;
parentIndex?: number;
leafCount: number;
viewedLeafCount: number;
} }
interface PlexMetadataResponse { interface PlexMetadataResponse {
@ -63,6 +74,9 @@ class PlexAPI {
cb(undefined, plexToken); cb(undefined, plexToken);
}, },
}, },
// requestOptions: {
// includeChildren: 1,
// },
options: { options: {
identifier: settings.clientId, identifier: settings.clientId,
product: 'Overseerr', product: 'Overseerr',
@ -92,18 +106,25 @@ class PlexAPI {
return response.MediaContainer.Metadata; 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>( const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}` `/library/metadata/${key}${
options.includeChildren ? '?includeChildren=1' : ''
}`
); );
return response.MediaContainer.Metadata[0]; return response.MediaContainer.Metadata[0];
} }
public async getRecentlyAdded() { public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query('/library/recentlyAdded'); 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; export default TheMovieDb;

@ -12,6 +12,7 @@ import {
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger'; import logger from '../logger';
import Season from './Season';
@Entity() @Entity()
class Media { class Media {
@ -79,6 +80,12 @@ class Media {
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true }) @OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
public requests: MediaRequest[]; public requests: MediaRequest[];
@OneToMany(() => Season, (season) => season.media, {
cascade: true,
eager: true,
})
public seasons: Season[];
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

@ -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 { getRepository } from 'typeorm';
import { User } from '../entity/User'; import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem } from '../api/plexapi'; import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import Media from '../entity/Media'; import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../logger'; import logger from '../../logger';
import { getSettings, Library } from '../lib/settings'; import { getSettings, Library } from '../../lib/settings';
import Season from '../../entity/Season';
const BUNDLE_SIZE = 10; const BUNDLE_SIZE = 10;
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([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:\/\//); const plexRegex = new RegExp(/plex:\/\//);
interface SyncStatus { interface SyncStatus {
@ -29,9 +32,11 @@ class JobPlexSync {
private libraries: Library[]; private libraries: Library[];
private currentLibrary: Library; private currentLibrary: Library;
private running = false; private running = false;
private isRecentOnly = false;
constructor() { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb(); this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
} }
private async getExisting(tmdbId: number) { 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[]) { private async processItems(slicedItems: PlexLibraryItem[]) {
await Promise.all( await Promise.all(
slicedItems.map(async (plexitem) => { slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') { if (plexitem.type === 'movie') {
await this.processMovie(plexitem); 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 }); 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( this.libraries = settings.plex.libraries.filter(
(library) => library.enabled (library) => library.enabled
); );
@ -169,6 +289,7 @@ class JobPlexSync {
this.items = await this.plexClient.getLibraryContents(library.id); this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop(); await this.loop();
} }
}
this.running = false; this.running = false;
this.log('complete'); this.log('complete');
} }
@ -189,6 +310,5 @@ class JobPlexSync {
} }
} }
const jobPlexSync = new JobPlexSync(); export const jobPlexFullSync = new JobPlexSync();
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });
export default jobPlexSync;

@ -1,14 +1,32 @@
import schedule from 'node-schedule'; import schedule from 'node-schedule';
import jobPlexSync from './plexsync'; import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger'; 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 => { 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 // 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' }); logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexSync.run(); jobPlexFullSync.run();
}),
}); });
logger.info('Scheduled jobs loaded', { label: 'Jobs' }); logger.info('Scheduled jobs loaded', { label: 'Jobs' });

@ -8,10 +8,11 @@ import {
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { User } from '../entity/User'; import { User } from '../entity/User';
import PlexAPI, { PlexLibrary } from '../api/plexapi'; import PlexAPI, { PlexLibrary } from '../api/plexapi';
import jobPlexSync from '../job/plexsync'; import { jobPlexFullSync } from '../job/plexsync';
import SonarrAPI from '../api/sonarr'; import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/radarr'; import RadarrAPI from '../api/radarr';
import logger from '../logger'; import logger from '../logger';
import { scheduledJobs } from '../job/schedule';
const settingsRoutes = Router(); const settingsRoutes = Router();
@ -108,12 +109,12 @@ settingsRoutes.get('/plex/library', async (req, res) => {
settingsRoutes.get('/plex/sync', (req, res) => { settingsRoutes.get('/plex/sync', (req, res) => {
if (req.query.cancel) { if (req.query.cancel) {
jobPlexSync.cancel(); jobPlexFullSync.cancel();
} else if (req.query.start) { } 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) => { settingsRoutes.get('/radarr', (req, res) => {
@ -324,4 +325,13 @@ settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
return res.status(200).json(removed[0]); 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; export default settingsRoutes;

@ -16,6 +16,7 @@ declare module 'plex-api' {
deviceName: string; deviceName: string;
platform: string; platform: string;
}; };
requestOptions?: Record<string, string | number>;
}); });
query: <T extends Record<string, any>>(endpoint: string) => Promise<T>; query: <T extends Record<string, any>>(endpoint: string) => Promise<T>;

@ -50,20 +50,6 @@ const UserDropdown: React.FC = () => {
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="user-menu" 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 <a
href="#" href="#"
className="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 transition ease-in-out duration-150" 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[] => const getAllRequestedSeasons = (): number[] => {
(data?.mediaInfo?.requests ?? []).reduce((requestedSeasons, request) => { const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce(
(requestedSeasons, request) => {
return [ return [
...requestedSeasons, ...requestedSeasons,
...request.seasons.map((sr) => sr.seasonNumber), ...request.seasons.map((sr) => sr.seasonNumber),
]; ];
}, [] as number[]); },
[] as number[]
);
const isSelectedSeason = (seasonNumber: number): boolean => { const availableSeasons = (data?.mediaInfo?.seasons ?? [])
return selectedSeasons.includes(seasonNumber); .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 => { const toggleSeason = (seasonNumber: number): void => {
// If this season already has a pending request, don't allow it to be toggled // If this season already has a pending request, don't allow it to be toggled
if (getAllRequestedSeasons().includes(seasonNumber)) { if (getAllRequestedSeasons().includes(seasonNumber)) {
@ -241,6 +255,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
const seasonRequest = getSeasonRequest( const seasonRequest = getSeasonRequest(
season.seasonNumber season.seasonNumber
); );
const mediaSeason = data?.mediaInfo?.seasons.find(
(sn) => sn.seasonNumber === season.seasonNumber
);
return ( return (
<tr key={`season-${season.id}`}> <tr key={`season-${season.id}`}>
<td className="px-4 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100"> <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" role="checkbox"
tabIndex={0} tabIndex={0}
aria-checked={ aria-checked={
!!mediaSeason ||
!!seasonRequest || !!seasonRequest ||
isSelectedSeason(season.seasonNumber) 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 ${ 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 <span
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
!!mediaSeason ||
!!seasonRequest || !!seasonRequest ||
isSelectedSeason(season.seasonNumber) isSelectedSeason(season.seasonNumber)
? 'bg-indigo-500' ? 'bg-indigo-500'
@ -273,6 +292,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
<span <span
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
!!mediaSeason ||
!!seasonRequest || !!seasonRequest ||
isSelectedSeason(season.seasonNumber) isSelectedSeason(season.seasonNumber)
? 'translate-x-5' ? 'translate-x-5'
@ -290,19 +310,27 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{season.episodeCount} {season.episodeCount}
</td> </td>
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200"> <td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
{!seasonRequest && <Badge>Not Requested</Badge>} {!seasonRequest && !mediaSeason && (
{seasonRequest?.status === <Badge>Not Requested</Badge>
)}
{!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.PENDING && ( MediaRequestStatus.PENDING && (
<Badge badgeType="warning">Pending</Badge> <Badge badgeType="warning">Pending</Badge>
)} )}
{seasonRequest?.status === {!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.APPROVED && ( MediaRequestStatus.APPROVED && (
<Badge badgeType="danger">Unavailable</Badge> <Badge badgeType="danger">Unavailable</Badge>
)} )}
{seasonRequest?.status === {!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.AVAILABLE && ( MediaRequestStatus.AVAILABLE && (
<Badge badgeType="success">Available</Badge> <Badge badgeType="success">Available</Badge>
)} )}
{mediaSeason?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">Available</Badge>
)}
</td> </td>
</tr> </tr>
); );

@ -18,7 +18,6 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
{name} {name}
</div> </div>
<div className="flex-shrink-0 pr-2"> <div className="flex-shrink-0 pr-2">
{/* <!-- On: "bg-indigo-600", Off: "bg-gray-200" --> */}
<span <span
role="checkbox" role="checkbox"
tabIndex={0} tabIndex={0}
@ -33,14 +32,12 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
isEnabled ? 'bg-indigo-600' : 'bg-cool-gray-700' 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`} } 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 <span
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0' 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`} } 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 <span
className={`${ className={`${
isEnabled isEnabled
@ -62,7 +59,6 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
/> />
</svg> </svg>
</span> </span>
{/* <!-- On: "opacity-100 ease-in duration-200", Off: "opacity-0 ease-out duration-100" --> */}
<span <span
className={`${ className={`${
isEnabled isEnabled

@ -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 Button from '../Common/Button';
import { hasPermission } from '../../../server/lib/permissions'; import { hasPermission } from '../../../server/lib/permissions';
import { Permission } from '../../hooks/useUser'; import { Permission } from '../../hooks/useUser';
import { useRouter } from 'next/router';
const UserList: React.FC = () => { const UserList: React.FC = () => {
const router = useRouter();
const { data, error } = useSWR<User[]>('/api/v1/user'); const { data, error } = useSWR<User[]>('/api/v1/user');
if (!data && !error) { if (!data && !error) {
@ -94,7 +96,13 @@ const UserList: React.FC = () => {
<FormattedDate value={user.updatedAt} /> <FormattedDate value={user.updatedAt} />
</td> </td>
<td className="px-6 py-4 whitespace-no-wrap text-right text-sm leading-5 font-medium"> <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 Edit
</Button> </Button>
<Button buttonType="danger">Delete</Button> <Button buttonType="danger">Delete</Button>

@ -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, id,
initialData, initialData,
}: { id?: number; initialData?: User } = {}): UserHookResponse => { }: { id?: number; initialData?: User } = {}): UserHookResponse => {
const initialRef = useRef(initialData);
const { data, error, revalidate } = useSwr<User>( const { data, error, revalidate } = useSwr<User>(
id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, id ? `/api/v1/user/${id}` : `/api/v1/auth/me`,
{ {
initialData: initialRef.current, initialData,
refreshInterval: 30000, refreshInterval: 30000,
errorRetryInterval: 30000, errorRetryInterval: 30000,
shouldRetryOnError: false, shouldRetryOnError: false,

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

@ -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;
Loading…
Cancel
Save