mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
23 Commits
test-disab
...
preview-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72a4a5ad8 | ||
|
|
6a51ade676 | ||
|
|
310d789bfb | ||
|
|
f3641d4cab | ||
|
|
2a1aab5d24 | ||
|
|
6ea76ecf31 | ||
|
|
62f97b385f | ||
|
|
6613254d43 | ||
|
|
8c4f37836f | ||
|
|
2700694a99 | ||
|
|
ce04315899 | ||
|
|
130bb298f7 | ||
|
|
226d451adf | ||
|
|
8384d411ba | ||
|
|
e640ff6c2e | ||
|
|
04b86c30e6 | ||
|
|
fe8c781cba | ||
|
|
43e0a29543 | ||
|
|
57336d8a75 | ||
|
|
822a0768cf | ||
|
|
e34881064a | ||
|
|
135155cd85 | ||
|
|
68952a9239 |
@@ -19,6 +19,7 @@
|
|||||||
"region": "",
|
"region": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
@@ -37,6 +38,17 @@
|
|||||||
],
|
],
|
||||||
"machineId": "test"
|
"machineId": "test"
|
||||||
},
|
},
|
||||||
|
"jellyfin": {
|
||||||
|
"name": "",
|
||||||
|
"ip": "",
|
||||||
|
"port": 8096,
|
||||||
|
"useSsl": false,
|
||||||
|
"urlBase": "",
|
||||||
|
"externalHostname": "",
|
||||||
|
"jellyfinForgotPasswordUrl": "",
|
||||||
|
"libraries": [],
|
||||||
|
"serverId": ""
|
||||||
|
},
|
||||||
"tautulli": {},
|
"tautulli": {},
|
||||||
"radarr": [],
|
"radarr": [],
|
||||||
"sonarr": [],
|
"sonarr": [],
|
||||||
@@ -139,11 +151,26 @@
|
|||||||
"sonarr-scan": {
|
"sonarr-scan": {
|
||||||
"schedule": "0 30 4 * * *"
|
"schedule": "0 30 4 * * *"
|
||||||
},
|
},
|
||||||
|
"plex-watchlist-sync": {
|
||||||
|
"schedule": "0 */10 * * * *"
|
||||||
|
},
|
||||||
|
"availability-sync": {
|
||||||
|
"schedule": "0 0 5 * * *"
|
||||||
|
},
|
||||||
"download-sync": {
|
"download-sync": {
|
||||||
"schedule": "0 * * * * *"
|
"schedule": "0 * * * * *"
|
||||||
},
|
},
|
||||||
"download-sync-reset": {
|
"download-sync-reset": {
|
||||||
"schedule": "0 0 1 * * *"
|
"schedule": "0 0 1 * * *"
|
||||||
|
},
|
||||||
|
"jellyfin-recently-added-scan": {
|
||||||
|
"schedule": "0 */5 * * * *"
|
||||||
|
},
|
||||||
|
"jellyfin-full-scan": {
|
||||||
|
"schedule": "0 0 3 * * *"
|
||||||
|
},
|
||||||
|
"image-cache-cleanup": {
|
||||||
|
"schedule": "0 0 5 * * *"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getSystemInfo(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const systemInfoResponse = await this.get<any>('/System/Info');
|
||||||
|
|
||||||
|
return systemInfoResponse;
|
||||||
|
} catch (e) {
|
||||||
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getServerName(): Promise<string> {
|
public async getServerName(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const serverResponse = await this.get<JellyfinUserResponse>(
|
const serverResponse = await this.get<JellyfinUserResponse>(
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ export enum ApiErrorCode {
|
|||||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
NotAdmin = 'NOT_ADMIN',
|
NotAdmin = 'NOT_ADMIN',
|
||||||
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
|
|||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
@@ -211,15 +212,12 @@ class Media {
|
|||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
const { serverId, hostname, externalHostname } = getSettings().jellyfin;
|
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
|
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
|
||||||
class AvailabilitySync {
|
class AvailabilitySync {
|
||||||
public running = false;
|
public running = false;
|
||||||
@@ -84,7 +85,7 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Library } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import AsyncLock from '@server/utils/asyncLock';
|
import AsyncLock from '@server/utils/asyncLock';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { randomUUID as uuid } from 'crypto';
|
import { randomUUID as uuid } from 'crypto';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
@@ -594,8 +595,10 @@ class JellyfinScanner {
|
|||||||
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
hostname,
|
||||||
admin.jellyfinAuthToken,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { runMigrations } from '@server/lib/settings/migrator';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { Permission } from './permissions';
|
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,7 +39,10 @@ export interface PlexSettings {
|
|||||||
|
|
||||||
export interface JellyfinSettings {
|
export interface JellyfinSettings {
|
||||||
name: string;
|
name: string;
|
||||||
hostname: string;
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
useSsl?: boolean;
|
||||||
|
urlBase?: string;
|
||||||
externalHostname?: string;
|
externalHostname?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
@@ -130,7 +134,6 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
region: string;
|
region: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
jellyfinHost?: string;
|
|
||||||
jellyfinExternalHost?: string;
|
jellyfinExternalHost?: string;
|
||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
jellyfinServerName?: string;
|
jellyfinServerName?: string;
|
||||||
@@ -274,7 +277,7 @@ export type JobId =
|
|||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync';
|
||||||
|
|
||||||
interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
vapidPublic: string;
|
vapidPublic: string;
|
||||||
vapidPrivate: string;
|
vapidPrivate: string;
|
||||||
@@ -291,7 +294,7 @@ interface AllSettings {
|
|||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
? `${process.env.CONFIG_DIRECTORY}/settings.json`
|
||||||
: path.join(__dirname, '../../config/settings.json');
|
: path.join(__dirname, '../../../config/settings.json');
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
private data: AllSettings;
|
private data: AllSettings;
|
||||||
@@ -331,7 +334,10 @@ class Settings {
|
|||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
ip: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
externalHostname: '',
|
externalHostname: '',
|
||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
@@ -547,8 +553,6 @@ class Settings {
|
|||||||
region: this.data.main.region,
|
region: this.data.main.region,
|
||||||
originalLanguage: this.data.main.originalLanguage,
|
originalLanguage: this.data.main.originalLanguage,
|
||||||
mediaServerType: this.main.mediaServerType,
|
mediaServerType: this.main.mediaServerType,
|
||||||
jellyfinHost: this.jellyfin.hostname,
|
|
||||||
jellyfinExternalHost: this.jellyfin.externalHostname,
|
|
||||||
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
|
||||||
cacheImages: this.data.main.cacheImages,
|
cacheImages: this.data.main.cacheImages,
|
||||||
vapidPublic: this.vapidPublic,
|
vapidPublic: this.vapidPublic,
|
||||||
@@ -637,7 +641,11 @@ class Settings {
|
|||||||
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
this.data = merge(this.data, JSON.parse(data));
|
const parsedJson = JSON.parse(data);
|
||||||
|
this.data = runMigrations(parsedJson);
|
||||||
|
|
||||||
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal file
30
server/lib/settings/migrations/0001_migrate_hostname.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
const migrateHostname = (settings: any): AllSettings => {
|
||||||
|
const oldJellyfinSettings = settings.jellyfin;
|
||||||
|
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
|
||||||
|
const { hostname } = oldJellyfinSettings;
|
||||||
|
const protocolMatch = hostname.match(/^(https?):\/\//i);
|
||||||
|
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
|
||||||
|
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
|
||||||
|
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
|
||||||
|
|
||||||
|
delete oldJellyfinSettings.hostname;
|
||||||
|
if (urlMatch) {
|
||||||
|
const [, ip, , port, urlBase] = urlMatch;
|
||||||
|
settings.jellyfin = {
|
||||||
|
...settings.jellyfin,
|
||||||
|
ip,
|
||||||
|
port: port || (useSsl ? 443 : 80),
|
||||||
|
useSsl,
|
||||||
|
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.jellyfin && settings.jellyfin.hostname) {
|
||||||
|
delete settings.jellyfin.hostname;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateHostname;
|
||||||
21
server/lib/settings/migrator.ts
Normal file
21
server/lib/settings/migrator.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
|
export const runMigrations = (settings: AllSettings): AllSettings => {
|
||||||
|
const migrations = fs
|
||||||
|
.readdirSync(migrationsDir)
|
||||||
|
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
.map((file) => require(path.join(migrationsDir, file)).default);
|
||||||
|
|
||||||
|
let migrated = settings;
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
migrated = migration(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
@@ -222,30 +223,39 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
urlBase?: string;
|
||||||
|
useSsl?: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
settings.jellyfin.hostname !== ''
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
} else if (!body.username) {
|
} else if (!body.username) {
|
||||||
return res.status(500).json({ error: 'You must provide an username' });
|
return res.status(500).json({ error: 'You must provide an username' });
|
||||||
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
|
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: 'Jellyfin hostname already configured' });
|
.json({ error: 'Jellyfin hostname already configured' });
|
||||||
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
|
} else if (settings.jellyfin.ip === '' && !body.hostname) {
|
||||||
return res.status(500).json({ error: 'No hostname provided.' });
|
return res.status(500).json({ error: 'No hostname provided.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostname =
|
const hostname =
|
||||||
settings.jellyfin.hostname !== ''
|
settings.jellyfin.ip !== ''
|
||||||
? settings.jellyfin.hostname
|
? getHostname()
|
||||||
: body.hostname ?? '';
|
: getHostname({
|
||||||
|
useSsl: body.useSsl,
|
||||||
|
ip: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
urlBase: body.urlBase,
|
||||||
|
});
|
||||||
|
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
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
|
||||||
@@ -261,17 +271,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
let jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
|
|
||||||
const ip = req.ip;
|
const ip = req.ip;
|
||||||
let clientIp;
|
let clientIp;
|
||||||
|
|
||||||
@@ -328,8 +335,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
const serverName = await jellyfinserver.getServerName();
|
const serverName = await jellyfinserver.getServerName();
|
||||||
|
|
||||||
settings.jellyfin.name = serverName;
|
settings.jellyfin.name = serverName;
|
||||||
settings.jellyfin.hostname = body.hostname ?? '';
|
|
||||||
settings.jellyfin.serverId = account.User.ServerId;
|
settings.jellyfin.serverId = account.User.ServerId;
|
||||||
|
settings.jellyfin.ip = body.hostname ?? '';
|
||||||
|
settings.jellyfin.port = body.port ?? 8096;
|
||||||
|
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||||
|
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||||
settings.save();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
@@ -444,7 +454,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
error: e.errorCode,
|
error: e.errorCode,
|
||||||
status: e.statusCode,
|
status: e.statusCode,
|
||||||
hostname: body.hostname,
|
hostname: getHostname({
|
||||||
|
useSsl: body.useSsl,
|
||||||
|
ip: body.hostname,
|
||||||
|
port: body.port,
|
||||||
|
urlBase: body.urlBase,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return next({
|
return next({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import PlexTvAPI from '@server/api/plextv';
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import TautulliAPI from '@server/api/tautulli';
|
import TautulliAPI from '@server/api/tautulli';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
@@ -24,8 +25,10 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import discoverSettingRoutes from '@server/routes/settings/discover';
|
import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||||
|
import { ApiError } from '@server/types/error';
|
||||||
import { appDataPath } from '@server/utils/appDataVolume';
|
import { appDataPath } from '@server/utils/appDataVolume';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -252,11 +255,59 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
|
|||||||
res.status(200).json(settings.jellyfin);
|
res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin', (req, res) => {
|
settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
settings.jellyfin = merge(settings.jellyfin, req.body);
|
try {
|
||||||
settings.save();
|
const admin = await userRepository.findOneOrFail({
|
||||||
|
where: { id: 1 },
|
||||||
|
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
|
||||||
|
|
||||||
|
const jellyfinClient = new JellyfinAPI(
|
||||||
|
getHostname(tempJellyfinSettings),
|
||||||
|
admin.jellyfinAuthToken ?? '',
|
||||||
|
admin.jellyfinDeviceId ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await jellyfinClient.getSystemInfo();
|
||||||
|
|
||||||
|
if (!result?.Id) {
|
||||||
|
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(settings.jellyfin, req.body);
|
||||||
|
settings.jellyfin.serverId = result.Id;
|
||||||
|
settings.jellyfin.name = result.ServerName;
|
||||||
|
settings.save();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
logger.error('Something went wrong testing Jellyfin connection', {
|
||||||
|
label: 'API',
|
||||||
|
status: e.statusCode,
|
||||||
|
errorMessage: ApiErrorCode.InvalidUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next({
|
||||||
|
status: e.statusCode,
|
||||||
|
message: ApiErrorCode.InvalidUrl,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Something went wrong', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return next({
|
||||||
|
status: e.statusCode ?? 500,
|
||||||
|
message: ApiErrorCode.Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json(settings.jellyfin);
|
return res.status(200).json(settings.jellyfin);
|
||||||
});
|
});
|
||||||
@@ -272,7 +323,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -288,10 +339,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
|
|
||||||
// Automatic Library grouping is not supported when user views are used to get library
|
// Automatic Library grouping is not supported when user views are used to get library
|
||||||
if (account.Configuration.GroupedFolders.length > 0) {
|
if (account.Configuration.GroupedFolders.length > 0) {
|
||||||
return next({ status: 501, message: 'SYNC_ERROR_GROUPED_FOLDERS' });
|
return next({
|
||||||
|
status: 501,
|
||||||
|
message: ApiErrorCode.SyncErrorGroupedFolders,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return next({ status: 404, message: 'SYNC_ERROR_NO_LIBRARIES' });
|
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLibraries: Library[] = libraries.map((library) => {
|
const newLibraries: Library[] = libraries.map((library) => {
|
||||||
@@ -322,16 +376,12 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const settings = getSettings();
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const jellyfinHost =
|
||||||
let jellyfinHost =
|
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: getHostname();
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
@@ -339,7 +389,6 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { findIndex, sortBy } from 'lodash';
|
import { findIndex, sortBy } from 'lodash';
|
||||||
@@ -496,7 +497,6 @@ router.post(
|
|||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
@@ -504,15 +504,14 @@ router.post(
|
|||||||
|
|
||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { hostname, externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
let jellyfinHost =
|
const hostname = getHostname();
|
||||||
|
|
||||||
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
: hostname;
|
: hostname;
|
||||||
|
|
||||||
jellyfinHost = jellyfinHost.endsWith('/')
|
|
||||||
? jellyfinHost.slice(0, -1)
|
|
||||||
: jellyfinHost;
|
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
const jellyfinUsers = await jellyfinClient.getUsers();
|
const jellyfinUsers = await jellyfinClient.getUsers();
|
||||||
|
|
||||||
|
|||||||
18
server/utils/getHostname.ts
Normal file
18
server/utils/getHostname.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
interface HostnameParams {
|
||||||
|
useSsl?: boolean;
|
||||||
|
ip?: string;
|
||||||
|
port?: number;
|
||||||
|
urlBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHostname = (params?: HostnameParams): string => {
|
||||||
|
const settings = params ? params : getSettings().jellyfin;
|
||||||
|
|
||||||
|
const { useSsl, ip, port, urlBase } = settings;
|
||||||
|
|
||||||
|
const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`;
|
||||||
|
|
||||||
|
return hostname;
|
||||||
|
};
|
||||||
@@ -14,7 +14,10 @@ import * as Yup from 'yup';
|
|||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
host: '{mediaServerName} URL',
|
hostname: '{mediaServerName} URL',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailtooltip:
|
emailtooltip:
|
||||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||||
@@ -24,6 +27,11 @@ const messages = defineMessages({
|
|||||||
validationemailformat: 'Valid email required',
|
validationemailformat: 'Valid email required',
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
adminerror: 'You must use an admin account to sign in.',
|
adminerror: 'You must use an admin account to sign in.',
|
||||||
credentialerror: 'The username or password is incorrect.',
|
credentialerror: 'The username or password is incorrect.',
|
||||||
@@ -51,16 +59,23 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
|
|
||||||
if (initial) {
|
if (initial) {
|
||||||
const LoginSchema = Yup.object().shape({
|
const LoginSchema = Yup.object().shape({
|
||||||
host: Yup.string()
|
hostname: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationhostrequired, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
port: Yup.number().required(
|
||||||
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
|
),
|
||||||
|
urlBase: Yup.string()
|
||||||
.matches(
|
.matches(
|
||||||
/^(?:(?:(?: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.validationUrlBaseLeadingSlash)
|
||||||
)
|
)
|
||||||
.required(
|
.matches(
|
||||||
intl.formatMessage(messages.validationhostrequired, {
|
/^(.*[^/])$/,
|
||||||
mediaServerName:
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
@@ -75,12 +90,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
mediaServerName:
|
mediaServerName:
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
host: '',
|
hostname: '',
|
||||||
|
port: 8096,
|
||||||
|
useSsl: false,
|
||||||
|
urlBase: '',
|
||||||
email: '',
|
email: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
@@ -89,7 +108,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
await axios.post('/api/v1/auth/jellyfin', {
|
await axios.post('/api/v1/auth/jellyfin', {
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
hostname: values.host,
|
hostname: values.hostname,
|
||||||
|
port: values.port,
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -121,32 +143,100 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ errors, touched, isSubmitting, isValid }) => (
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => (
|
||||||
<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">
|
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||||
{intl.formatMessage(messages.host, mediaServerFormatValues)}
|
<div className="w-full">
|
||||||
|
<label htmlFor="hostname" className="text-label">
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
type="text"
|
||||||
|
className="rounded-r-only flex-1"
|
||||||
|
placeholder={intl.formatMessage(
|
||||||
|
messages.hostname,
|
||||||
|
mediaServerFormatValues
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname && touched.hostname && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:mt-0">
|
||||||
|
<Field
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
inputMode="numeric"
|
||||||
|
type="text"
|
||||||
|
className="short flex-1"
|
||||||
|
placeholder={intl.formatMessage(messages.port)}
|
||||||
|
/>
|
||||||
|
{errors.port && touched.port && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="useSsl" className="text-label mt-2">
|
||||||
|
{intl.formatMessage(messages.enablessl)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:col-span-2">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="useSsl"
|
||||||
|
name="useSsl"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('useSsl', !values.useSsl);
|
||||||
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="urlBase" className="text-label mt-1">
|
||||||
|
{intl.formatMessage(messages.urlBase)}
|
||||||
</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">
|
||||||
<Field
|
<Field
|
||||||
id="host"
|
|
||||||
name="host"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={intl.formatMessage(
|
inputMode="url"
|
||||||
messages.host,
|
id="urlBase"
|
||||||
mediaServerFormatValues
|
name="urlBase"
|
||||||
)}
|
placeholder={intl.formatMessage(messages.urlBase)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.host && touched.host && (
|
{errors.urlBase && touched.urlBase && (
|
||||||
<div className="error">{errors.host}</div>
|
<div className="error">{errors.urlBase}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="text-label"
|
className="text-label inline-flex gap-1 align-middle"
|
||||||
style={{ display: 'inline-flex' }}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
<span className="label-tip">
|
<span className="label-tip">
|
||||||
@@ -162,7 +252,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||||
<div className="flex rounded-md shadow-sm">
|
<div className="flex rounded-md shadow-sm">
|
||||||
<Field
|
<Field
|
||||||
id="email"
|
id="email"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|||||||
import LibraryItem from '@app/components/Settings/LibraryItem';
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import type { JellyfinSettings } from '@server/lib/settings';
|
import type { JellyfinSettings } from '@server/lib/settings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
@@ -32,14 +33,17 @@ const messages = defineMessages({
|
|||||||
jellyfinSettingsDescription:
|
jellyfinSettingsDescription:
|
||||||
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
|
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
internalUrl: 'Internal URL',
|
hostname: 'Hostname or IP Address',
|
||||||
|
port: 'Port',
|
||||||
|
enablessl: 'Use SSL',
|
||||||
|
urlBase: 'URL Base',
|
||||||
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||||
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
||||||
jellyfinSyncFailedAutomaticGroupedFolders:
|
jellyfinSyncFailedAutomaticGroupedFolders:
|
||||||
'Custom authentication with Automatic Library Grouping not supported',
|
'Custom authentication with Automatic Library Grouping not supported',
|
||||||
jellyfinSyncFailedGenericError:
|
jellyfinSyncFailedGenericError:
|
||||||
'Something went wrong while syncing libraries',
|
'Something went wrong while syncing libraries',
|
||||||
validationUrl: 'You must provide a valid URL',
|
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||||
syncing: 'Syncing',
|
syncing: 'Syncing',
|
||||||
syncJellyfin: 'Sync Libraries',
|
syncJellyfin: 'Sync Libraries',
|
||||||
manualscanJellyfin: 'Manual Library Scan',
|
manualscanJellyfin: 'Manual Library Scan',
|
||||||
@@ -50,6 +54,12 @@ const messages = defineMessages({
|
|||||||
librariesRemaining: 'Libraries Remaining: {count}',
|
librariesRemaining: 'Libraries Remaining: {count}',
|
||||||
startscan: 'Start Scan',
|
startscan: 'Start Scan',
|
||||||
cancelscan: 'Cancel Scan',
|
cancelscan: 'Cancel Scan',
|
||||||
|
validationUrl: 'You must provide a valid URL',
|
||||||
|
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||||
|
validationPortRequired: 'You must provide a valid port number',
|
||||||
|
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||||
|
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||||
|
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
@@ -65,6 +75,7 @@ interface SyncStatus {
|
|||||||
currentLibrary?: Library;
|
currentLibrary?: Library;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsJellyfinProps {
|
interface SettingsJellyfinProps {
|
||||||
showAdvancedSettings?: boolean;
|
showAdvancedSettings?: boolean;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
@@ -93,18 +104,50 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
const { publicRuntimeConfig } = getConfig();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
const JellyfinSettingsSchema = Yup.object().shape({
|
const JellyfinSettingsSchema = Yup.object().shape({
|
||||||
jellyfinExternalUrl: Yup.string().matches(
|
hostname: Yup.string()
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
.nullable()
|
||||||
intl.formatMessage(messages.validationUrl)
|
.required(intl.formatMessage(messages.validationHostnameRequired))
|
||||||
),
|
.matches(
|
||||||
jellyfinInternalUrl: Yup.string().matches(
|
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
intl.formatMessage(messages.validationHostnameRequired)
|
||||||
intl.formatMessage(messages.validationUrl)
|
),
|
||||||
),
|
port: Yup.number().when(['hostname'], {
|
||||||
jellyfinForgotPasswordUrl: Yup.string().matches(
|
is: (value: unknown) => !!value,
|
||||||
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
|
then: Yup.number()
|
||||||
intl.formatMessage(messages.validationUrl)
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||||
),
|
.nullable()
|
||||||
|
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||||
|
otherwise: Yup.number()
|
||||||
|
.typeError(intl.formatMessage(messages.validationPortRequired))
|
||||||
|
.nullable(),
|
||||||
|
}),
|
||||||
|
urlBase: Yup.string()
|
||||||
|
.test(
|
||||||
|
'leading-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||||
|
(value) => !value || value.startsWith('/')
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
jellyfinExternalUrl: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
|
jellyfinForgotPasswordUrl: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.url(intl.formatMessage(messages.validationUrl))
|
||||||
|
.test(
|
||||||
|
'no-trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeLibraries =
|
const activeLibraries =
|
||||||
@@ -394,7 +437,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
jellyfinInternalUrl: data?.hostname || '',
|
hostname: data?.ip,
|
||||||
|
port: data?.port ?? 8096,
|
||||||
|
useSsl: data?.useSsl,
|
||||||
|
urlBase: data?.urlBase || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
}}
|
}}
|
||||||
@@ -402,7 +448,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/jellyfin', {
|
await axios.post('/api/v1/settings/jellyfin', {
|
||||||
hostname: values.jellyfinInternalUrl,
|
ip: values.hostname,
|
||||||
|
port: Number(values.port),
|
||||||
|
useSsl: values.useSsl,
|
||||||
|
urlBase: values.urlBase,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
} as JellyfinSettings);
|
} as JellyfinSettings);
|
||||||
@@ -420,44 +469,127 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(
|
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
|
||||||
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
addToast(
|
||||||
mediaServerName:
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
mediaServerName:
|
||||||
? 'Emby'
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
: 'Jellyfin',
|
? 'Emby'
|
||||||
}),
|
: 'Jellyfin',
|
||||||
{
|
}),
|
||||||
autoDismiss: true,
|
{
|
||||||
appearance: 'error',
|
autoDismiss: true,
|
||||||
}
|
appearance: 'error',
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="jellyfinInternalUrl" className="text-label">
|
<label htmlFor="hostname" className="text-label">
|
||||||
{intl.formatMessage(messages.internalUrl)}
|
{intl.formatMessage(messages.hostname)}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
className="rounded-r-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.hostname &&
|
||||||
|
touched.hostname &&
|
||||||
|
typeof errors.hostname === 'string' && (
|
||||||
|
<div className="error">{errors.hostname}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="port" className="text-label">
|
||||||
|
{intl.formatMessage(messages.port)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
id="port"
|
||||||
|
name="port"
|
||||||
|
className="short"
|
||||||
|
/>
|
||||||
|
{errors.port &&
|
||||||
|
touched.port &&
|
||||||
|
typeof errors.port === 'string' && (
|
||||||
|
<div className="error">{errors.port}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="useSsl" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.enablessl)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="useSsl"
|
||||||
|
name="useSsl"
|
||||||
|
onChange={() => {
|
||||||
|
setFieldValue('useSsl', !values.useSsl);
|
||||||
|
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="urlBase" className="text-label">
|
||||||
|
{intl.formatMessage(messages.urlBase)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
id="jellyfinInternalUrl"
|
id="urlBase"
|
||||||
name="jellyfinInternalUrl"
|
name="urlBase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.jellyfinInternalUrl &&
|
{errors.urlBase &&
|
||||||
touched.jellyfinInternalUrl && (
|
touched.urlBase &&
|
||||||
<div className="error">
|
typeof errors.urlBase === 'string' && (
|
||||||
{errors.jellyfinInternalUrl}
|
<div className="error">{errors.urlBase}</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,17 +220,19 @@
|
|||||||
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
|
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
|
||||||
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
|
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
|
||||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||||
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
|
||||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||||
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
|
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
|
||||||
"components.Login.email": "Email Address",
|
"components.Login.email": "Email Address",
|
||||||
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
|
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
|
||||||
|
"components.Login.enablessl": "Use SSL",
|
||||||
"components.Login.forgotpassword": "Forgot Password?",
|
"components.Login.forgotpassword": "Forgot Password?",
|
||||||
"components.Login.host": "{mediaServerName} URL",
|
"components.Login.hostname": "{mediaServerName} URL",
|
||||||
"components.Login.initialsignin": "Connect",
|
"components.Login.initialsignin": "Connect",
|
||||||
"components.Login.initialsigningin": "Connecting…",
|
"components.Login.initialsigningin": "Connecting…",
|
||||||
|
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
||||||
"components.Login.password": "Password",
|
"components.Login.password": "Password",
|
||||||
|
"components.Login.port": "Port",
|
||||||
"components.Login.save": "Add",
|
"components.Login.save": "Add",
|
||||||
"components.Login.saving": "Adding…",
|
"components.Login.saving": "Adding…",
|
||||||
"components.Login.signin": "Sign In",
|
"components.Login.signin": "Sign In",
|
||||||
@@ -240,9 +242,15 @@
|
|||||||
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
|
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
|
||||||
"components.Login.signinwithplex": "Use your Plex account",
|
"components.Login.signinwithplex": "Use your Plex account",
|
||||||
"components.Login.title": "Add Email",
|
"components.Login.title": "Add Email",
|
||||||
|
"components.Login.urlBase": "URL Base",
|
||||||
"components.Login.username": "Username",
|
"components.Login.username": "Username",
|
||||||
"components.Login.validationEmailFormat": "Invalid email",
|
"components.Login.validationEmailFormat": "Invalid email",
|
||||||
"components.Login.validationEmailRequired": "You must provide an email",
|
"components.Login.validationEmailRequired": "You must provide an email",
|
||||||
|
"components.Login.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
||||||
|
"components.Login.validationPortRequired": "You must provide a valid port number",
|
||||||
|
"components.Login.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
|
||||||
|
"components.Login.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
|
||||||
|
"components.Login.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
"components.Login.validationemailformat": "Valid email required",
|
"components.Login.validationemailformat": "Valid email required",
|
||||||
"components.Login.validationemailrequired": "You must provide a valid email address",
|
"components.Login.validationemailrequired": "You must provide a valid email address",
|
||||||
"components.Login.validationhostformat": "Valid URL required",
|
"components.Login.validationhostformat": "Valid URL required",
|
||||||
@@ -937,7 +945,7 @@
|
|||||||
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
||||||
"components.Settings.externalUrl": "External URL",
|
"components.Settings.externalUrl": "External URL",
|
||||||
"components.Settings.hostname": "Hostname or IP Address",
|
"components.Settings.hostname": "Hostname or IP Address",
|
||||||
"components.Settings.internalUrl": "Internal URL",
|
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Settings.is4k": "4K",
|
"components.Settings.is4k": "4K",
|
||||||
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
||||||
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
||||||
|
|||||||
Reference in New Issue
Block a user