Merge branch 'develop'

pull/570/head
sct 4 years ago
commit d2668d3f49

@ -144,6 +144,15 @@
"code", "code",
"doc" "doc"
] ]
},
{
"login": "mmozeiko",
"name": "Mārtiņš Možeiko",
"avatar_url": "https://avatars3.githubusercontent.com/u/1665010?v=4",
"profile": "https://github.com/mmozeiko",
"contributions": [
"code"
]
} }
], ],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>", "badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -29,13 +29,31 @@ jobs:
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci') if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, 'skip ci')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Build and push to Docker Hub - name: Set up QEMU
uses: docker/build-push-action@v1 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_TOKEN }}
repository: sctx/overseerr - name: Login to GitHub Container Registry
build_args: COMMIT_TAG=${{ github.sha }} uses: docker/login-action@v1
tags: develop with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
sctx/overseerr:develop
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}

@ -15,5 +15,5 @@ jobs:
issue-comment: > issue-comment: >
:wave: @{issue-author}, please edit your issue and follow the template provided. :wave: @{issue-author}, please edit your issue and follow the template provided.
close-issue: false close-issue: false
lock-issue: true lock-issue: false
issue-lock-reason: 'resolved' issue-lock-reason: 'resolved'

@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a> <a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"> <img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- 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-15-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-16-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
</p> </p>
@ -118,6 +118,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
</tr> </tr>
<tr> <tr>
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td> <td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
</tr> </tr>
</table> </table>

@ -436,12 +436,7 @@ components:
spokenLanguages: spokenLanguages:
type: array type: array
items: items:
type: object $ref: '#/components/schemas/SpokenLanguage'
properties:
iso_639_1:
type: string
name:
type: string
status: status:
type: string type: string
tagline: tagline:
@ -592,6 +587,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/ProductionCompany' $ref: '#/components/schemas/ProductionCompany'
spokenLanguages:
type: array
items:
$ref: '#/components/schemas/SpokenLanguage'
seasons: seasons:
type: array type: array
items: items:
@ -617,6 +616,10 @@ components:
$ref: '#/components/schemas/Crew' $ref: '#/components/schemas/Crew'
externalIds: externalIds:
$ref: '#/components/schemas/ExternalIds' $ref: '#/components/schemas/ExternalIds'
keywords:
type: array
items:
$ref: '#/components/schemas/Keyword'
mediaInfo: mediaInfo:
$ref: '#/components/schemas/MediaInfo' $ref: '#/components/schemas/MediaInfo'
MediaRequest: MediaRequest:
@ -961,6 +964,28 @@ components:
type: string type: string
mediaInfo: mediaInfo:
$ref: '#/components/schemas/MediaInfo' $ref: '#/components/schemas/MediaInfo'
Keyword:
type: object
properties:
id:
type: number
example: 1
name:
type: string
example: 'anime'
SpokenLanguage:
type: object
properties:
englishName:
type: string
example: 'English'
nullable: true
iso_639_1:
type: string
example: 'en'
name:
type: string
example: 'English'
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey
@ -975,7 +1000,7 @@ paths:
/settings/main: /settings/main:
get: get:
summary: Returns main settings summary: Returns main settings
description: Retreives all main settings in JSON format description: Retrieves all main settings in JSON format
tags: tags:
- settings - settings
responses: responses:
@ -1003,6 +1028,19 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/MainSettings' $ref: '#/components/schemas/MainSettings'
/settings/main/regenerate:
get:
summary: Returns main settings with newly generated API Key
description: Retreives all main settings in JSON format with new API Key
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/plex: /settings/plex:
get: get:
summary: Returns plex settings summary: Returns plex settings
@ -1140,7 +1178,7 @@ paths:
/settings/radarr/test: /settings/radarr/test:
post: post:
summary: Test radarr configuration summary: Test radarr configuration
description: Test if the provided Radarr congifuration values are valid. Returns profiles and root folders on success description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success
tags: tags:
- settings - settings
requestBody: requestBody:
@ -1284,7 +1322,7 @@ paths:
/settings/sonarr/test: /settings/sonarr/test:
post: post:
summary: Test Sonarr configuration summary: Test Sonarr configuration
description: Test if the provided Sonarr congifuration values are valid. Returns profiles and root folders on success description: Test if the provided Sonarr configuration values are valid. Returns profiles and root folders on success
tags: tags:
- settings - settings
requestBody: requestBody:
@ -1387,7 +1425,7 @@ paths:
/settings/initialize: /settings/initialize:
get: get:
summary: Set the application as initialized summary: Set the application as initialized
description: Sets the app as initalized and allows the user to navigate to pages other than the setup page description: Sets the app as initialized and allows the user to navigate to pages other than the setup page
tags: tags:
- settings - settings
responses: responses:
@ -1870,6 +1908,51 @@ paths:
- $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult' - $ref: '#/components/schemas/PersonResult'
/discover/keyword/{keywordId}/movies:
get:
summary: Request list of movies from keyword
description: Returns list of movies based on provided keyword ID in JSON format
tags:
- search
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 207317
- in: query
name: page
schema:
type: number
example: 1
default: 1
- in: query
name: language
schema:
type: string
example: en
responses:
'200':
description: List of movies
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/MovieResult'
/request: /request:
get: get:
summary: Get all requests summary: Get all requests

@ -171,7 +171,8 @@
"prepareCmd": "docker build -t sctx/overseerr ." "prepareCmd": "docker build -t sctx/overseerr ."
} }
], ],
"semantic-release-docker" "semantic-release-docker",
"@semantic-release/github"
], ],
"branches": [ "branches": [
"master" "master"

@ -6,7 +6,7 @@ interface SonarrSeason {
monitored: boolean; monitored: boolean;
} }
interface SonarrSeries { export interface SonarrSeries {
title: string; title: string;
sortTitle: string; sortTitle: string;
seasonCount: number; seasonCount: number;
@ -33,7 +33,7 @@ interface SonarrSeries {
tvMazeId: number; tvMazeId: number;
firstAired: string; firstAired: string;
lastInfoSync?: string; lastInfoSync?: string;
seriesType: string; seriesType: 'standard' | 'daily' | 'anime';
cleanTitle: string; cleanTitle: string;
imdbId: string; imdbId: string;
titleSlug: string; titleSlug: string;
@ -78,6 +78,7 @@ interface AddSeriesOptions {
seasons: number[]; seasons: number[];
seasonFolder: boolean; seasonFolder: boolean;
rootFolderPath: string; rootFolderPath: string;
seriesType: SonarrSeries['seriesType'];
monitored?: boolean; monitored?: boolean;
searchNow?: boolean; searchNow?: boolean;
} }
@ -153,6 +154,7 @@ class SonarrAPI {
seasonFolder: options.seasonFolder, seasonFolder: options.seasonFolder,
monitored: options.monitored, monitored: options.monitored,
rootFolderPath: options.rootFolderPath, rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: { addOptions: {
ignoreEpisodesWithFiles: true, ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow, searchForMissingEpisodes: options.searchNow,
@ -164,7 +166,7 @@ class SonarrAPI {
} catch (e) { } catch (e) {
logger.error('Something went wrong adding a series to Sonarr', { logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API', label: 'Sonarr API',
message: e.message, errorMessage: e.message,
error: e, error: e,
}); });
throw new Error('Failed to add series'); throw new Error('Failed to add series');

@ -1,5 +1,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
export const ANIME_KEYWORD_ID = 210024;
interface SearchOptions { interface SearchOptions {
query: string; query: string;
page?: number; page?: number;
@ -258,6 +260,11 @@ export interface TmdbTvDetails {
name: string; name: string;
origin_country: string; origin_country: string;
}[]; }[];
spoken_languages: {
english_name: string;
iso_639_1: string;
name: string;
}[];
seasons: TmdbTvSeasonResult[]; seasons: TmdbTvSeasonResult[];
status: string; status: string;
type: string; type: string;
@ -268,6 +275,14 @@ export interface TmdbTvDetails {
crew: TmdbCreditCrew[]; crew: TmdbCreditCrew[];
}; };
external_ids: TmdbExternalIds; external_ids: TmdbExternalIds;
keywords: {
results: TmdbKeyword[];
};
}
export interface TmdbKeyword {
id: number;
name: string;
} }
export interface TmdbPersonDetail { export interface TmdbPersonDetail {
@ -437,7 +452,10 @@ class TheMovieDb {
}): Promise<TmdbTvDetails> => { }): Promise<TmdbTvDetails> => {
try { try {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, { const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: { language, append_to_response: 'credits,external_ids' }, params: {
language,
append_to_response: 'credits,external_ids,keywords',
},
}); });
return response.data; return response.data;
@ -524,6 +542,32 @@ class TheMovieDb {
} }
} }
public async getMoviesByKeyword({
keywordId,
page = 1,
language = 'en-US',
}: {
keywordId: number;
page?: number;
language?: string;
}): Promise<TmdbSearchMovieResponse> {
try {
const response = await this.axios.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
}
}
public async getTvRecommendations({ public async getTvRecommendations({
tvId, tvId,
page = 1, page = 1,

@ -15,11 +15,11 @@ import { User } from './User';
import Media from './Media'; import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings'; import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb, { ANIME_KEYWORD_ID } from '../api/themoviedb';
import RadarrAPI from '../api/radarr'; import RadarrAPI from '../api/radarr';
import logger from '../logger'; import logger from '../logger';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
import SonarrAPI from '../api/sonarr'; import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications'; import notificationManager, { Notification } from '../lib/notifications';
@Entity() @Entity()
@ -36,10 +36,18 @@ export class MediaRequest {
}) })
public media: Media; public media: Media;
@ManyToOne(() => User, (user) => user.requests, { eager: true }) @ManyToOne(() => User, (user) => user.requests, {
eager: true,
onDelete: 'CASCADE',
})
public requestedBy: User; public requestedBy: User;
@ManyToOne(() => User, { nullable: true, cascade: true, eager: true }) @ManyToOne(() => User, {
nullable: true,
cascade: true,
eager: true,
onDelete: 'SET NULL',
})
public modifiedBy?: User; public modifiedBy?: User;
@CreateDateColumn() @CreateDateColumn()
@ -328,14 +336,32 @@ export class MediaRequest {
throw new Error('Series was missing tvdb id'); throw new Error('Series was missing tvdb id');
} }
let seriesType: SonarrSeries['seriesType'] = 'standard';
// Change series type to anime if the anime keyword is present on tmdb
if (
series.keywords.results.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = 'anime';
}
// Run this asynchronously so we don't wait for it on the UI side // Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({ sonarr.addSeries({
profileId: sonarrSettings.activeProfileId, profileId:
rootFolderPath: sonarrSettings.activeDirectory, seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name, title: series.name,
tvdbid: series.external_ids.tvdb_id, tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber), seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders, seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true, monitored: true,
searchNow: true, searchNow: true,
}); });

@ -101,11 +101,20 @@ app
); );
const port = Number(process.env.PORT) || 3000; const port = Number(process.env.PORT) || 3000;
server.listen(port, () => { const host = process.env.HOST;
logger.info(`Server ready on port ${port}`, { if (host) {
label: 'Server', server.listen(port, host, () => {
logger.info(`Server ready on ${host} port ${port}`, {
label: 'Server',
});
}); });
}); } else {
server.listen(port, () => {
logger.info(`Server ready on port ${port}`, {
label: 'Server',
});
});
}
}) })
.catch((err) => { .catch((err) => {
logger.error(err.stack); logger.error(err.stack);

@ -213,7 +213,7 @@ class Settings {
} }
private generateApiKey(): string { private generateApiKey(): string {
return Buffer.from(`${Date.now()}${this.clientId}`).toString('base64'); return Buffer.from(`${Date.now()}${uuidv4()})`).toString('base64');
} }
/** /**

@ -40,7 +40,7 @@ export const isAuthenticated = (
if (!req.user || !req.user.hasPermission(permissions ?? 0)) { if (!req.user || !req.user.hasPermission(permissions ?? 0)) {
res.status(403).json({ res.status(403).json({
status: 403, status: 403,
error: 'You do not have permisson to access this endpoint', error: 'You do not have permission to access this endpoint',
}); });
} else { } else {
next(); next();

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface {
name = 'AddUserRequestDeleteCascades1608219049304';
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, 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") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" 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, 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 NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

@ -7,6 +7,7 @@ import {
mapCrew, mapCrew,
ExternalIds, ExternalIds,
mapExternalIds, mapExternalIds,
Keyword,
} from './common'; } from './common';
import { import {
TmdbTvEpisodeResult, TmdbTvEpisodeResult,
@ -45,6 +46,12 @@ export interface SeasonWithEpisodes extends Season {
externalIds: ExternalIds; externalIds: ExternalIds;
} }
interface SpokenLanguage {
englishName: string;
iso_639_1: string;
name: string;
}
export interface TvDetails { export interface TvDetails {
id: number; id: number;
backdropPath?: string; backdropPath?: string;
@ -74,6 +81,7 @@ export interface TvDetails {
overview: string; overview: string;
popularity: number; popularity: number;
productionCompanies: ProductionCompany[]; productionCompanies: ProductionCompany[];
spokenLanguages: SpokenLanguage[];
seasons: Season[]; seasons: Season[];
status: string; status: string;
type: string; type: string;
@ -84,6 +92,7 @@ export interface TvDetails {
crew: Crew[]; crew: Crew[];
}; };
externalIds: ExternalIds; externalIds: ExternalIds;
keywords: Keyword[];
mediaInfo?: Media; mediaInfo?: Media;
} }
@ -161,6 +170,11 @@ export const mapTvDetails = (
originCountry: company.origin_country, originCountry: company.origin_country,
logoPath: company.logo_path, logoPath: company.logo_path,
})), })),
spokenLanguages: show.spoken_languages.map((language) => ({
englishName: language.english_name,
iso_639_1: language.iso_639_1,
name: language.name,
})),
seasons: show.seasons.map(mapSeasonResult), seasons: show.seasons.map(mapSeasonResult),
status: show.status, status: show.status,
type: show.type, type: show.type,
@ -179,5 +193,9 @@ export const mapTvDetails = (
crew: show.credits.crew.map(mapCrew), crew: show.credits.crew.map(mapCrew),
}, },
externalIds: mapExternalIds(show.external_ids), externalIds: mapExternalIds(show.external_ids),
keywords: show.keywords.results.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
mediaInfo: media, mediaInfo: media,
}); });

@ -11,6 +11,11 @@ export interface ProductionCompany {
name: string; name: string;
} }
export interface Keyword {
id: number;
name: string;
}
export interface Genre { export interface Genre {
id: number; id: number;
name: string; name: string;

@ -14,7 +14,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
return res.status(500).json({ return res.status(500).json({
status: 500, status: 500,
error: error:
'Requsted user endpoint withuot valid authenticated user in session', 'Requested user endpoint without valid authenticated user in session',
}); });
} }
const user = await userRepository.findOneOrFail({ const user = await userRepository.findOneOrFail({

@ -121,4 +121,36 @@ discoverRoutes.get('/trending', async (req, res) => {
}); });
}); });
discoverRoutes.get<{ keywordId: string }>(
'/keyword/:keywordId/movies',
async (req, res) => {
const tmdb = new TheMovieDb();
const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId),
page: Number(req.query.page),
language: req.query.language as string,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
mapMovieResult(
result,
media.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
)
)
),
});
}
);
export default discoverRoutes; export default discoverRoutes;

@ -23,16 +23,27 @@ import { getAppVersion } from '../utils/appVersion';
const settingsRoutes = Router(); const settingsRoutes = Router();
settingsRoutes.get('/main', (req, res) => { const filteredMainSettings = (
user: User,
main: MainSettings
): Partial<MainSettings> => {
if (!user?.hasPermission(Permission.ADMIN)) {
return {
applicationUrl: main.applicationUrl,
};
}
return main;
};
settingsRoutes.get('/main', (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
if (!req.user?.hasPermission(Permission.ADMIN)) { if (!req.user) {
return res.status(200).json({ return next({ status: 500, message: 'User missing from request' });
applicationUrl: settings.main.applicationUrl,
} as Partial<MainSettings>);
} }
res.status(200).json(settings.main); res.status(200).json(filteredMainSettings(req.user, settings.main));
}); });
settingsRoutes.post('/main', (req, res) => { settingsRoutes.post('/main', (req, res) => {
@ -44,6 +55,18 @@ settingsRoutes.post('/main', (req, res) => {
return res.status(200).json(settings.main); return res.status(200).json(settings.main);
}); });
settingsRoutes.get('/main/regenerate', (req, res, next) => {
const settings = getSettings();
const main = settings.regenerateApiKey();
if (!req.user) {
return next({ status: 500, message: 'User missing from request' });
}
return res.status(200).json(filteredMainSettings(req.user, main));
});
settingsRoutes.get('/plex', (_req, res) => { settingsRoutes.get('/plex', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

@ -4,6 +4,7 @@ import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv';
import { mapTvResult } from '../models/Search'; import { mapTvResult } from '../models/Search';
import Media from '../entity/Media'; import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes'; import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
const tvRoutes = Router(); const tvRoutes = Router();
@ -19,6 +20,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
return res.status(200).json(mapTvDetails(tv, media)); return res.status(200).json(mapTvDetails(tv, media));
} catch (e) { } catch (e) {
logger.error('Failed to get tv show', {
label: 'API',
errorMessage: e.message,
});
return next({ status: 404, message: 'TV Show does not exist' }); return next({ status: 404, message: 'TV Show does not exist' });
} }
}); });

@ -1,7 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User'; import { User } from '../entity/User';
import { hasPermission, Permission } from '../lib/permissions'; import { hasPermission, Permission } from '../lib/permissions';
import logger from '../logger';
const router = Router(); const router = Router();
@ -94,13 +96,49 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
try { try {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({ const user = await userRepository.findOne({
where: { id: Number(req.params.id) }, where: { id: Number(req.params.id) },
relations: ['requests'],
}); });
if (!user) {
return next({ status: 404, message: 'User not found' });
}
if (user.id === 1) {
return next({ status: 405, message: 'This account cannot be deleted.' });
}
if (user.hasPermission(Permission.ADMIN)) {
return next({
status: 405,
message: 'You cannot delete users with administrative privileges.',
});
}
const requestRepository = getRepository(MediaRequest);
/**
* Requests are usually deleted through a cascade constraint. Those however, do
* not trigger the removal event so listeners to not run and the parent Media
* will not be updated back to unknown for titles that were still pending. So
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests);
await userRepository.delete(user.id); await userRepository.delete(user.id);
return res.status(200).json(user.filter()); return res.status(200).json(user.filter());
} catch (e) { } catch (e) {
next({ status: 404, message: 'User not found' }); logger.error('Something went wrong deleting a user', {
label: 'API',
userId: req.params.id,
errorMessage: e.message,
});
return next({
status: 500,
message: 'Something went wrong deleting the user',
});
} }
}); });

@ -8,7 +8,7 @@ import { useIntl } from 'react-intl';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition'; import Transition from '../../Transition';
interface ModalProps extends React.HTMLAttributes<HTMLDivElement> { interface ModalProps {
title?: string; title?: string;
onCancel?: (e?: MouseEvent<HTMLElement>) => void; onCancel?: (e?: MouseEvent<HTMLElement>) => void;
onOk?: (e?: MouseEvent<HTMLButtonElement>) => void; onOk?: (e?: MouseEvent<HTMLButtonElement>) => void;

@ -0,0 +1,70 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import type { MovieResult } from '../../../../server/models/Search';
import ListView from '../../Common/ListView';
import { LanguageContext } from '../../../context/LanguageContext';
import Header from '../../Common/Header';
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: MovieResult[];
}
const Holiday: React.FC = () => {
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/keyword/207317/movies?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) {
return <div>{error}</div>;
}
const titles = data?.reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<Header>Happy Holidays!</Header>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default Holiday;

@ -63,6 +63,12 @@ const Discover: React.FC = () => {
} = useSWR<MovieDiscoverResult>( } = useSWR<MovieDiscoverResult>(
`/api/v1/discover/movies/upcoming?language=${locale}` `/api/v1/discover/movies/upcoming?language=${locale}`
); );
const {
data: holUpcomingData,
error: holUpcomingError,
} = useSWR<MovieDiscoverResult>(
`/api/v1/discover/keyword/207317/movies?language=${locale}`
);
const { data: trendingData, error: trendingError } = useSWR<MixedResult>( const { data: trendingData, error: trendingError } = useSWR<MixedResult>(
`/api/v1/discover/trending?language=${locale}` `/api/v1/discover/trending?language=${locale}`
@ -140,6 +146,57 @@ const Discover: React.FC = () => {
placeholder={<RequestCard.Placeholder />} placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)} emptyMessage={intl.formatMessage(messages.nopending)}
/> />
{/* Special Temporary Slider */}
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<Link href="/discover/holiday">
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>
<span role="img" aria-label="Christmas Tree" className="mr-2">
🎄
</span>
Happy Holidays!
<span role="img" aria-label="Christmas Tree" className="ml-2">
🎄
</span>
</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
</div>
<Slider
sliderKey="holiday"
isLoading={!holUpcomingData && !holUpcomingError}
isEmpty={false}
items={holUpcomingData?.results.map((title) => (
<TitleCard
key={`holiday-movie-slider-${title.id}`}
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
/>
))}
/>
{/* End Special Temporary Slider */}
<div className="md:flex md:items-center md:justify-between mb-4 mt-6"> <div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Link href="/discover/movies/upcoming"> <Link href="/discover/movies/upcoming">

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import SearchInput from './SearchInput'; import SearchInput from './SearchInput';
import UserDropdown from './UserDropdown'; import UserDropdown from './UserDropdown';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import Notifications from './Notifications';
import LanguagePicker from './LanguagePicker'; import LanguagePicker from './LanguagePicker';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl'; import { defineMessages, FormattedMessage } from 'react-intl';
@ -47,7 +46,6 @@ const Layout: React.FC = ({ children }) => {
<SearchInput /> <SearchInput />
<div className="ml-4 flex items-center md:ml-6"> <div className="ml-4 flex items-center md:ml-6">
<LanguagePicker /> <LanguagePicker />
<Notifications />
<UserDropdown /> <UserDropdown />
</div> </div>
</div> </div>

@ -20,7 +20,7 @@ const Login: React.FC = () => {
// Effect that is triggered when the `authToken` comes back from the Plex OAuth // Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will // We take the token and attempt to login. If we get a success message, we will
// ask swr to revalidate the user which _shouid_ come back with a valid user. // ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => { useEffect(() => {
const login = async () => { const login = async () => {
setProcessing(true); setProcessing(true);

@ -65,6 +65,7 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.', 'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve', approve: 'Approve',
decline: 'Decline', decline: 'Decline',
studio: 'Studio',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@ -484,14 +485,32 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span> </span>
</div> </div>
)} )}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0"> {data.spokenLanguages.some(
<span className="text-sm"> (lng) => lng.iso_639_1 === data.originalLanguage
<FormattedMessage {...messages.originallanguage} /> ) && (
</span> <div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="flex-1 text-right text-gray-400 text-sm"> <span className="text-sm">
{data.originalLanguage} <FormattedMessage {...messages.originallanguage} />
</span> </span>
</div> <span className="flex-1 text-right text-gray-400 text-sm">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name
}
</span>
</div>
)}
{data.productionCompanies[0] && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.studio} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{data.productionCompanies[0]?.name}
</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

@ -25,7 +25,10 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
return ( return (
<button <button
onClick={setCopied} onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150" className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
> >
<svg <svg

@ -8,6 +8,7 @@ import axios from 'axios';
import Button from '../Common/Button'; import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useUser, Permission } from '../../hooks/useUser'; import { useUser, Permission } from '../../hooks/useUser';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({ const messages = defineMessages({
generalsettings: 'General Settings', generalsettings: 'General Settings',
@ -17,15 +18,37 @@ const messages = defineMessages({
saving: 'Saving...', saving: 'Saving...',
apikey: 'API Key', apikey: 'API Key',
applicationurl: 'Application URL', applicationurl: 'Application URL',
toastApiKeySuccess: 'New API Key generated!',
toastApiKeyFailure: 'Something went wrong generating a new API Key.',
toastSettingsSuccess: 'Settings saved.',
toastSettingsFailure: 'Something went wrong saving settings.',
}); });
const SettingsMain: React.FC = () => { const SettingsMain: React.FC = () => {
const { addToast } = useToasts();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const intl = useIntl(); const intl = useIntl();
const { data, error, revalidate } = useSWR<MainSettings>( const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main' '/api/v1/settings/main'
); );
const regenerate = async () => {
try {
await axios.get('/api/v1/settings/main/regenerate');
revalidate();
addToast(intl.formatMessage(messages.toastApiKeySuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
addToast(intl.formatMessage(messages.toastApiKeyFailure), {
autoDismiss: true,
appearance: 'error',
});
}
};
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@ -50,8 +73,16 @@ const SettingsMain: React.FC = () => {
await axios.post('/api/v1/settings/main', { await axios.post('/api/v1/settings/main', {
applicationUrl: values.applicationUrl, applicationUrl: values.applicationUrl,
}); });
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) { } catch (e) {
// TODO show error addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
appearance: 'error',
});
} finally { } finally {
revalidate(); revalidate();
} }
@ -77,8 +108,17 @@ const SettingsMain: React.FC = () => {
value={data?.apiKey} value={data?.apiKey}
readOnly readOnly
/> />
<CopyButton textToCopy={data?.apiKey ?? ''} /> <CopyButton
<button className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"> textToCopy={data?.apiKey ?? ''}
key={data?.apiKey}
/>
<button
onClick={(e) => {
e.preventDefault();
regenerate();
}}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
>
<svg <svg
className="w-5 h-5" className="w-5 h-5"
fill="currentColor" fill="currentColor"

@ -36,6 +36,8 @@ const messages = defineMessages({
baseUrlPlaceholder: 'Example: /sonarr', baseUrlPlaceholder: 'Example: /sonarr',
qualityprofile: 'Quality Profile', qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder', rootfolder: 'Root Folder',
animequalityprofile: 'Anime Quality Profile',
animerootfolder: 'Anime Root Folder',
seasonfolders: 'Season Folders', seasonfolders: 'Season Folders',
server4k: '4K Server', server4k: '4K Server',
selectQualityProfile: 'Select a Quality Profile', selectQualityProfile: 'Select a Quality Profile',
@ -182,6 +184,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
baseUrl: sonarr?.baseUrl, baseUrl: sonarr?.baseUrl,
activeProfileId: sonarr?.activeProfileId, activeProfileId: sonarr?.activeProfileId,
rootFolder: sonarr?.activeDirectory, rootFolder: sonarr?.activeDirectory,
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
isDefault: sonarr?.isDefault ?? false, isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false, is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false, enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@ -192,6 +196,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
const profileName = testResponse.profiles.find( const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId) (profile) => profile.id === Number(values.activeProfileId)
)?.name; )?.name;
const animeProfileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeAnimeProfileId)
)?.name;
const submission = { const submission = {
name: values.name, name: values.name,
@ -203,6 +210,11 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
activeProfileId: Number(values.activeProfileId), activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName, activeProfileName: profileName,
activeDirectory: values.rootFolder, activeDirectory: values.rootFolder,
activeAnimeProfileId: values.activeAnimeProfileId
? Number(values.activeAnimeProfileId)
: undefined,
activeAnimeProfileName: animeProfileName ?? undefined,
activeAnimeDirectory: values.activeAnimeRootFolder,
is4k: values.is4k, is4k: values.is4k,
isDefault: values.isDefault, isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders, enableSeasonFolders: values.enableSeasonFolders,
@ -528,6 +540,92 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)} )}
</div> </div>
</div> </div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeProfileId"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeProfileId"
name="activeAnimeProfileId"
disabled={!isValidated || isTesting}
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeAnimeProfileId &&
touched.activeAnimeProfileId && (
<div className="text-red-500 mt-2">
{errors.activeAnimeProfileId}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="activeAnimeRootFolder"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<Field
as="select"
id="activeAnimeRootFolder"
name="activeAnimeRootFolder"
disabled={!isValidated || isTesting}
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.activeAnimeRootFolder &&
touched.activeAnimeRootFolder && (
<div className="text-red-500 mt-2">
{errors.rootFolder}
</div>
)}
</div>
</div>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5"> <div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label <label
htmlFor="is4k" htmlFor="is4k"

@ -28,6 +28,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
import Head from 'next/head'; import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
const messages = defineMessages({ const messages = defineMessages({
userrating: 'User Rating', userrating: 'User Rating',
@ -56,6 +57,9 @@ const messages = defineMessages({
'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.', 'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.',
approve: 'Approve', approve: 'Approve',
decline: 'Decline', decline: 'Decline',
showtype: 'Show Type',
anime: 'Anime',
network: 'Network',
}); });
interface TvDetailsProps { interface TvDetailsProps {
@ -431,6 +435,18 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)} )}
</div> </div>
)} )}
{data.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.showtype)}
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{intl.formatMessage(messages.anime)}
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0"> <div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm"> <span className="text-sm">
<FormattedMessage {...messages.status} /> <FormattedMessage {...messages.status} />
@ -439,14 +455,32 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
{data.status} {data.status}
</span> </span>
</div> </div>
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0"> {data.spokenLanguages.some(
<span className="text-sm"> (lng) => lng.iso_639_1 === data.originalLanguage
<FormattedMessage {...messages.originallanguage} /> ) && (
</span> <div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="flex-1 text-right text-gray-400 text-sm"> <span className="text-sm">
{data.originalLanguage} <FormattedMessage {...messages.originallanguage} />
</span> </span>
</div> <span className="flex-1 text-right text-gray-400 text-sm">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
)?.name
}
</span>
</div>
)}
{data.networks.length > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<FormattedMessage {...messages.network} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
{data.networks.map((n) => n.name).join(', ')}
</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

@ -23,13 +23,12 @@ const messages = defineMessages({
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.', 'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.',
settings: 'Manage Settings', settings: 'Manage Settings',
settingsDescription: settingsDescription:
'Grants permission to modify all Overseerr settings. User must have this permission to be able to grant it to others.', 'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests', managerequests: 'Manage Requests',
managerequestsDescription: managerequestsDescription:
'Grants permission to manage Overseerr requests. This includes approving and denying requests.', 'Grants permission to manage Overseerr requests. This includes approving and denying requests.',
request: 'Request', request: 'Request',
requestDescription: requestDescription: 'Grants permission to request movies and series.',
'Grants permission to make requests for movies or tv shows.',
vote: 'Vote', vote: 'Vote',
voteDescription: voteDescription:
'Grants permission to vote on requests (voting not yet implemented)', 'Grants permission to vote on requests (voting not yet implemented)',
@ -38,7 +37,7 @@ const messages = defineMessages({
'Grants auto approval for any requests made by this user.', 'Grants auto approval for any requests made by this user.',
save: 'Save', save: 'Save',
saving: 'Saving...', saving: 'Saving...',
usersaved: 'User succesfully saved', usersaved: 'User saved',
userfail: 'Something went wrong saving the user.', userfail: 'Something went wrong saving the user.',
}); });

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import type { User } from '../../../server/entity/User'; import type { User } from '../../../server/entity/User';
@ -10,6 +10,11 @@ import { Permission } from '../../hooks/useUser';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Header from '../Common/Header'; import Header from '../Common/Header';
import Table from '../Common/Table'; import Table from '../Common/Table';
import Transition from '../Transition';
import Modal from '../Common/Modal';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({ const messages = defineMessages({
userlist: 'User List', userlist: 'User List',
@ -24,12 +29,46 @@ const messages = defineMessages({
admin: 'Admin', admin: 'Admin',
user: 'User', user: 'User',
plexuser: 'Plex User', plexuser: 'Plex User',
deleteuser: 'Delete User',
userdeleted: 'User deleted',
userdeleteerror: 'Something went wrong deleting the user',
deleteconfirm:
'Are you sure you want to delete this user? All existing request data from this user will be removed.',
}); });
const UserList: React.FC = () => { const UserList: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter(); const router = useRouter();
const { data, error } = useSWR<User[]>('/api/v1/user'); const { addToast } = useToasts();
const { data, error, revalidate } = useSWR<User[]>('/api/v1/user');
const [isDeleting, setDeleting] = useState(false);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user?: User;
}>({
isOpen: false,
});
const deleteUser = async () => {
setDeleting(true);
try {
await axios.delete(`/api/v1/user/${deleteModal.user?.id}`);
addToast(intl.formatMessage(messages.userdeleted), {
autoDismiss: true,
appearance: 'success',
});
setDeleteModal({ isOpen: false });
} catch (e) {
addToast(intl.formatMessage(messages.userdeleteerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidate();
}
};
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@ -37,6 +76,46 @@ const UserList: React.FC = () => {
return ( return (
<> <>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={deleteModal.isOpen}
>
<Modal
onOk={() => deleteUser()}
okText={
isDeleting
? intl.formatMessage(globalMessages.deleting)
: intl.formatMessage(globalMessages.delete)
}
okDisabled={isDeleting}
okButtonType="danger"
onCancel={() => setDeleteModal({ isOpen: false })}
title={intl.formatMessage(messages.deleteuser)}
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
}
>
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
</Transition>
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header> <Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<Table> <Table>
<thead> <thead>
@ -104,7 +183,11 @@ const UserList: React.FC = () => {
> >
{intl.formatMessage(messages.edit)} {intl.formatMessage(messages.edit)}
</Button> </Button>
<Button buttonType="danger"> <Button
buttonType="danger"
disabled={hasPermission(Permission.ADMIN, user.permissions)}
onClick={() => setDeleteModal({ isOpen: true, user })}
>
{intl.formatMessage(messages.delete)} {intl.formatMessage(messages.delete)}
</Button> </Button>
</Table.TD> </Table.TD>

@ -14,6 +14,7 @@ const globalMessages = defineMessages({
approve: 'Approve', approve: 'Approve',
decline: 'Decline', decline: 'Decline',
delete: 'Delete', delete: 'Delete',
deleting: 'Deleting…',
}); });
export default globalMessages; export default globalMessages;

@ -43,6 +43,7 @@
"components.MovieDetails.similar": "Similar Titles", "components.MovieDetails.similar": "Similar Titles",
"components.MovieDetails.similarsubtext": "Other movies similar to {title}", "components.MovieDetails.similarsubtext": "Other movies similar to {title}",
"components.MovieDetails.status": "Status", "components.MovieDetails.status": "Status",
"components.MovieDetails.studio": "Studio",
"components.MovieDetails.unavailable": "Unavailable", "components.MovieDetails.unavailable": "Unavailable",
"components.MovieDetails.userrating": "User Rating", "components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.viewrequest": "View Request", "components.MovieDetails.viewrequest": "View Request",
@ -152,6 +153,8 @@
"components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.totalrequests": "Total Requests",
"components.Settings.SettingsAbout.version": "Version", "components.Settings.SettingsAbout.version": "Version",
"components.Settings.SonarrModal.add": "Add Server", "components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile",
"components.Settings.SonarrModal.animerootfolder": "Anime Root Folder",
"components.Settings.SonarrModal.apiKey": "API Key", "components.Settings.SonarrModal.apiKey": "API Key",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key", "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key",
"components.Settings.SonarrModal.baseUrl": "Base URL", "components.Settings.SonarrModal.baseUrl": "Base URL",
@ -239,6 +242,10 @@
"components.Settings.startscan": "Start Scan", "components.Settings.startscan": "Start Scan",
"components.Settings.sync": "Sync Plex Libraries", "components.Settings.sync": "Sync Plex Libraries",
"components.Settings.syncing": "Syncing…", "components.Settings.syncing": "Syncing…",
"components.Settings.toastApiKeyFailure": "Something went wrong generating a new API Key.",
"components.Settings.toastApiKeySuccess": "New API Key generated!",
"components.Settings.toastSettingsFailure": "Something went wrong saving settings.",
"components.Settings.toastSettingsSuccess": "Settings saved.",
"components.Settings.validationHostnameRequired": "You must provide a hostname/IP", "components.Settings.validationHostnameRequired": "You must provide a hostname/IP",
"components.Settings.validationPortRequired": "You must provide a port", "components.Settings.validationPortRequired": "You must provide a port",
"components.Setup.configureplex": "Configure Plex", "components.Setup.configureplex": "Configure Plex",
@ -255,6 +262,7 @@
"components.TitleCard.movie": "Movie", "components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series", "components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve", "components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}", "components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available", "components.TvDetails.available": "Available",
@ -267,6 +275,7 @@
"components.TvDetails.manageModalNoRequests": "No Requests", "components.TvDetails.manageModalNoRequests": "No Requests",
"components.TvDetails.manageModalRequests": "Requests", "components.TvDetails.manageModalRequests": "Requests",
"components.TvDetails.manageModalTitle": "Manage Series", "components.TvDetails.manageModalTitle": "Manage Series",
"components.TvDetails.network": "Network",
"components.TvDetails.originallanguage": "Original Language", "components.TvDetails.originallanguage": "Original Language",
"components.TvDetails.overview": "Overview", "components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable", "components.TvDetails.overviewunavailable": "Overview unavailable",
@ -275,6 +284,7 @@
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like...",
"components.TvDetails.request": "Request", "components.TvDetails.request": "Request",
"components.TvDetails.requestmore": "Request More", "components.TvDetails.requestmore": "Request More",
"components.TvDetails.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series", "components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}", "components.TvDetails.similarsubtext": "Other series similar to {title}",
"components.TvDetails.status": "Status", "components.TvDetails.status": "Status",
@ -306,12 +316,16 @@
"components.UserList.admin": "Admin", "components.UserList.admin": "Admin",
"components.UserList.created": "Created", "components.UserList.created": "Created",
"components.UserList.delete": "Delete", "components.UserList.delete": "Delete",
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.edit": "Edit", "components.UserList.edit": "Edit",
"components.UserList.lastupdated": "Last Updated", "components.UserList.lastupdated": "Last Updated",
"components.UserList.plexuser": "Plex User", "components.UserList.plexuser": "Plex User",
"components.UserList.role": "Role", "components.UserList.role": "Role",
"components.UserList.totalrequests": "Total Requests", "components.UserList.totalrequests": "Total Requests",
"components.UserList.user": "User", "components.UserList.user": "User",
"components.UserList.userdeleted": "User deleted",
"components.UserList.userdeleteerror": "Something went wrong deleting the user",
"components.UserList.userlist": "User List", "components.UserList.userlist": "User List",
"components.UserList.username": "Username", "components.UserList.username": "Username",
"components.UserList.usertype": "User Type", "components.UserList.usertype": "User Type",
@ -322,6 +336,7 @@
"i18n.decline": "Decline", "i18n.decline": "Decline",
"i18n.declined": "Declined", "i18n.declined": "Declined",
"i18n.delete": "Delete", "i18n.delete": "Delete",
"i18n.deleting": "Deleting…",
"i18n.movies": "Movies", "i18n.movies": "Movies",
"i18n.partiallyavailable": "Partially Available", "i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending", "i18n.pending": "Pending",

@ -0,0 +1,9 @@
import React from 'react';
import { NextPage } from 'next';
import Holiday from '../../components/Discover/Holiday';
const HolidayPage: NextPage = () => {
return <Holiday />;
};
export default HolidayPage;

@ -3,7 +3,7 @@ export type Nullable<T> = T | null;
export type Maybe<T> = T | null | undefined; export type Maybe<T> = T | null | undefined;
/** /**
* Helps type objects with an abitrary number of properties that are * Helps type objects with an arbitrary number of properties that are
* usually being defined at export. * usually being defined at export.
* *
* @param component Main object you want to apply properties to * @param component Main object you want to apply properties to

Loading…
Cancel
Save