Merge pull request #153 from Fallenbagel/develop

Merge branch 'develop'
This commit is contained in:
Fallenbagel
2022-06-20 18:30:20 +05:00
committed by GitHub
74 changed files with 912 additions and 320 deletions

View File

@@ -0,0 +1,39 @@
name: 'create docker image on pull request and push to private registery'
on:
pull_request:
branches:
- develop
workflow_dispatch:
jobs:
build-image:
runs-on: self-hosted
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to private registery
uses: docker/login-action@v2.0.0
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
builder: ${{ steps.buildx.outputs.name }}
push: true
tags: '${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:${{ github.sha }}'
cache-from: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache'
cache-to: 'type=registry,ref=${{ secrets.REGISTRY_URL }}/fallenbagel/jellyseerr:buildcache,mode=max'

View File

@@ -5,7 +5,7 @@ on: workflow_dispatch
jobs: jobs:
semantic-release: semantic-release:
name: Tag and release latest version name: Tag and release latest version
runs-on: ubuntu-20.04 runs-on: self-hosted
env: env:
HUSKY: 0 HUSKY: 0
steps: steps:
@@ -39,7 +39,7 @@ jobs:
name: Send Discord Notification name: Send Discord Notification
needs: semantic-release needs: semantic-release
if: always() if: always()
runs-on: ubuntu-20.04 runs-on: self-hosted
steps: steps:
- name: Get Build Job Status - name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v2 uses: technote-space/workflow-conclusion-action@v2

3
.gitignore vendored
View File

@@ -53,3 +53,6 @@ config/db/db.sqlite3-journal
# VS Code # VS Code
.vscode/launch.json .vscode/launch.json
# Webstorm
.idea

View File

@@ -32,6 +32,18 @@ With more features on the way! Check out our [issue tracker](https://github.com/
Check out our dockerhub for instructions on how to install and run Jellyseerr: Check out our dockerhub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr https://hub.docker.com/r/fallenbagel/jellyseerr
### Launching Jellyseerr manually:
```bash
yarn install
yarn run build
yarn start
```
### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
## Preview ## Preview
<img src="./public/preview.jpg"> <img src="./public/preview.jpg">

View File

@@ -8,7 +8,7 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
``` ```
[Definition] [Definition]
failregex = .*\[info\]\[Auth\]\: Failed sign-in attempt.*"ip":"<HOST>" failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
``` ```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

@@ -2,6 +2,10 @@ module.exports = {
env: { env: {
commitTag: process.env.COMMIT_TAG || 'local', commitTag: process.env.COMMIT_TAG || 'local',
}, },
publicRuntimeConfig: {
// Will be available on both server and client
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
},
images: { images: {
domains: ['image.tmdb.org'], domains: ['image.tmdb.org'],
}, },

View File

@@ -5815,6 +5815,36 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Issue' $ref: '#/components/schemas/Issue'
/issue/count:
get:
summary: Gets issue counts
description: |
Returns the number of open and closed issues, as well as the number of issues of each type.
tags:
- issue
responses:
'200':
description: Issue counts returned
content:
application/json:
schema:
type: object
properties:
total:
type: number
video:
type: number
audio:
type: number
subtitles:
type: number
others:
type: number
open:
type: number
closed:
type: number
/issue/{issueId}: /issue/{issueId}:
get: get:
summary: Get issue summary: Get issue

View File

@@ -37,6 +37,7 @@
"country-flag-icons": "^1.4.21", "country-flag-icons": "^1.4.21",
"csurf": "^1.11.0", "csurf": "^1.11.0",
"email-templates": "^8.0.10", "email-templates": "^8.0.10",
"email-validator": "^2.0.4",
"express": "^4.17.3", "express": "^4.17.3",
"express-openapi-validator": "^4.13.6", "express-openapi-validator": "^4.13.6",
"express-rate-limit": "^6.3.0", "express-rate-limit": "^6.3.0",
@@ -84,6 +85,7 @@
"@babel/cli": "^7.17.6", "@babel/cli": "^7.17.6",
"@commitlint/cli": "^16.2.1", "@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1", "@commitlint/config-conventional": "^16.2.1",
"@next/eslint-plugin-next": "^12.1.6",
"@semantic-release/changelog": "^6.0.1", "@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",

View File

@@ -31,6 +31,7 @@ export interface JellyfinLibraryItem {
Id: string; Id: string;
HasSubtitles: boolean; HasSubtitles: boolean;
Type: 'Movie' | 'Episode' | 'Season' | 'Series'; Type: 'Movie' | 'Episode' | 'Season' | 'Series';
LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual';
SeriesName?: string; SeriesName?: string;
SeriesId?: string; SeriesId?: string;
SeasonId?: string; SeasonId?: string;
@@ -205,7 +206,9 @@ class JellyfinAPI {
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}` `/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
); );
return contents.data.Items; return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
@@ -251,7 +254,9 @@ class JellyfinAPI {
try { try {
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`); const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
return contents.data.Items; return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
@@ -270,7 +275,9 @@ class JellyfinAPI {
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
); );
return contents.data.Items; return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,

View File

@@ -129,7 +129,13 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => { }: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try { try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', { const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: { query, page, include_adult: includeAdult, language, year }, params: {
query,
page,
include_adult: includeAdult,
language,
primary_release_year: year,
},
}); });
return data; return data;

View File

@@ -137,6 +137,8 @@ export class User {
@UpdateDateColumn() @UpdateDateColumn()
public updatedAt: Date; public updatedAt: Date;
public warnings: string[] = [];
constructor(init?: Partial<User>) { constructor(init?: Partial<User>) {
Object.assign(this, init); Object.assign(this, init);
} }

View File

@@ -2,6 +2,7 @@ import { NotificationAgentKey } from '../../lib/settings';
export interface UserSettingsGeneralResponse { export interface UserSettingsGeneralResponse {
username?: string; username?: string;
email?: string;
discordId?: string; discordId?: string;
locale?: string; locale?: string;
region?: string; region?: string;

View File

@@ -13,6 +13,7 @@ import {
NotificationAgentKey, NotificationAgentKey,
} from '../../settings'; } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import * as EmailValidator from 'email-validator';
class EmailAgent class EmailAgent
extends BaseAgent<NotificationAgentEmail> extends BaseAgent<NotificationAgentEmail>
@@ -215,6 +216,7 @@ class EmailAgent
this.getSettings(), this.getSettings(),
payload.notifyUser.settings?.pgpKey payload.notifyUser.settings?.pgpKey
); );
if (EmailValidator.validate(payload.notifyUser.email)) {
await email.send( await email.send(
this.buildMessage( this.buildMessage(
type, type,
@@ -223,6 +225,14 @@ class EmailAgent
payload.notifyUser.displayName payload.notifyUser.displayName
) )
); );
} else {
logger.warn('Invalid email address provided for user', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
}
} catch (e) { } catch (e) {
logger.error('Error sending email notification', { logger.error('Error sending email notification', {
label: 'Notifications', label: 'Notifications',
@@ -268,9 +278,18 @@ class EmailAgent
this.getSettings(), this.getSettings(),
user.settings?.pgpKey user.settings?.pgpKey
); );
if (EmailValidator.validate(user.email)) {
await email.send( await email.send(
this.buildMessage(type, payload, user.email, user.displayName) this.buildMessage(type, payload, user.email, user.displayName)
); );
} else {
logger.warn('Invalid email address provided for user', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
}
} catch (e) { } catch (e) {
logger.error('Error sending email notification', { logger.error('Error sending email notification', {
label: 'Notifications', label: 'Notifications',

View File

@@ -134,6 +134,7 @@ interface FullPublicSettings extends PublicSettings {
enablePushRegistration: boolean; enablePushRegistration: boolean;
locale: string; locale: string;
emailEnabled: boolean; emailEnabled: boolean;
userEmailRequired: boolean;
newPlexLogin: boolean; newPlexLogin: boolean;
} }
@@ -159,6 +160,7 @@ export interface NotificationAgentSlack extends NotificationAgentConfig {
export interface NotificationAgentEmail extends NotificationAgentConfig { export interface NotificationAgentEmail extends NotificationAgentConfig {
options: { options: {
userEmailRequired: boolean;
emailFrom: string; emailFrom: string;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
@@ -335,6 +337,7 @@ class Settings {
email: { email: {
enabled: false, enabled: false,
options: { options: {
userEmailRequired: false,
emailFrom: '', emailFrom: '',
smtpHost: '', smtpHost: '',
smtpPort: 587, smtpPort: 587,
@@ -342,7 +345,7 @@ class Settings {
ignoreTls: false, ignoreTls: false,
requireTls: false, requireTls: false,
allowSelfSigned: false, allowSelfSigned: false,
senderName: 'Overseerr', senderName: 'Jellyseerr',
}, },
}, },
discord: { discord: {
@@ -529,6 +532,8 @@ class Settings {
enablePushRegistration: this.data.notifications.agents.webpush.enabled, enablePushRegistration: this.data.notifications.agents.webpush.enabled,
locale: this.data.main.locale, locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled, emailEnabled: this.data.notifications.agents.email.enabled,
userEmailRequired:
this.data.notifications.agents.email.options.userEmailRequired,
newPlexLogin: this.data.main.newPlexLogin, newPlexLogin: this.data.main.newPlexLogin,
}; };
} }

View File

@@ -9,6 +9,7 @@ import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings'; import { getSettings } from '../lib/settings';
import logger from '../logger'; import logger from '../logger';
import { isAuthenticated } from '../middleware/auth'; import { isAuthenticated } from '../middleware/auth';
import * as EmailValidator from 'email-validator';
const authRoutes = Router(); const authRoutes = Router();
@@ -24,6 +25,16 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
where: { id: req.user.id }, where: { id: req.user.id },
}); });
// check if email is required in settings and if user has an valid email
const settings = await getSettings();
if (
settings.notifications.agents.email.options.userEmailRequired &&
!EmailValidator.validate(user.email)
) {
user.warnings.push('userEmailRequired');
logger.warn(`User ${user.username} has no valid email address`);
}
return res.status(200).json(user); return res.status(200).json(user);
}); });
@@ -70,6 +81,9 @@ authRoutes.post('/plex', async (req, res, next) => {
userType: UserType.PLEX, userType: UserType.PLEX,
}); });
settings.main.mediaServerType = MediaServerType.PLEX;
settings.save();
await userRepository.save(user); await userRepository.save(user);
} else { } else {
const mainUser = await userRepository.findOneOrFail({ const mainUser = await userRepository.findOneOrFail({
@@ -196,10 +210,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.hostname !== '' settings.jellyfin.hostname !== ''
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username || !body.password) { } else if (!body.username) {
return res return res.status(500).json({ error: 'You must provide an username' });
.status(500)
.json({ error: 'You must provide an username and a password' });
} else if (settings.jellyfin.hostname !== '' && body.hostname) { } else if (settings.jellyfin.hostname !== '' && body.hostname) {
return res return res
.status(500) .status(500)
@@ -213,6 +225,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.hostname !== '' settings.jellyfin.hostname !== ''
? settings.jellyfin.hostname ? settings.jellyfin.hostname
: body.hostname; : body.hostname;
const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
let user = await userRepository.findOne({ let user = await userRepository.findOne({
@@ -229,6 +242,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
const account = await jellyfinserver.login(body.username, body.password); const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists // Next let's see if the user already exists
@@ -244,7 +261,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else { } else {
user.avatar = '/os_logo_square.png'; user.avatar = '/os_logo_square.png';
} }
@@ -290,7 +307,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinAuthToken: account.AccessToken, jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN, permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png', : '/os_logo_square.png',
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
@@ -319,7 +336,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinAuthToken: account.AccessToken, jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag avatar: account.User.PrimaryImageTag
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png', : '/os_logo_square.png',
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
@@ -327,7 +344,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
const passedExplicitPassword = const passedExplicitPassword =
body.password && body.password.length > 0; body.password && body.password.length > 0;
if (passedExplicitPassword) { if (passedExplicitPassword) {
await user.setPassword(body.password); await user.setPassword(body.password ?? '');
} }
await userRepository.save(user); await userRepository.save(user);
} }

View File

@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { IssueStatus } from '../constants/issue'; import { IssueStatus, IssueType } from '../constants/issue';
import Issue from '../entity/Issue'; import Issue from '../entity/Issue';
import IssueComment from '../entity/IssueComment'; import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media'; import Media from '../entity/Media';
@@ -146,6 +146,68 @@ issueRoutes.post<
} }
); );
issueRoutes.get('/count', async (req, res, next) => {
const issueRepository = getRepository(Issue);
try {
const query = issueRepository.createQueryBuilder('issue');
const totalCount = await query.getCount();
const videoCount = await query
.where('issue.issueType = :issueType', {
issueType: IssueType.VIDEO,
})
.getCount();
const audioCount = await query
.where('issue.issueType = :issueType', {
issueType: IssueType.AUDIO,
})
.getCount();
const subtitlesCount = await query
.where('issue.issueType = :issueType', {
issueType: IssueType.SUBTITLES,
})
.getCount();
const othersCount = await query
.where('issue.issueType = :issueType', {
issueType: IssueType.OTHER,
})
.getCount();
const openCount = await query
.where('issue.status = :issueStatus', {
issueStatus: IssueStatus.OPEN,
})
.getCount();
const closedCount = await query
.where('issue.status = :issueStatus', {
issueStatus: IssueStatus.RESOLVED,
})
.getCount();
return res.status(200).json({
total: totalCount,
video: videoCount,
audio: audioCount,
subtitles: subtitlesCount,
others: othersCount,
open: openCount,
closed: closedCount,
});
} catch (e) {
logger.debug('Something went wrong retrieving issue counts.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 500, message: 'Unable to retrieve issue counts.' });
}
});
issueRoutes.get<{ issueId: string }>( issueRoutes.get<{ issueId: string }>(
'/:issueId', '/:issueId',
isAuthenticated( isAuthenticated(

View File

@@ -303,6 +303,11 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const { hostname, externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
: hostname;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
@@ -321,7 +326,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
username: user.Name, username: user.Name,
id: user.Id, id: user.Id,
thumb: user.PrimaryImageTag thumb: user.PrimaryImageTag
? `${settings.jellyfin.hostname}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: '/os_logo_square.png', : '/os_logo_square.png',
email: user.Name, email: user.Name,
})); }));

View File

@@ -492,56 +492,46 @@ router.post(
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
for (const account of jellyfinUsersResponse.users) { const { hostname, externalHostname } = getSettings().jellyfin;
if (account.Name) { const jellyfinHost =
const user = await userRepository externalHostname && externalHostname.length > 0
.createQueryBuilder('user') ? externalHostname
.where('user.jellyfinUserId = :id', { id: account.Id }) : hostname;
.orWhere('user.email = :email', {
email: account.Name,
})
.getOne();
const avatar = account.PrimaryImageTag jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
? `${settings.jellyfin.hostname}/Users/${account.Id}/Images/Primary/?tag=${account.PrimaryImageTag}&quality=90` const jellyfinUsers = await jellyfinClient.getUsers();
: '/os_logo_square.png';
if (user) { for (const jellyfinUserId of body.jellyfinUserIds) {
// Update the user's avatar with their Jellyfin thumbnail, in case it changed const jellyfinUser = jellyfinUsers.users.find(
user.avatar = avatar; (user) => user.Id === jellyfinUserId
user.email = account.Name; );
user.jellyfinUsername = account.Name;
// In case the user was previously a local account const user = await userRepository.findOne({
if (user.userType === UserType.LOCAL) { select: ['id', 'jellyfinUserId'],
user.userType = UserType.JELLYFIN; where: { jellyfinUserId: jellyfinUserId },
user.jellyfinUserId = account.Id; });
}
await userRepository.save(user);
} else if (!body || body.jellyfinUserIds.includes(account.Id)) {
// logger.error('CREATED USER', {
// label: 'API',
// });
if (!user) {
const newUser = new User({ const newUser = new User({
jellyfinUsername: account.Name, jellyfinUsername: jellyfinUser?.Name,
jellyfinUserId: account.Id, jellyfinUserId: jellyfinUser?.Id,
jellyfinDeviceId: Buffer.from( jellyfinDeviceId: Buffer.from(
`BOT_overseerr_${account.Name ?? ''}` `BOT_jellyseerr_${jellyfinUser?.Name ?? ''}`
).toString('base64'), ).toString('base64'),
email: account.Name, email: jellyfinUser?.Name,
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar, avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });
await userRepository.save(newUser); await userRepository.save(newUser);
createdUsers.push(newUser); createdUsers.push(newUser);
} }
} }
}
return res.status(201).json(User.filterMany(createdUsers)); return res.status(201).json(User.filterMany(createdUsers));
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

View File

@@ -51,6 +51,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({ return res.status(200).json({
username: user.username, username: user.username,
email: user.email,
discordId: user.settings?.discordId, discordId: user.settings?.discordId,
locale: user.settings?.locale, locale: user.settings?.locale,
region: user.settings?.region, region: user.settings?.region,
@@ -120,6 +121,7 @@ userSettingsRoutes.post<
user.settings.locale = req.body.locale; user.settings.locale = req.body.locale;
user.settings.region = req.body.region; user.settings.region = req.body.region;
user.settings.originalLanguage = req.body.originalLanguage; user.settings.originalLanguage = req.body.originalLanguage;
user.email = req.body.email ?? user.email;
} }
await userRepository.save(user); await userRepository.save(user);
@@ -130,6 +132,7 @@ userSettingsRoutes.post<
locale: user.settings.locale, locale: user.settings.locale,
region: user.settings.region, region: user.settings.region,
originalLanguage: user.settings.originalLanguage, originalLanguage: user.settings.originalLanguage,
email: user.email,
}); });
} catch (e) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });

View File

@@ -110,6 +110,12 @@ const networks: Network[] = [
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png', 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
url: '/discover/tv/network/16', url: '/discover/tv/network/16',
}, },
{
name: 'Paramount+',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fi83B1oztoS47xxcemFdPMhIzK.png',
url: '/discover/tv/network/4330',
},
{ {
name: 'BBC One', name: 'BBC One',
image: image:

View File

@@ -90,7 +90,7 @@ const IssueComment: React.FC<IssueCommentProps> = ({
<img <img
src={comment.user.avatar} src={comment.user.avatar}
alt="" alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/> />
</a> </a>
</Link> </Link>

View File

@@ -35,6 +35,7 @@ import IssueComment from './IssueComment';
import IssueDescription from './IssueDescription'; import IssueDescription from './IssueDescription';
import { MediaServerType } from '../../../server/constants/server'; import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
openedby: '#{issueId} opened {relativeTime} by {username}', openedby: '#{issueId} opened {relativeTime} by {username}',
@@ -99,6 +100,7 @@ const IssueDetails: React.FC = () => {
(opt) => opt.issueType === issueData?.issueType (opt) => opt.issueType === issueData?.issueType
); );
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -267,7 +269,7 @@ const IssueDetails: React.FC = () => {
> >
<a className="group ml-1 inline-flex h-full items-center xl:ml-1.5"> <a className="group ml-1 inline-flex h-full items-center xl:ml-1.5">
<img <img
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6" className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar} src={issueData.createdBy.avatar}
alt="" alt=""
/> />
@@ -366,12 +368,17 @@ const IssueDetails: React.FC = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{intl.formatMessage(messages.playonplex, { {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
mediaServerName: ? intl.formatMessage(messages.playonplex, {
settings.currentSettings.mediaServerType === mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? intl.formatMessage(messages.playonplex, {
: 'Jellyfin', mediaServerName: 'Plex',
})
: intl.formatMessage(messages.playonplex, {
mediaServerName: 'Jellyfin',
})} })}
</span> </span>
</Button> </Button>
@@ -407,12 +414,17 @@ const IssueDetails: React.FC = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{intl.formatMessage(messages.play4konplex, { {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
mediaServerName: ? intl.formatMessage(messages.play4konplex, {
settings.currentSettings.mediaServerType === mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? intl.formatMessage(messages.play4konplex, {
: 'Jellyfin', mediaServerName: 'Plex',
})
: intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Jellyfin',
})} })}
</span> </span>
</Button> </Button>
@@ -618,12 +630,17 @@ const IssueDetails: React.FC = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{intl.formatMessage(messages.playonplex, { {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
mediaServerName: ? intl.formatMessage(messages.playonplex, {
settings.currentSettings.mediaServerType === mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? intl.formatMessage(messages.playonplex, {
: 'Jellyfin', mediaServerName: 'Plex',
})
: intl.formatMessage(messages.playonplex, {
mediaServerName: 'Jellyfin',
})} })}
</span> </span>
</Button> </Button>
@@ -659,12 +676,17 @@ const IssueDetails: React.FC = () => {
> >
<PlayIcon /> <PlayIcon />
<span> <span>
{intl.formatMessage(messages.play4konplex, { {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
mediaServerName: ? intl.formatMessage(messages.play4konplex, {
settings.currentSettings.mediaServerType === mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? intl.formatMessage(messages.play4konplex, {
: 'Jellyfin', mediaServerName: 'Plex',
})
: intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Jellyfin',
})} })}
</span> </span>
</Button> </Button>

View File

@@ -228,7 +228,7 @@ const IssueItem: React.FC<IssueItemProps> = ({ issue }) => {
<img <img
src={issue.createdBy.avatar} src={issue.createdBy.avatar}
alt="" alt=""
className="avatar-sm ml-1.5" className="avatar-sm ml-1.5 object-cover"
/> />
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline"> <span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{issue.createdBy.displayName} {issue.createdBy.displayName}

View File

@@ -14,6 +14,7 @@ import useClickOutside from '../../../hooks/useClickOutside';
import { Permission, useUser } from '../../../hooks/useUser'; import { Permission, useUser } from '../../../hooks/useUser';
import Transition from '../../Transition'; import Transition from '../../Transition';
import VersionStatus from '../VersionStatus'; import VersionStatus from '../VersionStatus';
import UserWarnings from '../UserWarnings';
const messages = defineMessages({ const messages = defineMessages({
dashboard: 'Discover', dashboard: 'Discover',
@@ -177,6 +178,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
); );
})} })}
</nav> </nav>
<div className="px-2">
<UserWarnings onClick={() => setClosed()} />
</div>
{hasPermission(Permission.ADMIN) && ( {hasPermission(Permission.ADMIN) && (
<div className="px-2"> <div className="px-2">
<VersionStatus onClick={() => setClosed()} /> <VersionStatus onClick={() => setClosed()} />
@@ -236,6 +241,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
); );
})} })}
</nav> </nav>
<div className="px-2">
<UserWarnings />
</div>
{hasPermission(Permission.ADMIN) && ( {hasPermission(Permission.ADMIN) && (
<div className="px-2"> <div className="px-2">
<VersionStatus /> <VersionStatus />

View File

@@ -40,7 +40,7 @@ const UserDropdown: React.FC = () => {
onClick={() => setDropdownOpen(true)} onClick={() => setDropdownOpen(true)}
> >
<img <img
className="h-8 w-8 rounded-full sm:h-10 sm:w-10" className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar} src={user?.avatar}
alt="" alt=""
/> />

View File

@@ -0,0 +1,66 @@
import React from 'react';
import Link from 'next/link';
import { ExclamationIcon } from '@heroicons/react/outline';
import { defineMessages, useIntl } from 'react-intl';
import { useUser } from '../../../hooks/useUser';
const messages = defineMessages({
emailRequired: 'An email address is required.',
emailInvalid: 'Email address is invalid.',
passwordRequired: 'A password is required.',
});
interface UserWarningsProps {
onClick?: () => void;
}
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
const intl = useIntl();
const { user } = useUser();
if (!user) {
return null;
}
let res = null;
//check if a user has warnings
if (user.warnings.length > 0) {
user.warnings.forEach((warning) => {
let link = '';
let warningText = '';
let warningTitle = '';
switch (warning) {
case 'userEmailRequired':
link = '/profile/settings/';
warningTitle = 'Profile is incomplete';
warningText = intl.formatMessage(messages.emailRequired);
}
res = (
<Link href={link}>
<a
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' && onClick) {
onClick();
}
}}
role="button"
tabIndex={0}
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
>
<ExclamationIcon className="h-6 w-6" />
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{warningTitle}</span>
<span className="truncate">{warningText}</span>
</div>
</a>
</Link>
);
});
}
return res;
};
export default UserWarnings;

View File

@@ -50,6 +50,7 @@ const Layout: React.FC = ({ children }) => {
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900"> <div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" /> <div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
</div> </div>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} /> <Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64"> <div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">

View File

@@ -1,19 +1,19 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import { Field, Form, Formik } from 'formik';
import * as Yup from 'yup';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import Button from '../Common/Button';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
host: 'Jellyfin URL', host: '{mediaServerName} URL',
email: 'Email', email: 'Email',
validationhostrequired: 'Jellyfin URL required', validationhostrequired: '{mediaServerName} URL required',
validationhostformat: 'Valid URL required', validationhostformat: 'Valid URL required',
validationemailrequired: 'Email required', validationemailrequired: 'Email required',
validationemailformat: 'Valid email required', validationemailformat: 'Valid email required',
@@ -40,6 +40,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
const toasts = useToasts(); const toasts = useToasts();
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (initial) { if (initial) {
const LoginSchema = Yup.object().shape({ const LoginSchema = Yup.object().shape({
@@ -48,16 +49,19 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/, /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/,
intl.formatMessage(messages.validationhostformat) intl.formatMessage(messages.validationhostformat)
) )
.required(intl.formatMessage(messages.validationhostrequired)), .required(
intl.formatMessage(messages.validationhostrequired, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})
),
email: Yup.string() email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat)) .email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)), .required(intl.formatMessage(messages.validationemailrequired)),
username: Yup.string().required( username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired) intl.formatMessage(messages.validationusernamerequired)
), ),
password: Yup.string().required( password: Yup.string(),
intl.formatMessage(messages.validationpasswordrequired)
),
}); });
return ( return (
<Formik <Formik
@@ -97,7 +101,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<Form> <Form>
<div className="sm:border-t sm:border-gray-800"> <div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label"> <label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host)} {intl.formatMessage(messages.host, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
})}
</label> </label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0"> <div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm"> <div className="flex rounded-md shadow-sm">
@@ -105,7 +114,12 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
id="host" id="host"
name="host" name="host"
type="text" type="text"
placeholder={intl.formatMessage(messages.host)} placeholder={intl.formatMessage(messages.host, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
})}
/> />
</div> </div>
{errors.host && touched.host && ( {errors.host && touched.host && (
@@ -185,9 +199,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
username: Yup.string().required( username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired) intl.formatMessage(messages.validationusernamerequired)
), ),
password: Yup.string().required( password: Yup.string(),
intl.formatMessage(messages.validationpasswordrequired)
),
}); });
return ( return (
<div> <div>
@@ -266,8 +278,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
as="a" as="a"
buttonType="ghost" buttonType="ghost"
href={ href={
settings.currentSettings.jellyfinHost + process.env.JELLYFIN_TYPE == 'emby'
'/web/#!/forgotpassword.html' ? settings.currentSettings.jellyfinHost +
'/web/index.html#!/startup/forgotpassword.html'
: settings.currentSettings.jellyfinHost +
'/web/index.html#!/forgotpassword.html'
} }
> >
{intl.formatMessage(messages.forgotpassword)} {intl.formatMessage(messages.forgotpassword)}

View File

@@ -15,12 +15,13 @@ import PlexLoginButton from '../PlexLoginButton';
import Transition from '../Transition'; import Transition from '../Transition';
import JellyfinLogin from './JellyfinLogin'; import JellyfinLogin from './JellyfinLogin';
import LocalLogin from './LocalLogin'; import LocalLogin from './LocalLogin';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
signin: 'Sign In', signin: 'Sign In',
signinheader: 'Sign in to continue', signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account', signinwithplex: 'Use your Plex account',
signinwithjellyfin: 'Use your Jellyfin account', signinwithjellyfin: 'Use your {mediaServerName} account',
signinwithoverseerr: 'Use your {applicationTitle} account', signinwithoverseerr: 'Use your {applicationTitle} account',
}); });
@@ -32,6 +33,7 @@ const Login: React.FC = () => {
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const router = useRouter(); const router = useRouter();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
// 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 sign in. If we get a success message, we will // We take the token and attempt to sign in. If we get a success message, we will
@@ -133,7 +135,12 @@ const Login: React.FC = () => {
{settings.currentSettings.mediaServerType == {settings.currentSettings.mediaServerType ==
MediaServerType.PLEX MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex) ? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(messages.signinwithjellyfin)} : intl.formatMessage(messages.signinwithjellyfin, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
})}
</button> </button>
<AccordionContent isOpen={openIndexes.includes(0)}> <AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8"> <div className="px-10 py-8">

View File

@@ -210,7 +210,7 @@ const ManageSlideOver: React.FC<
{hasPermission(Permission.ADMIN) && {hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl || (data.mediaInfo?.serviceUrl ||
data.mediaInfo?.tautulliUrl || data.mediaInfo?.tautulliUrl ||
watchData?.data?.playCount) && ( !!watchData?.data?.playCount) && (
<div> <div>
<h3 className="mb-2 text-xl font-bold"> <h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia)} {intl.formatMessage(messages.manageModalMedia)}
@@ -272,7 +272,7 @@ const ManageSlideOver: React.FC<
<img <img
src={user.avatar} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/> />
</a> </a>
</Link> </Link>
@@ -325,7 +325,7 @@ const ManageSlideOver: React.FC<
{hasPermission(Permission.ADMIN) && {hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl4k || (data.mediaInfo?.serviceUrl4k ||
data.mediaInfo?.tautulliUrl4k || data.mediaInfo?.tautulliUrl4k ||
watchData?.data4k?.playCount) && ( !!watchData?.data4k?.playCount) && (
<div> <div>
<h3 className="mb-2 text-xl font-bold"> <h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia4k)} {intl.formatMessage(messages.manageModalMedia4k)}
@@ -387,7 +387,7 @@ const ManageSlideOver: React.FC<
<img <img
src={user.avatar} src={user.avatar}
alt={user.displayName} alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105" className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/> />
</a> </a>
</Link> </Link>

View File

@@ -48,6 +48,7 @@ import PersonCard from '../PersonCard';
import RequestButton from '../RequestButton'; import RequestButton from '../RequestButton';
import Slider from '../Slider'; import Slider from '../Slider';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
originaltitle: 'Original Title', originaltitle: 'Original Title',
@@ -95,6 +96,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const minStudios = 3; const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false); const [showMoreStudios, setShowMoreStudios] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false);
const { publicRuntimeConfig } = getConfig();
const { const {
data, data,
@@ -130,10 +132,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
if (data.mediaInfo?.mediaUrl) { if (data.mediaInfo?.mediaUrl) {
mediaLinks.push({ mediaLinks.push({
text: text: getAvalaibleMediaServerName(),
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl, url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -146,10 +145,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}) })
) { ) {
mediaLinks.push({ mediaLinks.push({
text: text: getAvalaible4kMediaServerName(),
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl4k, url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -228,6 +224,30 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region) data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
?.flatrate ?? []; ?.flatrate ?? [];
function getAvalaibleMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
return intl.formatMessage(messages.play, { mediaServerName: 'Plex' });
}
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
function getAvalaible4kMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' });
}
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
}
return ( return (
<div <div
className="media-page" className="media-page"

View File

@@ -26,6 +26,7 @@ const messages = defineMessages({
server: 'Destination Server', server: 'Destination Server',
profilechanged: 'Quality Profile', profilechanged: 'Quality Profile',
rootfolder: 'Root Folder', rootfolder: 'Root Folder',
languageprofile: 'Language Profile',
}); });
interface RequestBlockProps { interface RequestBlockProps {
@@ -38,7 +39,8 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
const intl = useIntl(); const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const { profile, rootFolder, server } = useRequestOverride(request); const { profile, rootFolder, server, languageProfile } =
useRequestOverride(request);
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => { const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
setIsUpdating(true); setIsUpdating(true);
@@ -209,7 +211,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div> </div>
</div> </div>
)} )}
{(server || profile !== null || rootFolder) && ( {(server || profile || rootFolder || languageProfile) && (
<> <>
<div className="mt-4 mb-1 text-sm"> <div className="mt-4 mb-1 text-sm">
{intl.formatMessage(messages.requestoverrides)} {intl.formatMessage(messages.requestoverrides)}
@@ -223,12 +225,12 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<span>{server}</span> <span>{server}</span>
</li> </li>
)} )}
{profile !== null && ( {profile && (
<li className="flex justify-between px-1 py-2"> <li className="flex justify-between px-1 py-2">
<span className="font-bold"> <span className="font-bold">
{intl.formatMessage(messages.profilechanged)} {intl.formatMessage(messages.profilechanged)}
</span> </span>
<span>ID {profile}</span> <span>{profile}</span>
</li> </li>
)} )}
{rootFolder && ( {rootFolder && (
@@ -239,6 +241,14 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<span>{rootFolder}</span> <span>{rootFolder}</span>
</li> </li>
)} )}
{languageProfile && (
<li className="flex justify-between px-1 py-2">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.languageprofile)}
</span>
<span>{languageProfile}</span>
</li>
)}
</ul> </ul>
</> </>
)} )}

View File

@@ -231,7 +231,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
<img <img
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm" className="avatar-sm object-cover"
/> />
<span className="truncate font-semibold group-hover:text-white group-hover:underline"> <span className="truncate font-semibold group-hover:text-white group-hover:underline">
{requestData.requestedBy.displayName} {requestData.requestedBy.displayName}

View File

@@ -336,7 +336,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
<img <img
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="avatar-sm ml-1.5" className="avatar-sm ml-1.5 object-cover"
/> />
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline"> <span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{requestData.requestedBy.displayName} {requestData.requestedBy.displayName}
@@ -390,7 +390,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
<img <img
src={requestData.modifiedBy.avatar} src={requestData.modifiedBy.avatar}
alt="" alt=""
className="avatar-sm ml-1.5" className="avatar-sm ml-1.5 object-cover"
/> />
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline"> <span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{requestData.modifiedBy.displayName} {requestData.modifiedBy.displayName}

View File

@@ -534,7 +534,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
<img <img
src={selectedUser.avatar} src={selectedUser.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
/> />
<span className="ml-3 block"> <span className="ml-3 block">
{selectedUser.displayName} {selectedUser.displayName}
@@ -584,7 +584,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
<img <img
src={user.avatar} src={user.avatar}
alt="" alt=""
className="h-6 w-6 flex-shrink-0 rounded-full" className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
/> />
<span className="ml-3 block flex-shrink-0"> <span className="ml-3 block flex-shrink-0">
{user.displayName} {user.displayName}

View File

@@ -16,6 +16,7 @@ const messages = defineMessages({
validationSmtpHostRequired: 'You must provide a valid hostname or IP address', validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
validationSmtpPortRequired: 'You must provide a valid port number', validationSmtpPortRequired: 'You must provide a valid port number',
agentenabled: 'Enable Agent', agentenabled: 'Enable Agent',
userEmailRequired: 'Require user email',
emailsender: 'Sender Address', emailsender: 'Sender Address',
smtpHost: 'SMTP Host', smtpHost: 'SMTP Host',
smtpPort: 'SMTP Port', smtpPort: 'SMTP Port',
@@ -125,6 +126,7 @@ const NotificationsEmail: React.FC = () => {
<Formik <Formik
initialValues={{ initialValues={{
enabled: data.enabled, enabled: data.enabled,
userEmailRequired: data.options.userEmailRequired,
emailFrom: data.options.emailFrom, emailFrom: data.options.emailFrom,
smtpHost: data.options.smtpHost, smtpHost: data.options.smtpHost,
smtpPort: data.options.smtpPort ?? 587, smtpPort: data.options.smtpPort ?? 587,
@@ -148,6 +150,7 @@ const NotificationsEmail: React.FC = () => {
await axios.post('/api/v1/settings/notifications/email', { await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled, enabled: values.enabled,
options: { options: {
userEmailRequired: values.userEmailRequired,
emailFrom: values.emailFrom, emailFrom: values.emailFrom,
smtpHost: values.smtpHost, smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort), smtpPort: Number(values.smtpPort),
@@ -241,6 +244,18 @@ const NotificationsEmail: React.FC = () => {
<Field type="checkbox" id="enabled" name="enabled" /> <Field type="checkbox" id="enabled" name="enabled" />
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="userEmailRequired" className="checkbox-label">
{intl.formatMessage(messages.userEmailRequired)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="userEmailRequired"
name="userEmailRequired"
/>
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="senderName" className="text-label"> <label htmlFor="senderName" className="text-label">
{intl.formatMessage(messages.senderName)} {intl.formatMessage(messages.senderName)}

View File

@@ -188,35 +188,6 @@ const SettingsAbout: React.FC = () => {
</List.Item> </List.Item>
</List> </List>
</div> </div>
<div className="section">
<List title={intl.formatMessage(messages.supportoverseerr)}>
<List.Item
title={`${intl.formatMessage(messages.helppaycoffee)} ☕️`}
>
<a
href="https://github.com/sponsors/sct"
target="_blank"
rel="noreferrer"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://github.com/sponsors/sct
</a>
<Badge className="ml-2">
{intl.formatMessage(messages.preferredmethod)}
</Badge>
</List.Item>
<List.Item title="">
<a
href="https://patreon.com/overseerr"
target="_blank"
rel="noreferrer"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://patreon.com/overseerr
</a>
</List.Item>
</List>
</div>
<div className="section"> <div className="section">
<Releases currentVersion={data.version} /> <Releases currentVersion={data.version} />
</div> </div>

View File

@@ -12,30 +12,31 @@ import Badge from '../Common/Badge';
import Button from '../Common/Button'; import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import LibraryItem from './LibraryItem'; import LibraryItem from './LibraryItem';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
jellyfinsettings: 'Jellyfin Settings', jellyfinsettings: '{mediaServerName} Settings',
jellyfinsettingsDescription: jellyfinsettingsDescription:
'Configure the settings for your Jellyfin server. Jellyfin scans your Jellyfin libraries to see what content is available.', 'Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.',
timeout: 'Timeout', timeout: 'Timeout',
save: 'Save Changes', save: 'Save Changes',
saving: 'Saving…', saving: 'Saving…',
jellyfinlibraries: 'Jellyfin Libraries', jellyfinlibraries: '{mediaServerName} Libraries',
jellyfinlibrariesDescription: jellyfinlibrariesDescription:
'The libraries Jellyfin scans for titles. Click the button below if no libraries are listed.', 'The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.',
jellyfinSettingsFailure: jellyfinSettingsFailure:
'Something went wrong while saving Jellyfin settings.', 'Something went wrong while saving {mediaServerName} settings.',
jellyfinSettingsSuccess: 'Jellyfin settings saved successfully!', jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: 'Jellyfin Settings', jellyfinSettings: '{mediaServerName} Settings',
jellyfinSettingsDescription: jellyfinSettingsDescription:
'Optionally configure an external player endpoint for your jellyfin server that is different to the internal URL used during setup', 'Optionally configure an external player endpoint for your {mediaServerName} server that is different to the internal URL used during setup',
externalUrl: 'External URL', externalUrl: 'External URL',
validationUrl: 'You must provide a valid URL', validationUrl: 'You must provide a valid URL',
syncing: 'Syncing', syncing: 'Syncing',
syncJellyfin: 'Sync Libraries', syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan', manualscanJellyfin: 'Manual Library Scan',
manualscanDescriptionJellyfin: manualscanDescriptionJellyfin:
"Normally, this will only be run once every 24 hours. Jellyfin will check your Jellyfin server's recently added more aggressively. If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended!", "Normally, this will only be run once every 24 hours. Jellyseerr will check your {mediaServerName} server's recently added more aggressively. If this is your first time configuring Jellyseerr, a one-time full manual library scan is recommended!",
notrunning: 'Not Running', notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}', currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}', librariesRemaining: 'Libraries Remaining: {count}',
@@ -80,6 +81,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
); );
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { publicRuntimeConfig } = getConfig();
const JellyfinSettingsSchema = Yup.object().shape({ const JellyfinSettingsSchema = Yup.object().shape({
jellyfinExternalUrl: Yup.string().matches( jellyfinExternalUrl: Yup.string().matches(
@@ -161,10 +163,22 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
<> <>
<div className="mb-6"> <div className="mb-6">
<h3 className="heading"> <h3 className="heading">
<FormattedMessage {...messages.jellyfinlibraries} /> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinlibraries, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinlibraries, {
mediaServerName: 'Jellyfin',
})}
</h3> </h3>
<p className="description"> <p className="description">
<FormattedMessage {...messages.jellyfinlibrariesDescription} /> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinlibrariesDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinlibrariesDescription, {
mediaServerName: 'Jellyfin',
})}
</p> </p>
</div> </div>
<div className="section"> <div className="section">
@@ -201,7 +215,13 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
<FormattedMessage {...messages.manualscanJellyfin} /> <FormattedMessage {...messages.manualscanJellyfin} />
</h3> </h3>
<p className="description"> <p className="description">
<FormattedMessage {...messages.manualscanDescriptionJellyfin} /> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.manualscanDescriptionJellyfin, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.manualscanDescriptionJellyfin, {
mediaServerName: 'Jellyfin',
})}
</p> </p>
</div> </div>
<div className="section"> <div className="section">
@@ -305,10 +325,22 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
<> <>
<div className="mt-10 mb-6"> <div className="mt-10 mb-6">
<h3 className="heading"> <h3 className="heading">
{intl.formatMessage(messages.jellyfinSettings)} {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Jellyfin',
})}
</h3> </h3>
<p className="description"> <p className="description">
{intl.formatMessage(messages.jellyfinSettingsDescription)} {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Jellyfin',
})}
</p> </p>
</div> </div>
<Formik <Formik
@@ -322,15 +354,31 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
externalHostname: values.jellyfinExternalUrl, externalHostname: values.jellyfinExternalUrl,
} as JellyfinSettings); } as JellyfinSettings);
addToast(intl.formatMessage(messages.jellyfinSettingsSuccess), { addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
}); }
);
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.jellyfinSettingsFailure), { addToast(
intl.formatMessage(messages.jellyfinSettingsFailure, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true, autoDismiss: true,
appearance: 'error', appearance: 'error',
}); }
);
} finally { } finally {
revalidate(); revalidate();
} }

View File

@@ -10,9 +10,11 @@ import {
} from 'react-intl'; } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import { MediaServerType } from '../../../../server/constants/server';
import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces'; import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces';
import { JobId } from '../../../../server/lib/settings'; import { JobId } from '../../../../server/lib/settings';
import Spinner from '../../../assets/spinner.svg'; import Spinner from '../../../assets/spinner.svg';
import useSettings from '../../../hooks/useSettings';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import { formatBytes } from '../../../utils/numberHelpers'; import { formatBytes } from '../../../utils/numberHelpers';
import Badge from '../../Common/Badge'; import Badge from '../../Common/Badge';
@@ -102,6 +104,7 @@ const SettingsJobs: React.FC = () => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5); const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5);
const [jobScheduleHours, setJobScheduleHours] = useState(1); const [jobScheduleHours, setJobScheduleHours] = useState(1);
const settings = useSettings();
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -369,7 +372,15 @@ const SettingsJobs: React.FC = () => {
</tr> </tr>
</thead> </thead>
<Table.TBody> <Table.TBody>
{cacheData?.map((cache) => ( {cacheData
?.filter(
(cache) =>
!(
settings.currentSettings.mediaServerType !==
MediaServerType.PLEX && cache.id === 'plexguid'
)
)
.map((cache) => (
<tr key={`cache-list-${cache.id}`}> <tr key={`cache-list-${cache.id}`}>
<Table.TD>{cache.name}</Table.TD> <Table.TD>{cache.name}</Table.TD>
<Table.TD>{intl.formatNumber(cache.stats.hits)}</Table.TD> <Table.TD>{intl.formatNumber(cache.stats.hits)}</Table.TD>
@@ -378,7 +389,10 @@ const SettingsJobs: React.FC = () => {
<Table.TD>{formatBytes(cache.stats.ksize)}</Table.TD> <Table.TD>{formatBytes(cache.stats.ksize)}</Table.TD>
<Table.TD>{formatBytes(cache.stats.vsize)}</Table.TD> <Table.TD>{formatBytes(cache.stats.vsize)}</Table.TD>
<Table.TD alignText="right"> <Table.TD alignText="right">
<Button buttonType="danger" onClick={() => flushCache(cache)}> <Button
buttonType="danger"
onClick={() => flushCache(cache)}
>
<TrashIcon /> <TrashIcon />
<span>{intl.formatMessage(messages.flushcache)}</span> <span>{intl.formatMessage(messages.flushcache)}</span>
</Button> </Button>

View File

@@ -1,5 +1,8 @@
import getConfig from 'next/config';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs'; import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs';
@@ -8,7 +11,7 @@ const messages = defineMessages({
menuGeneralSettings: 'General', menuGeneralSettings: 'General',
menuUsers: 'Users', menuUsers: 'Users',
menuPlexSettings: 'Plex', menuPlexSettings: 'Plex',
menuJellyfinSettings: 'Jellyfin', menuJellyfinSettings: '{mediaServerName}',
menuServices: 'Services', menuServices: 'Services',
menuNotifications: 'Notifications', menuNotifications: 'Notifications',
menuLogs: 'Logs', menuLogs: 'Logs',
@@ -18,7 +21,8 @@ const messages = defineMessages({
const SettingsLayout: React.FC = ({ children }) => { const SettingsLayout: React.FC = ({ children }) => {
const intl = useIntl(); const intl = useIntl();
const { publicRuntimeConfig } = getConfig();
const settings = useSettings();
const settingsRoutes: SettingsRoute[] = [ const settingsRoutes: SettingsRoute[] = [
{ {
text: intl.formatMessage(messages.menuGeneralSettings), text: intl.formatMessage(messages.menuGeneralSettings),
@@ -30,13 +34,14 @@ const SettingsLayout: React.FC = ({ children }) => {
route: '/settings/users', route: '/settings/users',
regex: /^\/settings\/users/, regex: /^\/settings\/users/,
}, },
{ settings.currentSettings.mediaServerType === MediaServerType.PLEX
? {
text: intl.formatMessage(messages.menuPlexSettings), text: intl.formatMessage(messages.menuPlexSettings),
route: '/settings/plex', route: '/settings/plex',
regex: /^\/settings\/plex/, regex: /^\/settings\/plex/,
}, }
{ : {
text: intl.formatMessage(messages.menuJellyfinSettings), text: getAvailableMediaServerName(),
route: '/settings/jellyfin', route: '/settings/jellyfin',
regex: /^\/settings\/jellyfin/, regex: /^\/settings\/jellyfin/,
}, },
@@ -76,6 +81,12 @@ const SettingsLayout: React.FC = ({ children }) => {
<div className="mt-10 text-white">{children}</div> <div className="mt-10 text-white">{children}</div>
</> </>
); );
function getAvailableMediaServerName() {
return intl.formatMessage(messages.menuJellyfinSettings, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE === 'emby' ? 'Emby' : 'Jellyfin',
});
}
}; };
export default SettingsLayout; export default SettingsLayout;

View File

@@ -14,6 +14,7 @@ import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import PermissionEdit from '../../PermissionEdit'; import PermissionEdit from '../../PermissionEdit';
import QuotaSelector from '../../QuotaSelector'; import QuotaSelector from '../../QuotaSelector';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
users: 'Users', users: 'Users',
@@ -42,6 +43,7 @@ const SettingsUsers: React.FC = () => {
mutate: revalidate, mutate: revalidate,
} = useSWR<MainSettings>('/api/v1/settings/main'); } = useSWR<MainSettings>('/api/v1/settings/main');
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
if (!data && !error) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -131,7 +133,9 @@ const SettingsUsers: React.FC = () => {
<label htmlFor="newPlexLogin" className="checkbox-label"> <label htmlFor="newPlexLogin" className="checkbox-label">
{intl.formatMessage(messages.newPlexLogin, { {intl.formatMessage(messages.newPlexLogin, {
mediaServerName: mediaServerName:
settings.currentSettings.mediaServerType === publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? 'Plex'
: 'Jellyfin', : 'Jellyfin',
@@ -139,7 +143,9 @@ const SettingsUsers: React.FC = () => {
<span className="label-tip"> <span className="label-tip">
{intl.formatMessage(messages.newPlexLoginTip, { {intl.formatMessage(messages.newPlexLoginTip, {
mediaServerName: mediaServerName:
settings.currentSettings.mediaServerType === publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? 'Plex'
: 'Jellyfin', : 'Jellyfin',

View File

@@ -5,7 +5,7 @@ import { useUser } from '../../hooks/useUser';
import PlexLoginButton from '../PlexLoginButton'; import PlexLoginButton from '../PlexLoginButton';
const messages = defineMessages({ const messages = defineMessages({
welcome: 'Welcome to Overseerr', welcome: 'Welcome to Jellyseerr',
signinMessage: 'Get started by signing in with your Plex account', signinMessage: 'Get started by signing in with your Plex account',
}); });

View File

@@ -3,14 +3,15 @@ import { useUser } from '../../hooks/useUser';
import PlexLoginButton from '../PlexLoginButton'; import PlexLoginButton from '../PlexLoginButton';
import JellyfinLogin from '../Login/JellyfinLogin'; import JellyfinLogin from '../Login/JellyfinLogin';
import axios from 'axios'; import axios from 'axios';
import { defineMessages, FormattedMessage } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Accordion from '../Common/Accordion'; import Accordion from '../Common/Accordion';
import { MediaServerType } from '../../../server/constants/server'; import { MediaServerType } from '../../../server/constants/server';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
welcome: 'Welcome to Overseerr', welcome: 'Welcome to Jellyseerr',
signinMessage: 'Get started by signing in', signinMessage: 'Get started by signing in',
signinWithJellyfin: 'Use your Jellyfin account', signinWithJellyfin: 'Use your {mediaServerName} account',
signinWithPlex: 'Use your Plex account', signinWithPlex: 'Use your Plex account',
}); });
@@ -24,7 +25,8 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
MediaServerType.NOT_CONFIGURED MediaServerType.NOT_CONFIGURED
); );
const { user, revalidate } = useUser(); const { user, revalidate } = useUser();
const intl = useIntl();
const { publicRuntimeConfig } = getConfig();
// 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 _shouid_ come back with a valid user.
@@ -91,7 +93,13 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
}`} }`}
onClick={() => handleClick(1)} onClick={() => handleClick(1)}
> >
<FormattedMessage {...messages.signinWithJellyfin} /> {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.signinWithJellyfin, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.signinWithJellyfin, {
mediaServerName: 'Jellyfin',
})}
</button> </button>
<AccordionContent isOpen={openIndexes.includes(1)}> <AccordionContent isOpen={openIndexes.includes(1)}>
<div <div

View File

@@ -44,6 +44,7 @@ import RequestButton from '../RequestButton';
import RequestModal from '../RequestModal'; import RequestModal from '../RequestModal';
import Slider from '../Slider'; import Slider from '../Slider';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
firstAirDate: 'First Air Date', firstAirDate: 'First Air Date',
@@ -85,6 +86,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
router.query.manage == '1' ? true : false router.query.manage == '1' ? true : false
); );
const [showIssueModal, setShowIssueModal] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false);
const { publicRuntimeConfig } = getConfig();
const { const {
data, data,
@@ -124,10 +126,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}) })
) { ) {
mediaLinks.push({ mediaLinks.push({
text: text: getAvalaibleMediaServerName(),
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl, url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -141,10 +140,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}) })
) { ) {
mediaLinks.push({ mediaLinks.push({
text: text: getAvalaible4kMediaServerName(),
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl4k, url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />, svg: <PlayIcon />,
}); });
@@ -228,6 +224,30 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region) data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
?.flatrate ?? []; ?.flatrate ?? [];
function getAvalaibleMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
return intl.formatMessage(messages.play, { mediaServerName: 'Plex' });
}
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
function getAvalaible4kMediaServerName() {
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
}
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
return intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' });
}
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
}
return ( return (
<div <div
className="media-page" className="media-page"

View File

@@ -8,6 +8,8 @@ import useSettings from '../../hooks/useSettings';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert'; import Alert from '../Common/Alert';
import Modal from '../Common/Modal'; import Modal from '../Common/Modal';
import getConfig from 'next/config';
import { UserResultsResponse } from '../../../server/interfaces/api/userInterfaces';
interface JellyfinImportProps { interface JellyfinImportProps {
onCancel?: () => void; onCancel?: () => void;
@@ -15,23 +17,25 @@ interface JellyfinImportProps {
} }
const messages = defineMessages({ const messages = defineMessages({
importfromJellyfin: 'Import Jellyfin Users', importfromJellyfin: 'Import {mediaServerName} Users',
importfromJellyfinerror: importfromJellyfinerror:
'Something went wrong while importing Jellyfin users.', 'Something went wrong while importing {mediaServerName} users.',
importedfromJellyfin: importedfromJellyfin:
'<strong>{userCount}</strong> Jellyfin {userCount, plural, one {user} other {users}} imported successfully!', '<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!',
user: 'User', user: 'User',
noJellyfinuserstoimport: 'There are no Jellyfin users to import.', noJellyfinuserstoimport: 'There are no {mediaServerName} users to import.',
newJellyfinsigninenabled: newJellyfinsigninenabled:
'The <strong>Enable New Jellyfin Sign-In</strong> setting is currently enabled. Jellyfin users with library access do not need to be imported in order to sign in.', 'The <strong>Enable New {mediaServerName} Sign-In</strong> setting is currently enabled. {mediaServerName} users with library access do not need to be imported in order to sign in.',
}); });
const JellyfinImportModal: React.FC<JellyfinImportProps> = ({ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
onCancel, onCancel,
onComplete, onComplete,
children,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts(); const { addToast } = useToasts();
const [isImporting, setImporting] = useState(false); const [isImporting, setImporting] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@@ -47,6 +51,18 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
revalidateOnMount: true, revalidateOnMount: true,
}); });
const { data: existingUsers } = useSWR<UserResultsResponse>(
`/api/v1/user?take=${children}`
);
data?.forEach((user, pos) => {
if (
existingUsers?.results.some((data) => data.jellyfinUserId === user.id)
) {
data?.splice(pos, 1);
}
});
const importUsers = async () => { const importUsers = async () => {
setImporting(true); setImporting(true);
@@ -66,6 +82,8 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
strong: function strong(msg) { strong: function strong(msg) {
return <strong>{msg}</strong>; return <strong>{msg}</strong>;
}, },
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}), }),
{ {
autoDismiss: true, autoDismiss: true,
@@ -77,10 +95,16 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
onComplete(); onComplete();
} }
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.importfromJellyfinerror), { addToast(
intl.formatMessage(messages.importfromJellyfinerror, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
}),
{
autoDismiss: true, autoDismiss: true,
appearance: 'error', appearance: 'error',
}); }
);
} finally { } finally {
setImporting(false); setImporting(false);
} }
@@ -110,7 +134,10 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
return ( return (
<Modal <Modal
loading={!data && !error} loading={!data && !error}
title={intl.formatMessage(messages.importfromJellyfin)} title={intl.formatMessage(messages.importfromJellyfin, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})}
iconSvg={<InboxInIcon />} iconSvg={<InboxInIcon />}
onOk={() => { onOk={() => {
importUsers(); importUsers();
@@ -126,6 +153,10 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
{settings.currentSettings.newPlexLogin && ( {settings.currentSettings.newPlexLogin && (
<Alert <Alert
title={intl.formatMessage(messages.newJellyfinsigninenabled, { title={intl.formatMessage(messages.newJellyfinsigninenabled, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
strong: function strong(msg) { strong: function strong(msg) {
return ( return (
<strong className="font-semibold text-white">{msg}</strong> <strong className="font-semibold text-white">{msg}</strong>
@@ -240,7 +271,10 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
</> </>
) : ( ) : (
<Alert <Alert
title={intl.formatMessage(messages.noJellyfinuserstoimport)} title={intl.formatMessage(messages.noJellyfinuserstoimport, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
})}
type="info" type="info"
/> />
)} )}

View File

@@ -9,6 +9,7 @@ import {
} from '@heroicons/react/solid'; } from '@heroicons/react/solid';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -40,7 +41,7 @@ import PlexImportModal from './PlexImportModal';
const messages = defineMessages({ const messages = defineMessages({
users: 'Users', users: 'Users',
userlist: 'User List', userlist: 'User List',
importfromplex: 'Import {mediaServerName} Users', importfrommediaserver: 'Import {mediaServerName} Users',
user: 'User', user: 'User',
totalrequests: 'Requests', totalrequests: 'Requests',
accounttype: 'Type', accounttype: 'Type',
@@ -87,6 +88,7 @@ const UserList: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter(); const router = useRouter();
const settings = useSettings(); const settings = useSettings();
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [currentSort, setCurrentSort] = useState<Sort>('displayname'); const [currentSort, setCurrentSort] = useState<Sort>('displayname');
@@ -480,7 +482,9 @@ const UserList: React.FC = () => {
setShowImportModal(false); setShowImportModal(false);
revalidate(); revalidate();
}} }}
/> >
{data.pageInfo.results}
</JellyfinImportModal>
)} )}
</Transition> </Transition>
@@ -503,12 +507,17 @@ const UserList: React.FC = () => {
> >
<InboxInIcon /> <InboxInIcon />
<span> <span>
{intl.formatMessage(messages.importfromplex, { {publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
mediaServerName: ? intl.formatMessage(messages.importfrommediaserver, {
settings.currentSettings.mediaServerType === mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX MediaServerType.PLEX
? 'Plex' ? intl.formatMessage(messages.importfrommediaserver, {
: 'Jellyfin', mediaServerName: 'Plex',
})
: intl.formatMessage(messages.importfrommediaserver, {
mediaServerName: 'Jellyfin',
})} })}
</span> </span>
</Button> </Button>
@@ -596,7 +605,7 @@ const UserList: React.FC = () => {
<Link href={`/users/${user.id}`}> <Link href={`/users/${user.id}`}>
<a className="h-10 w-10 flex-shrink-0"> <a className="h-10 w-10 flex-shrink-0">
<img <img
className="h-10 w-10 rounded-full" className="h-10 w-10 rounded-full object-cover"
src={user.avatar} src={user.avatar}
alt="" alt=""
/> />
@@ -636,17 +645,23 @@ const UserList: React.FC = () => {
<Badge badgeType="warning"> <Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)} {intl.formatMessage(messages.plexuser)}
</Badge> </Badge>
) : ( ) : user.userType === UserType.LOCAL ? (
<Badge badgeType="default"> <Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
<Badge badgeType="success">
{intl.formatMessage(messages.mediaServerUser, { {intl.formatMessage(messages.mediaServerUser, {
mediaServerName: mediaServerName: 'Emby',
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})} })}
</Badge> </Badge>
)} ) : user.userType === UserType.JELLYFIN ? (
<Badge badgeType="default">
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName: 'Jellyfin',
})}
</Badge>
) : null}
</Table.TD> </Table.TD>
<Table.TD> <Table.TD>
{user.id === 1 {user.id === 1

View File

@@ -44,7 +44,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="relative"> <div className="relative">
<img <img
className="h-24 w-24 rounded-full bg-gray-600 ring-1 ring-gray-700" className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
src={user.avatar} src={user.avatar}
alt="" alt=""
/> />

View File

@@ -7,7 +7,6 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { MediaServerType } from '../../../../../server/constants/server';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import { import {
availableLanguages, availableLanguages,
@@ -25,11 +24,13 @@ import PageTitle from '../../../Common/PageTitle';
import LanguageSelector from '../../../LanguageSelector'; import LanguageSelector from '../../../LanguageSelector';
import QuotaSelector from '../../../QuotaSelector'; import QuotaSelector from '../../../QuotaSelector';
import RegionSelector from '../../../RegionSelector'; import RegionSelector from '../../../RegionSelector';
import getConfig from 'next/config';
const messages = defineMessages({ const messages = defineMessages({
general: 'General', general: 'General',
generalsettings: 'General Settings', generalsettings: 'General Settings',
displayName: 'Display Name', displayName: 'Display Name',
email: 'Email',
save: 'Save Changes', save: 'Save Changes',
saving: 'Saving…', saving: 'Saving…',
mediaServerUser: '{mediaServerName} User', mediaServerUser: '{mediaServerName} User',
@@ -59,7 +60,7 @@ const messages = defineMessages({
const UserGeneralSettings: React.FC = () => { const UserGeneralSettings: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { locale, setLocale } = useLocale(); const { locale, setLocale } = useLocale();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false); const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
@@ -120,8 +121,9 @@ const UserGeneralSettings: React.FC = () => {
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{
displayName: data?.username, displayName: data?.username ?? '',
discordId: data?.discordId, email: data?.email ?? '',
discordId: data?.discordId ?? '',
locale: data?.locale, locale: data?.locale,
region: data?.region, region: data?.region,
originalLanguage: data?.originalLanguage, originalLanguage: data?.originalLanguage,
@@ -136,6 +138,7 @@ const UserGeneralSettings: React.FC = () => {
try { try {
await axios.post(`/api/v1/user/${user?.id}/settings/main`, { await axios.post(`/api/v1/user/${user?.id}/settings/main`, {
username: values.displayName, username: values.displayName,
email: values.email,
discordId: values.discordId, discordId: values.discordId,
locale: values.locale, locale: values.locale,
region: values.region, region: values.region,
@@ -189,19 +192,25 @@ const UserGeneralSettings: React.FC = () => {
<div className="flex max-w-lg items-center"> <div className="flex max-w-lg items-center">
{user?.userType === UserType.PLEX ? ( {user?.userType === UserType.PLEX ? (
<Badge badgeType="warning"> <Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
) : user?.userType === UserType.LOCAL ? (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)} {intl.formatMessage(messages.localuser)}
</Badge> </Badge>
) : ( ) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
<Badge badgeType="default"> <Badge badgeType="success">
{intl.formatMessage(messages.mediaServerUser, { {intl.formatMessage(messages.mediaServerUser, {
mediaServerName: mediaServerName: 'Emby',
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})} })}
</Badge> </Badge>
)} ) : user?.userType === UserType.JELLYFIN ? (
<Badge badgeType="default">
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName: 'Jellyfin',
})}
</Badge>
) : null}
</div> </div>
</div> </div>
</div> </div>
@@ -239,6 +248,32 @@ const UserGeneralSettings: React.FC = () => {
)} )}
</div> </div>
</div> </div>
<div className="form-row">
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
{user?.warnings.find((w) => w === 'userEmailRequired') && (
<span className="label-required">*</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
placeholder="example@domain.com"
className={
user?.warnings.find((w) => w === 'userEmailRequired')
? 'border-2 border-red-400 focus:border-blue-600'
: ''
}
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
</div>
</div>
<div className="form-row"> <div className="form-row">
<label htmlFor="discordId" className="text-label"> <label htmlFor="discordId" className="text-label">
{intl.formatMessage(messages.discordId)} {intl.formatMessage(messages.discordId)}

View File

@@ -82,7 +82,9 @@ const useDiscover = <T extends BaseMedia, S = Record<string, never>>(
const isEmpty = !isLoadingInitialData && titles?.length === 0; const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd = const isReachingEnd =
isEmpty || (!!data && (data[data?.length - 1]?.results.length ?? 0) < 20); isEmpty ||
(!!data && (data[data?.length - 1]?.results.length ?? 0) < 20) ||
(!!data && (data[data?.length - 1]?.totalResults ?? 0) < 41);
return { return {
isLoadingInitialData, isLoadingInitialData,

View File

@@ -1,45 +1,61 @@
import useSWR from 'swr'; import useSWR from 'swr';
import { MediaRequest } from '../../server/entity/MediaRequest'; import { MediaRequest } from '../../server/entity/MediaRequest';
import { ServiceCommonServer } from '../../server/interfaces/api/serviceInterfaces'; import {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../../server/interfaces/api/serviceInterfaces';
interface OverrideStatus { interface OverrideStatus {
server: string | null; server?: string;
profile: number | null; profile?: string;
rootFolder: string | null; rootFolder?: string;
languageProfile?: string;
} }
const useRequestOverride = (request: MediaRequest): OverrideStatus => { const useRequestOverride = (request: MediaRequest): OverrideStatus => {
const { data } = useSWR<ServiceCommonServer[]>( const { data: allServers } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}` `/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}`
); );
if (!data) { const { data } = useSWR<ServiceCommonServerWithDetails>(
return { `/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}/${
server: null, request.serverId
profile: null, }`
rootFolder: null, );
};
if (!data || !allServers) {
return {};
} }
const defaultServer = data.find( const defaultServer = allServers.find(
(server) => server.is4k === request.is4k && server.isDefault (server) => server.is4k === request.is4k && server.isDefault
); );
const activeServer = data.find((server) => server.id === request.serverId); const activeServer = allServers.find(
(server) => server.id === request.serverId
);
return { return {
server: server:
activeServer && request.serverId !== defaultServer?.id activeServer && request.serverId !== defaultServer?.id
? activeServer.name ? activeServer.name
: null, : undefined,
profile: profile:
defaultServer?.activeProfileId !== request.profileId defaultServer?.activeProfileId !== request.profileId
? request.profileId ? data.profiles.find((profile) => profile.id === request.profileId)
: null, ?.name
: undefined,
rootFolder: rootFolder:
defaultServer?.activeDirectory !== request.rootFolder defaultServer?.activeDirectory !== request.rootFolder
? request.rootFolder ? request.rootFolder
: null, : undefined,
languageProfile:
request.type === 'tv' &&
defaultServer?.activeLanguageProfileId !== request.languageProfileId
? data.languageProfiles?.find(
(profile) => profile.id === request.languageProfileId
)?.name
: undefined,
}; };
}; };

View File

@@ -1,9 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import type { UrlObject } from 'url';
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import useDebouncedState from './useDebouncedState';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import type { UrlObject } from 'url';
import type { Nullable } from '../utils/typeHelpers'; import type { Nullable } from '../utils/typeHelpers';
import useDebouncedState from './useDebouncedState';
type Url = string | UrlObject; type Url = string | UrlObject;
@@ -48,7 +48,7 @@ const useSearchInput = (): SearchObject => {
* in a new route. If we are, then we only replace the history. * in a new route. If we are, then we only replace the history.
*/ */
useEffect(() => { useEffect(() => {
if (debouncedValue !== '') { if (debouncedValue !== '' && searchOpen) {
if (router.pathname.startsWith('/search')) { if (router.pathname.startsWith('/search')) {
router.replace({ router.replace({
pathname: router.pathname, pathname: router.pathname,

View File

@@ -13,6 +13,7 @@ export type { PermissionCheckOptions };
export interface User { export interface User {
id: number; id: number;
warnings: string[];
plexUsername?: string; plexUsername?: string;
username?: string; username?: string;
displayName: string; displayName: string;

View File

@@ -686,7 +686,8 @@
"components.UserList.nouserstoimport": "No hi ha usuaris nous de Plex a importar.", "components.UserList.nouserstoimport": "No hi ha usuaris nous de Plex a importar.",
"components.UserList.localuser": "Usuari local", "components.UserList.localuser": "Usuari local",
"components.UserList.importfromplexerror": "S'ha produït un error en importar usuaris de Plex.", "components.UserList.importfromplexerror": "S'ha produït un error en importar usuaris de Plex.",
"components.UserList.importfromplex": "Importeu usuaris de {mediaServerName}", "components.UserList.importfrommediaserver": "Importeu usuaris de {mediaServerName}",
"components.UserList.importfromplex": "Importeu usuaris de Plex",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {usuari} other {usuaris}} de Plex importat correctament!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {usuari} other {usuaris}} de Plex importat correctament!",
"components.TvDetails.watchtrailer": "Veure el tràiler", "components.TvDetails.watchtrailer": "Veure el tràiler",
"components.TvDetails.viewfullcrew": "Mostreu equip complet", "components.TvDetails.viewfullcrew": "Mostreu equip complet",

View File

@@ -850,7 +850,8 @@
"components.UserList.nouserstoimport": "Ingen nye brugere som kan importeres fra Plex.", "components.UserList.nouserstoimport": "Ingen nye brugere som kan importeres fra Plex.",
"components.UserList.edituser": "Redigér Brugertilladelser", "components.UserList.edituser": "Redigér Brugertilladelser",
"components.UserList.email": "Email Adresse", "components.UserList.email": "Email Adresse",
"components.UserList.importfromplex": "Importér Brugere fra {mediaServerName}", "components.UserList.importfrommediaserver": "Importér Brugere fra {mediaServerName}",
"components.UserList.importfromplex": "Importér Brugere fra Plex",
"components.UserList.owner": "Ejer", "components.UserList.owner": "Ejer",
"components.UserList.password": "Kodeord", "components.UserList.password": "Kodeord",
"components.UserList.passwordinfodescription": "Konfigurér en applikations-URL og aktivér emailnotifikationer for at tillade automatisk kodeordsgenerering.", "components.UserList.passwordinfodescription": "Konfigurér en applikations-URL og aktivér emailnotifikationer for at tillade automatisk kodeordsgenerering.",

View File

@@ -223,7 +223,8 @@
"components.Settings.SettingsAbout.Releases.latestversion": "Neuste", "components.Settings.SettingsAbout.Releases.latestversion": "Neuste",
"components.Settings.SettingsAbout.Releases.currentversion": "Aktuell", "components.Settings.SettingsAbout.Releases.currentversion": "Aktuell",
"components.UserList.importfromplexerror": "Beim Importieren von Plex-Benutzern ist etwas schief gelaufen.", "components.UserList.importfromplexerror": "Beim Importieren von Plex-Benutzern ist etwas schief gelaufen.",
"components.UserList.importfromplex": "{mediaServerName}-Benutzer importieren", "components.UserList.importfrommediaserver": "{mediaServerName}-Benutzer importieren",
"components.UserList.importfromplex": "Plex-Benutzer importieren",
"components.TvDetails.viewfullcrew": "Komplette Crew anzeigen", "components.TvDetails.viewfullcrew": "Komplette Crew anzeigen",
"components.TvDetails.TvCrew.fullseriescrew": "Komplette Serien-Crew", "components.TvDetails.TvCrew.fullseriescrew": "Komplette Serien-Crew",
"components.PersonDetails.crewmember": "Crew", "components.PersonDetails.crewmember": "Crew",

View File

@@ -602,7 +602,8 @@
"components.UserList.localuser": "Τοπικός χρήστης", "components.UserList.localuser": "Τοπικός χρήστης",
"components.UserList.localLoginDisabled": "Η ρύθμιση <strong>Ενεργοποίηση τοπικής σύνδεσης</strong> είναι προς το παρόν απενεργοποιημένη.", "components.UserList.localLoginDisabled": "Η ρύθμιση <strong>Ενεργοποίηση τοπικής σύνδεσης</strong> είναι προς το παρόν απενεργοποιημένη.",
"components.UserList.importfromplexerror": "Κάτι πήγε στραβά κατά την εισαγωγή χρηστών από το Plex.", "components.UserList.importfromplexerror": "Κάτι πήγε στραβά κατά την εισαγωγή χρηστών από το Plex.",
"components.UserList.importfromplex": "Εισαγωγή χρηστών από το {mediaServerName}", "components.UserList.importfrommediaserver": "Εισαγωγή χρηστών από το {mediaServerName}",
"components.UserList.importfromplex": "Εισαγωγή χρηστών από το Plex",
"components.UserList.importedfromplex": "{userCount, plural, one {# νέου χρήστη} other {#νέοι χρήστες}} εισήχθησαν απο το Plex επιτυχώς!", "components.UserList.importedfromplex": "{userCount, plural, one {# νέου χρήστη} other {#νέοι χρήστες}} εισήχθησαν απο το Plex επιτυχώς!",
"components.UserList.email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", "components.UserList.email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου",
"components.UserList.edituser": "Επεξεργασία δικαιωμάτων χρήστη", "components.UserList.edituser": "Επεξεργασία δικαιωμάτων χρήστη",

View File

@@ -269,6 +269,7 @@
"components.QuotaSelector.unlimited": "Unlimited", "components.QuotaSelector.unlimited": "Unlimited",
"components.RegionSelector.regionDefault": "All Regions", "components.RegionSelector.regionDefault": "All Regions",
"components.RegionSelector.regionServerDefault": "Default ({region})", "components.RegionSelector.regionServerDefault": "Default ({region})",
"components.RequestBlock.languageprofile": "Language Profile",
"components.RequestBlock.profilechanged": "Quality Profile", "components.RequestBlock.profilechanged": "Quality Profile",
"components.RequestBlock.requestoverrides": "Request Overrides", "components.RequestBlock.requestoverrides": "Request Overrides",
"components.RequestBlock.rootfolder": "Root Folder", "components.RequestBlock.rootfolder": "Root Folder",
@@ -860,7 +861,8 @@
"components.UserList.edituser": "Edit User Permissions", "components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address", "components.UserList.email": "Email Address",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} imported successfully!",
"components.UserList.importfromplex": "Import {mediaServerName} Users", "components.UserList.importfrommediaserver": "Import {mediaServerName} Users",
"components.UserList.importfromplex": "Import Plex Users",
"components.UserList.importfromplexerror": "Something went wrong while importing Plex users.", "components.UserList.importfromplexerror": "Something went wrong while importing Plex users.",
"components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.", "components.UserList.localLoginDisabled": "The <strong>Enable Local Sign-In</strong> setting is currently disabled.",
"components.UserList.localuser": "Local User", "components.UserList.localuser": "Local User",

View File

@@ -223,7 +223,8 @@
"components.Settings.SettingsAbout.Releases.currentversion": "Actual", "components.Settings.SettingsAbout.Releases.currentversion": "Actual",
"components.MovieDetails.studio": "{studioCount, plural, one {Estudio} other {Estudios}}", "components.MovieDetails.studio": "{studioCount, plural, one {Estudio} other {Estudios}}",
"components.UserList.importfromplexerror": "Algo salió mal importando usuarios de Plex.", "components.UserList.importfromplexerror": "Algo salió mal importando usuarios de Plex.",
"components.UserList.importfromplex": "Importar Usuarios de {mediaServerName}", "components.UserList.importfrommediaserver": "Importar Usuarios de {mediaServerName}",
"components.UserList.importfromplex": "Importar Usuarios de Plex",
"components.UserList.importedfromplex": "¡{userCount, plural, one {# nuevo usuario} other {# nuevos usuarios}} importado/s de Plex con éxito!", "components.UserList.importedfromplex": "¡{userCount, plural, one {# nuevo usuario} other {# nuevos usuarios}} importado/s de Plex con éxito!",
"components.TvDetails.viewfullcrew": "Ver Equipo Completo", "components.TvDetails.viewfullcrew": "Ver Equipo Completo",
"components.TvDetails.firstAirDate": "Primera fecha de emisión", "components.TvDetails.firstAirDate": "Primera fecha de emisión",

View File

@@ -223,7 +223,8 @@
"components.Settings.SettingsAbout.Releases.latestversion": "Dernière version", "components.Settings.SettingsAbout.Releases.latestversion": "Dernière version",
"components.Settings.SettingsAbout.Releases.currentversion": "Actuelle", "components.Settings.SettingsAbout.Releases.currentversion": "Actuelle",
"components.UserList.importfromplexerror": "Une erreur s'est produite durant l'importation des utilisateurs de Plex.", "components.UserList.importfromplexerror": "Une erreur s'est produite durant l'importation des utilisateurs de Plex.",
"components.UserList.importfromplex": "Importer les utilisateurs de {mediaServerName}", "components.UserList.importfrommediaserver": "Importer les utilisateurs de {mediaServerName}",
"components.UserList.importfromplex": "Importer les utilisateurs de Plex",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {utilisateur} other {utilisateurs}} importé(s) depuis Plex avec succès !", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {utilisateur} other {utilisateurs}} importé(s) depuis Plex avec succès !",
"components.TvDetails.viewfullcrew": "Voir l'équipe complète de la série", "components.TvDetails.viewfullcrew": "Voir l'équipe complète de la série",
"components.TvDetails.TvCrew.fullseriescrew": "Équipe complète de la série", "components.TvDetails.TvCrew.fullseriescrew": "Équipe complète de la série",

View File

@@ -165,7 +165,8 @@
"components.UserList.password": "Jelszó", "components.UserList.password": "Jelszó",
"components.UserList.localuser": "Helyi felhasználó", "components.UserList.localuser": "Helyi felhasználó",
"components.UserList.importfromplexerror": "Hiba történt a felhasználók Plex-ről történő importálása közben.", "components.UserList.importfromplexerror": "Hiba történt a felhasználók Plex-ről történő importálása közben.",
"components.UserList.importfromplex": "Felhasználók importálása {mediaServerName}-ről", "components.UserList.importfrommediaserver": "Felhasználók importálása {mediaServerName}-ről",
"components.UserList.importfromplex": "Felhasználók importálása Plex-ről",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Nem lett új} one {# új} other {# új}} felhasználó importálva Plex-ről!", "components.UserList.importedfromplex": "{userCount, plural, =0 {Nem lett új} one {# új} other {# új}} felhasználó importálva Plex-ről!",
"components.UserList.email": "E-mail-cím", "components.UserList.email": "E-mail-cím",
"components.UserList.deleteuser": "Felhasználó törlése", "components.UserList.deleteuser": "Felhasználó törlése",

View File

@@ -223,7 +223,8 @@
"components.Settings.SettingsAbout.Releases.latestversion": "Versione più recente", "components.Settings.SettingsAbout.Releases.latestversion": "Versione più recente",
"components.Settings.SettingsAbout.Releases.currentversion": "Versione attuale", "components.Settings.SettingsAbout.Releases.currentversion": "Versione attuale",
"components.UserList.importfromplexerror": "Qualcosa è andato storto nell'importare gli utenti Plex.", "components.UserList.importfromplexerror": "Qualcosa è andato storto nell'importare gli utenti Plex.",
"components.UserList.importfromplex": "Importa utenti {mediaServerName}", "components.UserList.importfrommediaserver": "Importa utenti {mediaServerName}",
"components.UserList.importfromplex": "Importa utenti Plex",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {utente} other {utenti}} Plex {userCount, plural, one {importato} other {importati}} correttamente!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {utente} other {utenti}} Plex {userCount, plural, one {importato} other {importati}} correttamente!",
"components.TvDetails.viewfullcrew": "Vedi troupe completa", "components.TvDetails.viewfullcrew": "Vedi troupe completa",
"components.TvDetails.TvCrew.fullseriescrew": "Troupe completa serie", "components.TvDetails.TvCrew.fullseriescrew": "Troupe completa serie",

View File

@@ -231,7 +231,8 @@
"components.TvDetails.watchtrailer": "予告編を見る", "components.TvDetails.watchtrailer": "予告編を見る",
"components.MovieDetails.watchtrailer": "予告編を見る", "components.MovieDetails.watchtrailer": "予告編を見る",
"components.UserList.importfromplexerror": "Plexからユーザーをインポート中に問題が発生しました。", "components.UserList.importfromplexerror": "Plexからユーザーをインポート中に問題が発生しました。",
"components.UserList.importfromplex": "{mediaServerName}からユーザーをインポート", "components.UserList.importfrommediaserver": "{mediaServerName}からユーザーをインポート",
"components.UserList.importfromplex": "Plexからユーザーをインポート",
"components.UserList.importedfromplex": "Plex から新ユーザー {userCount} 名をインポートしました。", "components.UserList.importedfromplex": "Plex から新ユーザー {userCount} 名をインポートしました。",
"components.TvDetails.viewfullcrew": "フルクルーを表示", "components.TvDetails.viewfullcrew": "フルクルーを表示",
"components.TvDetails.firstAirDate": "初放送日", "components.TvDetails.firstAirDate": "初放送日",

View File

@@ -194,7 +194,8 @@
"components.UserList.userssaved": "Brukertillatelsene ble lagret!", "components.UserList.userssaved": "Brukertillatelsene ble lagret!",
"components.UserList.users": "Brukere", "components.UserList.users": "Brukere",
"components.UserList.importfromplexerror": "Noe gikk galt ved importering av brukere fra Plex.", "components.UserList.importfromplexerror": "Noe gikk galt ved importering av brukere fra Plex.",
"components.UserList.importfromplex": "Importer brukere fra {mediaServerName}", "components.UserList.importfrommediaserver": "Importer brukere fra {mediaServerName}",
"components.UserList.importfromplex": "Importer brukere fra Plex",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {ny bruker} other {nye brukere}} ble importert fra Plex!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {ny bruker} other {nye brukere}} ble importert fra Plex!",
"components.Settings.menuUsers": "Brukere", "components.Settings.menuUsers": "Brukere",
"components.Settings.SettingsUsers.users": "Brukere", "components.Settings.SettingsUsers.users": "Brukere",

View File

@@ -214,7 +214,8 @@
"components.UserList.userdeleteerror": "Er ging iets mis bij het verwijderen van de gebruiker.", "components.UserList.userdeleteerror": "Er ging iets mis bij het verwijderen van de gebruiker.",
"components.UserList.userdeleted": "Gebruiker succesvol verwijderd!", "components.UserList.userdeleted": "Gebruiker succesvol verwijderd!",
"components.UserList.importfromplexerror": "Er is iets misgegaan bij het importeren van Plex-gebruikers.", "components.UserList.importfromplexerror": "Er is iets misgegaan bij het importeren van Plex-gebruikers.",
"components.UserList.importfromplex": "{mediaServerName}-gebruikers importeren", "components.UserList.importfrommediaserver": "{mediaServerName}-gebruikers importeren",
"components.UserList.importfromplex": "Plex-gebruikers importeren",
"components.UserList.deleteuser": "Gebruiker verwijderen", "components.UserList.deleteuser": "Gebruiker verwijderen",
"components.UserList.deleteconfirm": "Weet je zeker dat je deze gebruiker wilt verwijderen? Al hun bestaande aanvraaggegevens zullen worden verwijderd.", "components.UserList.deleteconfirm": "Weet je zeker dat je deze gebruiker wilt verwijderen? Al hun bestaande aanvraaggegevens zullen worden verwijderd.",
"components.TvDetails.watchtrailer": "Trailer bekijken", "components.TvDetails.watchtrailer": "Trailer bekijken",

View File

@@ -962,7 +962,8 @@
"components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Wyślij po cichu", "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Wyślij po cichu",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Nie udało się zapisać ustawień powiadomień telegram.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Nie udało się zapisać ustawień powiadomień telegram.",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "<FindDiscordIdLink>Wielocyfrowy numer ID</FindDiscordIdLink> powiązany z Twoim kontem użytkownika", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "<FindDiscordIdLink>Wielocyfrowy numer ID</FindDiscordIdLink> powiązany z Twoim kontem użytkownika",
"components.UserList.importfromplex": "Importuj użytkowników {mediaServerName}", "components.UserList.importfrommediaserver": "Importuj użytkowników {mediaServerName}",
"components.UserList.importfromplex": "Importuj użytkowników Plex",
"i18n.available": "Dostępny", "i18n.available": "Dostępny",
"components.UserList.sortDisplayName": "Wyświetlana nazwa", "components.UserList.sortDisplayName": "Wyświetlana nazwa",
"components.UserList.totalrequests": "Prośby", "components.UserList.totalrequests": "Prośby",

View File

@@ -228,7 +228,8 @@
"components.MovieDetails.viewfullcrew": "Ver Equipe Técnica Completa", "components.MovieDetails.viewfullcrew": "Ver Equipe Técnica Completa",
"components.MovieDetails.MovieCrew.fullcrew": "Equipe Técnica Completa", "components.MovieDetails.MovieCrew.fullcrew": "Equipe Técnica Completa",
"components.UserList.importfromplexerror": "Algo deu errado ao importar usuários do Plex.", "components.UserList.importfromplexerror": "Algo deu errado ao importar usuários do Plex.",
"components.UserList.importfromplex": "Importar Usuários do {mediaServerName}", "components.UserList.importfrommediaserver": "Importar Usuários do {mediaServerName}",
"components.UserList.importfromplex": "Importar Usuários do Plex",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {usuário Plex importado} other {usuários Plex importados}} com sucesso!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> {userCount, plural, one {usuário Plex importado} other {usuários Plex importados}} com sucesso!",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Habilitar Agente", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Habilitar Agente",
"components.RequestList.RequestItem.failedretry": "Algo deu errado ao retentar fazer a solicitação.", "components.RequestList.RequestItem.failedretry": "Algo deu errado ao retentar fazer a solicitação.",

View File

@@ -199,7 +199,8 @@
"components.UserList.passwordinfodescription": "Configurar um URL de aplicação e ativar as notificações por e-mail para permitir a geração automática de palavra-passe.", "components.UserList.passwordinfodescription": "Configurar um URL de aplicação e ativar as notificações por e-mail para permitir a geração automática de palavra-passe.",
"components.UserList.localuser": "Utilizador Local", "components.UserList.localuser": "Utilizador Local",
"components.UserList.importfromplexerror": "Ocorreu um erro ao importar utilizadores do Plex.", "components.UserList.importfromplexerror": "Ocorreu um erro ao importar utilizadores do Plex.",
"components.UserList.importfromplex": "Importar Utilizadores do {mediaServerName}", "components.UserList.importfrommediaserver": "Importar Utilizadores do {mediaServerName}",
"components.UserList.importfromplex": "Importar Utilizadores do Plex",
"components.UserList.importedfromplex": "{userCount, plural, one {# novo utilizador} other {# novos utilizadores}} importados do Plex com sucesso!", "components.UserList.importedfromplex": "{userCount, plural, one {# novo utilizador} other {# novos utilizadores}} importados do Plex com sucesso!",
"components.UserList.email": "Endereço de E-mail", "components.UserList.email": "Endereço de E-mail",
"components.UserList.deleteuser": "Apagar Utilizador", "components.UserList.deleteuser": "Apagar Utilizador",

View File

@@ -793,7 +793,8 @@
"components.UserList.usercreatedfailed": "Что-то пошло не так при создании пользователя.", "components.UserList.usercreatedfailed": "Что-то пошло не так при создании пользователя.",
"components.UserList.passwordinfodescription": "Настройте URL-адрес приложения и включите уведомления по электронной почте, чтобы обеспечить возможность автоматической генерации пароля.", "components.UserList.passwordinfodescription": "Настройте URL-адрес приложения и включите уведомления по электронной почте, чтобы обеспечить возможность автоматической генерации пароля.",
"components.UserList.importfromplexerror": "Что-то пошло не так при импорте пользователей из Plex.", "components.UserList.importfromplexerror": "Что-то пошло не так при импорте пользователей из Plex.",
"components.UserList.importfromplex": "Импортировать пользователей из {mediaServerName}", "components.UserList.importfrommediaserver": "Импортировать пользователей из {mediaServerName}",
"components.UserList.importfromplex": "Импортировать пользователей из Plex",
"components.UserList.importedfromplex": "{userCount, plural, one {# новый пользователь} other {# новых пользователя(ей)}} успешно импортированы из Plex!", "components.UserList.importedfromplex": "{userCount, plural, one {# новый пользователь} other {# новых пользователя(ей)}} успешно импортированы из Plex!",
"components.UserList.edituser": "Изменить разрешения пользователя", "components.UserList.edituser": "Изменить разрешения пользователя",
"components.UserList.displayName": "Отображаемое имя", "components.UserList.displayName": "Отображаемое имя",

View File

@@ -1006,7 +1006,8 @@
"components.UserProfile.UserSettings.UserPermissions.toastSettingsFailure": "Diçka shkoi keq duke ruajtur cilësimet.", "components.UserProfile.UserSettings.UserPermissions.toastSettingsFailure": "Diçka shkoi keq duke ruajtur cilësimet.",
"components.Settings.webAppUrlTip": "Në mënyrë opsionale drejto përdoruesit në aplikacionin web në serverin tënd në vend të atij web", "components.Settings.webAppUrlTip": "Në mënyrë opsionale drejto përdoruesit në aplikacionin web në serverin tënd në vend të atij web",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minuta", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minuta",
"components.UserList.importfromplex": "Importoni përdoruesit {mediaServerName}", "components.UserList.importfrommediaserver": "Importoni përdoruesit {mediaServerName}",
"components.UserList.importfromplex": "Importoni përdoruesit Plex",
"components.UserList.importfromplexerror": "Diçka shkoi keq duke importuar përdoruesit Plex.", "components.UserList.importfromplexerror": "Diçka shkoi keq duke importuar përdoruesit Plex.",
"components.TvDetails.firstAirDate": "Data e parë e transmetimit", "components.TvDetails.firstAirDate": "Data e parë e transmetimit",
"components.UserList.email": "Adresa email", "components.UserList.email": "Adresa email",

View File

@@ -222,7 +222,8 @@
"components.Settings.SettingsAbout.Releases.releasedataMissing": "Versionsdata är för närvarande inte tillgänglig.", "components.Settings.SettingsAbout.Releases.releasedataMissing": "Versionsdata är för närvarande inte tillgänglig.",
"components.Settings.SettingsAbout.Releases.latestversion": "Senaste Versionen", "components.Settings.SettingsAbout.Releases.latestversion": "Senaste Versionen",
"components.Settings.SettingsAbout.Releases.currentversion": "Aktuell", "components.Settings.SettingsAbout.Releases.currentversion": "Aktuell",
"components.UserList.importfromplex": "Importera {mediaServerName}användare", "components.UserList.importfrommediaserver": "Importera {mediaServerName}användare",
"components.UserList.importfromplex": "Importera Plexanvändare",
"components.UserList.importfromplexerror": "Något gick fel när Plexanvändare importerades.", "components.UserList.importfromplexerror": "Något gick fel när Plexanvändare importerades.",
"components.TvDetails.watchtrailer": "Kolla Trailer", "components.TvDetails.watchtrailer": "Kolla Trailer",
"components.Settings.Notifications.allowselfsigned": "Tillåt Självsignerade Certifikat", "components.Settings.Notifications.allowselfsigned": "Tillåt Självsignerade Certifikat",

View File

@@ -24,7 +24,8 @@
"components.UserList.localuser": "本地用户", "components.UserList.localuser": "本地用户",
"components.UserList.localLoginDisabled": "<strong>允许本地登录</strong>的设置目前被禁用。", "components.UserList.localLoginDisabled": "<strong>允许本地登录</strong>的设置目前被禁用。",
"components.UserList.importfromplexerror": "导入 Plex 用户时出错。", "components.UserList.importfromplexerror": "导入 Plex 用户时出错。",
"components.UserList.importfromplex": "导入 {mediaServerName} 用户", "components.UserList.importfrommediaserver": "导入 {mediaServerName} 用户",
"components.UserList.importfromplex": "导入 Plex 用户",
"components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} 成功导入!", "components.UserList.importedfromplex": "<strong>{userCount}</strong> Plex {userCount, plural, one {user} other {users}} 成功导入!",
"components.UserList.email": "电子邮件地址", "components.UserList.email": "电子邮件地址",
"components.UserList.edituser": "编辑用户权限", "components.UserList.edituser": "编辑用户权限",

View File

@@ -52,7 +52,8 @@
"components.Settings.radarrsettings": "Radarr 設定", "components.Settings.radarrsettings": "Radarr 設定",
"components.Settings.menuPlexSettings": "Plex", "components.Settings.menuPlexSettings": "Plex",
"components.UserList.importfromplexerror": "匯入 Plex 使用者時出了點問題。", "components.UserList.importfromplexerror": "匯入 Plex 使用者時出了點問題。",
"components.UserList.importfromplex": "匯入 {mediaServerName} 使用者", "components.UserList.importfrommediaserver": "匯入 {mediaServerName} 使用者",
"components.UserList.importfromplex": "匯入 Plex 使用者",
"components.UserList.importedfromplex": "匯入 <strong>{userCount}</strong> 個 Plex 使用者成功!", "components.UserList.importedfromplex": "匯入 <strong>{userCount}</strong> 個 Plex 使用者成功!",
"components.UserList.localuser": "本地使用者", "components.UserList.localuser": "本地使用者",
"components.UserList.creating": "創建中…", "components.UserList.creating": "創建中…",

View File

@@ -2,6 +2,7 @@
const defaultTheme = require('tailwindcss/defaultTheme'); const defaultTheme = require('tailwindcss/defaultTheme');
module.exports = { module.exports = {
important: true,
mode: 'jit', mode: 'jit',
content: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'], content: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'],
theme: { theme: {

View File

@@ -1622,6 +1622,13 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/eslint-plugin-next@^12.1.6":
version "12.1.6"
resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-12.1.6.tgz#dde3f98831f15923b25244588d924c716956292e"
integrity sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==
dependencies:
glob "7.1.7"
"@next/swc-android-arm64@12.1.0": "@next/swc-android-arm64@12.1.0":
version "12.1.0" version "12.1.0"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.0.tgz#865ba3a9afc204ff2bdeea49dd64d58705007a39" resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.1.0.tgz#865ba3a9afc204ff2bdeea49dd64d58705007a39"
@@ -4780,6 +4787,11 @@ email-templates@^8.0.10:
nodemailer "^6.7.2" nodemailer "^6.7.2"
preview-email "^3.0.5" preview-email "^3.0.5"
email-validator@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed"
integrity sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==
emoji-regex@^10.0.0: emoji-regex@^10.0.0:
version "10.0.1" version "10.0.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.0.1.tgz#77180edb279b99510a21b79b19e1dc283d8f3991" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.0.1.tgz#77180edb279b99510a21b79b19e1dc283d8f3991"