feat(rebase): rebase
This commit is contained in:
0
config/db/.gitkeep
Normal file
0
config/db/.gitkeep
Normal file
@@ -2818,7 +2818,7 @@ paths:
|
|||||||
/auth/jellyfin:
|
/auth/jellyfin:
|
||||||
post:
|
post:
|
||||||
summary: Sign in using a Jellyfin username and password
|
summary: Sign in using a Jellyfin username and password
|
||||||
description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions.
|
description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions.
|
||||||
security: []
|
security: []
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- auth
|
||||||
@@ -2842,6 +2842,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
hostname:
|
hostname:
|
||||||
type: string
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- password
|
- password
|
||||||
|
|||||||
@@ -75,17 +75,16 @@ class JellyfinAPI {
|
|||||||
private jellyfinHost: string;
|
private jellyfinHost: string;
|
||||||
private axios: AxiosInstance;
|
private axios: AxiosInstance;
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, userId?: string) {
|
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||||
|
console.log(jellyfinHost, deviceId, authToken);
|
||||||
this.jellyfinHost = jellyfinHost;
|
this.jellyfinHost = jellyfinHost;
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.userId = userId;
|
|
||||||
|
|
||||||
let authHeaderVal = '';
|
let authHeaderVal = '';
|
||||||
if (this.authToken) {
|
if (this.authToken) {
|
||||||
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0", Token="${authToken}"`;
|
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
|
||||||
} else {
|
} else {
|
||||||
authHeaderVal =
|
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
|
||||||
'MediaBrowser Client="Overseerr", Device="Axios", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0"';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.axios = axios.create({
|
this.axios = axios.create({
|
||||||
@@ -116,6 +115,11 @@ class JellyfinAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setUserId(userId: string): void {
|
||||||
|
this.userId = userId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public async getServerName(): Promise<string> {
|
public async getServerName(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const account = await this.axios.get<JellyfinUserResponse>(
|
const account = await this.axios.get<JellyfinUserResponse>(
|
||||||
@@ -150,19 +154,21 @@ class JellyfinAPI {
|
|||||||
try {
|
try {
|
||||||
const account = await this.axios.get<any>('/Library/MediaFolders');
|
const account = await this.axios.get<any>('/Library/MediaFolders');
|
||||||
|
|
||||||
const response: JellyfinLibrary[] = [];
|
const response: JellyfinLibrary[] = account.data.Items.filter(
|
||||||
|
(Item: any) => {
|
||||||
account.data.Items.forEach((Item: any) => {
|
return (
|
||||||
const library: JellyfinLibrary = {
|
Item.Type === 'CollectionFolder' &&
|
||||||
|
(Item.CollectionType === 'tvshows' ||
|
||||||
|
Item.CollectionType === 'movies')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).map((Item: any) => {
|
||||||
|
return <JellyfinLibrary>{
|
||||||
key: Item.Id,
|
key: Item.Id,
|
||||||
title: Item.Name,
|
title: Item.Name,
|
||||||
type: Item.CollectionType == 'movies' ? 'movie' : 'show',
|
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
|
||||||
agent: 'jellyfin',
|
agent: 'jellyfin',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Item.Type == 'CollectionFolder') {
|
|
||||||
response.push(library);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -178,9 +184,7 @@ class JellyfinAPI {
|
|||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const contents = await this.axios.get<any>(
|
||||||
`/Users/${
|
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&StartIndex=0&ParentId=${id}`
|
||||||
(await this.getUser()).Id
|
|
||||||
}/Items?SortBy=SortName&SortOrder=Ascending&Recursive=true&StartIndex=0&ParentId=${id}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data.Items;
|
return contents.data.Items;
|
||||||
@@ -196,9 +200,7 @@ class JellyfinAPI {
|
|||||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const contents = await this.axios.get<any>(
|
||||||
`/Users/${
|
`/Users/${this.userId}/Items/Latest?Limit=50&ParentId=${id}`
|
||||||
(await this.getUser()).Id
|
|
||||||
}/Items/Latest?Limit=50&ParentId=${id}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data.Items;
|
return contents.data.Items;
|
||||||
@@ -214,7 +216,7 @@ class JellyfinAPI {
|
|||||||
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
|
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
|
||||||
try {
|
try {
|
||||||
const contents = await this.axios.get<any>(
|
const contents = await this.axios.get<any>(
|
||||||
`/Users/${(await this.getUser()).Id}/Items/${id}`
|
`/Users/${this.userId}/Items/${id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return contents.data;
|
return contents.data;
|
||||||
|
|||||||
@@ -164,10 +164,10 @@ class Media {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.jellyfinMediaId) {
|
if (this.jellyfinMediaId) {
|
||||||
this.mediaUrl = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverID}`;
|
this.mediaUrl = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId}&context=home&serverId=${settings.jellyfin.serverId}`;
|
||||||
}
|
}
|
||||||
if (this.jellyfinMediaId4k) {
|
if (this.jellyfinMediaId4k) {
|
||||||
this.mediaUrl4k = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverID}`;
|
this.mediaUrl4k = `${settings.jellyfin.hostname}/web/#!/details?id=${this.jellyfinMediaId4k}&context=home&serverId=${settings.jellyfin.serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,16 +65,19 @@ export class User {
|
|||||||
@Column({ type: 'integer', default: UserType.PLEX })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true })
|
||||||
public plexId?: number;
|
public plexId?: number;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true })
|
||||||
public jellyfinId?: string;
|
public jellyfinUserId?: string;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true })
|
||||||
|
public jellyfinDeviceId?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
public jellyfinAuthToken?: string;
|
public jellyfinAuthToken?: string;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true })
|
||||||
public plexToken?: string;
|
public plexToken?: string;
|
||||||
|
|
||||||
@Column({ type: 'integer', default: 0 })
|
@Column({ type: 'integer', default: 0 })
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ class JobJellyfinSync {
|
|||||||
|
|
||||||
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||||
if (MediaStream.Type == 'Video') {
|
if (MediaStream.Type === 'Video') {
|
||||||
if (MediaStream.Width ?? 0 < 2000) {
|
if (MediaStream.Width ?? 0 < 2000) {
|
||||||
totalStandard++;
|
totalStandard++;
|
||||||
}
|
}
|
||||||
@@ -552,7 +552,12 @@ class JobJellyfinSync {
|
|||||||
this.running = true;
|
this.running = true;
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -562,10 +567,12 @@ class JobJellyfinSync {
|
|||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
settings.jellyfin.hostname ?? '',
|
||||||
admin.jellyfinAuthToken ?? '',
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinId ?? ''
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.jfClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
|
|
||||||
this.libraries = settings.jellyfin.libraries.filter(
|
this.libraries = settings.jellyfin.libraries.filter(
|
||||||
(library) => library.enabled
|
(library) => library.enabled
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ export interface JellyfinSettings {
|
|||||||
name: string;
|
name: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
adminUser: string;
|
serverId: string;
|
||||||
adminPass: string;
|
|
||||||
serverID: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DVRSettings {
|
interface DVRSettings {
|
||||||
@@ -223,9 +221,7 @@ class Settings {
|
|||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
adminUser: '',
|
serverId: '',
|
||||||
adminPass: '',
|
|
||||||
serverID: '',
|
|
||||||
},
|
},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
sonarr: [],
|
sonarr: [],
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
settings.main.mediaServerType != MediaServerType.PLEX &&
|
settings.main.mediaServerType != MediaServerType.PLEX &&
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Plex login disabled' });
|
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// First we need to use this auth token to get the users email from plex.tv
|
// First we need to use this auth token to get the users email from plex.tv
|
||||||
@@ -154,40 +154,52 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
hostname?: string;
|
hostname?: 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.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 || !body.password) {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ error: 'You must provide an username and a password' });
|
.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)
|
||||||
.json({ error: 'Jellyfin hostname already configured' });
|
.json({ error: 'Jellyfin hostname already configured' });
|
||||||
} else if (settings.jellyfin.hostname == '' && !body.hostname) {
|
} else if (settings.jellyfin.hostname === '' && !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.hostname !== ''
|
||||||
? settings.jellyfin.hostname
|
? settings.jellyfin.hostname
|
||||||
: body.hostname;
|
: body.hostname;
|
||||||
|
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||||
|
let user = await userRepository.findOne({
|
||||||
|
where: { jellyfinUsername: body.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
let deviceId = '';
|
||||||
|
if (user) {
|
||||||
|
deviceId = user.jellyfinDeviceId ?? '';
|
||||||
|
} else {
|
||||||
|
deviceId = Buffer.from(
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:85.0) Gecko/20100101 Firefox/85.0|${Date.now()}`
|
||||||
|
).toString('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 ?? '');
|
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
||||||
settings.jellyfin.name = await jellyfinserver.getServerName();
|
|
||||||
|
|
||||||
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
|
||||||
let user = await userRepository.findOne({
|
user = await userRepository.findOne({
|
||||||
where: { jellyfinId: account.User.Id },
|
where: { jellyfinUserId: account.User.Id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -200,9 +212,9 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
if (typeof account.User.PrimaryImageTag !== undefined) {
|
if (typeof account.User.PrimaryImageTag !== undefined) {
|
||||||
user.avatar = `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
user.avatar = `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||||
} else {
|
} else {
|
||||||
user.avatar = '/images/os_logo_square.png';
|
user.avatar = '/os_logo_square.png';
|
||||||
}
|
}
|
||||||
user.email = account.User.Name;
|
|
||||||
user.jellyfinUsername = account.User.Name;
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
|
||||||
if (user.username === account.User.Name) {
|
if (user.username === account.User.Name) {
|
||||||
@@ -213,30 +225,52 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
// Here we check if it's the first user. If it is, we create the user with no check
|
// Here we check if it's the first user. If it is, we create the user with no check
|
||||||
// and give them admin permissions
|
// and give them admin permissions
|
||||||
const totalUsers = await userRepository.count();
|
const totalUsers = await userRepository.count();
|
||||||
|
|
||||||
if (totalUsers === 0) {
|
if (totalUsers === 0) {
|
||||||
user = new User({
|
user = new User({
|
||||||
email: account.User.Name,
|
email: body.email,
|
||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
jellyfinId: account.User.Id,
|
jellyfinUserId: account.User.Id,
|
||||||
|
jellyfinDeviceId: deviceId,
|
||||||
jellyfinAuthToken: account.AccessToken,
|
jellyfinAuthToken: account.AccessToken,
|
||||||
permissions: Permission.ADMIN,
|
permissions: Permission.ADMIN,
|
||||||
avatar:
|
avatar:
|
||||||
typeof account.User.PrimaryImageTag !== undefined
|
typeof account.User.PrimaryImageTag !== undefined
|
||||||
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
: '/images/os_logo_square.png',
|
: '/os_logo_square.png',
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
|
||||||
//Update hostname in settings if it doesn't exist (initial configuration)
|
//Update hostname in settings if it doesn't exist (initial configuration)
|
||||||
//Also set mediaservertype to JELLYFIN
|
//Also set mediaservertype to JELLYFIN
|
||||||
if (settings.jellyfin.hostname == '') {
|
if (settings.jellyfin.hostname === '') {
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||||
settings.jellyfin.hostname = body.hostname ?? '';
|
settings.jellyfin.hostname = body.hostname ?? '';
|
||||||
|
settings.jellyfin.serverId = account.User.ServerId;
|
||||||
settings.save();
|
settings.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
if (!body.email) {
|
||||||
|
throw new Error('add_email');
|
||||||
|
}
|
||||||
|
|
||||||
|
user = new User({
|
||||||
|
email: body.email,
|
||||||
|
jellyfinUsername: account.User.Name,
|
||||||
|
jellyfinUserId: account.User.Id,
|
||||||
|
jellyfinDeviceId: deviceId,
|
||||||
|
jellyfinAuthToken: account.AccessToken,
|
||||||
|
permissions: settings.main.defaultPermissions,
|
||||||
|
avatar:
|
||||||
|
typeof account.User.PrimaryImageTag !== undefined
|
||||||
|
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
|
: '/os_logo_square.png',
|
||||||
|
userType: UserType.JELLYFIN,
|
||||||
|
});
|
||||||
|
await userRepository.save(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set logged in session
|
// Set logged in session
|
||||||
@@ -246,16 +280,32 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
|
|
||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message != 'Unauthorized') {
|
if (e.message === 'Unauthorized') {
|
||||||
|
logger.info(
|
||||||
|
'Failed login attempt from user with incorrect Jellyfin credentials',
|
||||||
|
{
|
||||||
|
label: 'Auth',
|
||||||
|
account: {
|
||||||
|
ip: req.ip,
|
||||||
|
email: body.username,
|
||||||
|
password: '__REDACTED__',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
} else if (e.message === 'add_email') {
|
||||||
|
return next({
|
||||||
|
status: 406,
|
||||||
|
message: 'CREDENTIAL_ERROR_ADD_EMAIL',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
logger.error(e.message, { label: 'Auth' });
|
logger.error(e.message, { label: 'Auth' });
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Something went wrong. Is your auth token valid?',
|
message: 'Something went wrong.',
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return next({
|
|
||||||
status: 401,
|
|
||||||
message: 'CREDENTIAL_ERROR',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,8 +228,7 @@ settingsRoutes.post('/plex/sync', (req, res) => {
|
|||||||
settingsRoutes.get('/jellyfin', (_req, res) => {
|
settingsRoutes.get('/jellyfin', (_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
//DO NOT RETURN ADMIN USER CREDENTIALS!!
|
res.status(200).json(settings.jellyfin);
|
||||||
res.status(200).json(omit(settings.jellyfin, ['adminUser', 'adminPass']));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/jellyfin', (req, res) => {
|
settingsRoutes.post('/jellyfin', (req, res) => {
|
||||||
@@ -247,21 +246,19 @@ settingsRoutes.get('/jellyfin/library', async (req, res) => {
|
|||||||
if (req.query.sync) {
|
if (req.query.sync) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId'],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
settings.jellyfin.hostname ?? '',
|
settings.jellyfin.hostname ?? '',
|
||||||
admin.jellyfinAuthToken ?? ''
|
admin.jellyfinAuthToken ?? '',
|
||||||
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const libraries = await jellyfinClient.getLibraries();
|
const libraries = await jellyfinClient.getLibraries();
|
||||||
|
|
||||||
const newLibraries: Library[] = libraries
|
const newLibraries: Library[] = libraries.map((library) => {
|
||||||
// Remove libraries that are not movie or show
|
const existing = settings.jellyfin.libraries.find(
|
||||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
|
||||||
.map((library) => {
|
|
||||||
const existing = settings.plex.libraries.find(
|
|
||||||
(l) => l.id === library.key && l.name === library.title
|
(l) => l.id === library.key && l.name === library.title
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
114
src/components/Login/AddEmailModal.tsx
Normal file
114
src/components/Login/AddEmailModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Transition from '../Transition';
|
||||||
|
import Modal from '../Common/Modal';
|
||||||
|
import { Formik, Field } from 'formik';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSettings from '../../hooks/useSettings';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: 'Add Email',
|
||||||
|
description:
|
||||||
|
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',
|
||||||
|
email: 'Email address',
|
||||||
|
validationEmailRequired: 'You must provide an email',
|
||||||
|
validationEmailFormat: 'Invalid email',
|
||||||
|
saving: 'Adding…',
|
||||||
|
save: 'Add',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AddEmailModalProps {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddEmailModal: React.FC<AddEmailModalProps> = ({
|
||||||
|
onClose,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const settings = useSettings();
|
||||||
|
|
||||||
|
const EmailSettingsSchema = Yup.object().shape({
|
||||||
|
email: Yup.string()
|
||||||
|
.email(intl.formatMessage(messages.validationEmailFormat))
|
||||||
|
.required(intl.formatMessage(messages.validationEmailRequired)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
appear
|
||||||
|
show
|
||||||
|
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacuty-100"
|
||||||
|
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: '',
|
||||||
|
}}
|
||||||
|
validationSchema={EmailSettingsSchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/auth/jellyfin', {
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
email: values.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSave();
|
||||||
|
} catch (e) {
|
||||||
|
// set error here
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onCancel={onClose}
|
||||||
|
okButtonType="primary"
|
||||||
|
okText={
|
||||||
|
isSubmitting
|
||||||
|
? intl.formatMessage(messages.saving)
|
||||||
|
: intl.formatMessage(messages.save)
|
||||||
|
}
|
||||||
|
okDisabled={isSubmitting || !isValid}
|
||||||
|
onOk={() => handleSubmit()}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.description, {
|
||||||
|
applicationName: settings.currentSettings.applicationTitle,
|
||||||
|
})}
|
||||||
|
<label htmlFor="email" className="text-label">
|
||||||
|
{intl.formatMessage(messages.email)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.email)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && touched.email && (
|
||||||
|
<div className="error">{errors.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddEmailModal;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import Button from '../Common/Button';
|
import Button from '../Common/Button';
|
||||||
|
|
||||||
@@ -7,13 +7,17 @@ import * as Yup from 'yup';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSettings from '../../hooks/useSettings';
|
import useSettings from '../../hooks/useSettings';
|
||||||
|
import AddEmailModal from './AddEmailModal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
host: 'Jellyfin URL',
|
host: 'Jellyfin URL',
|
||||||
|
email: 'Email',
|
||||||
validationhostrequired: 'Jellyfin URL required',
|
validationhostrequired: 'Jellyfin URL required',
|
||||||
validationhostformat: 'Valid URL required',
|
validationhostformat: 'Valid URL required',
|
||||||
|
validationemailrequired: 'Email required',
|
||||||
|
validationemailformat: 'Valid email required',
|
||||||
validationusernamerequired: 'Username required',
|
validationusernamerequired: 'Username required',
|
||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
@@ -34,6 +38,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
revalidate,
|
revalidate,
|
||||||
initial,
|
initial,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [requiresEmail, setRequiresEmail] = useState<number>(0);
|
||||||
|
const [username, setUsername] = useState<string>();
|
||||||
|
const [password, setPassword] = useState<string>();
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -43,6 +50,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
host: Yup.string()
|
host: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationhostformat))
|
.url(intl.formatMessage(messages.validationhostformat))
|
||||||
.required(intl.formatMessage(messages.validationhostrequired)),
|
.required(intl.formatMessage(messages.validationhostrequired)),
|
||||||
|
email: Yup.string()
|
||||||
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
username: Yup.string().required(
|
username: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationusernamerequired)
|
intl.formatMessage(messages.validationusernamerequired)
|
||||||
),
|
),
|
||||||
@@ -56,6 +66,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
host: '',
|
host: '',
|
||||||
|
email: '',
|
||||||
}}
|
}}
|
||||||
validationSchema={LoginSchema}
|
validationSchema={LoginSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -64,6 +75,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
hostname: values.host,
|
hostname: values.host,
|
||||||
|
email: values.email,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
@@ -101,6 +113,22 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
<div className="error">{errors.host}</div>
|
<div className="error">{errors.host}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label htmlFor="email" className="text-label">
|
||||||
|
{intl.formatMessage(messages.email)}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||||
|
<div className="flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="text"
|
||||||
|
placeholder={intl.formatMessage(messages.email)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && touched.email && (
|
||||||
|
<div className="error">{errors.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label htmlFor="username" className="text-label">
|
<label htmlFor="username" className="text-label">
|
||||||
{intl.formatMessage(messages.username)}
|
{intl.formatMessage(messages.username)}
|
||||||
</label>
|
</label>
|
||||||
@@ -163,6 +191,15 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
{requiresEmail == 1 && (
|
||||||
|
<AddEmailModal
|
||||||
|
username={username ?? ''}
|
||||||
|
password={password ?? ''}
|
||||||
|
onSave={revalidate}
|
||||||
|
onClose={() => setRequiresEmail(0)}
|
||||||
|
></AddEmailModal>
|
||||||
|
)}
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
username: '',
|
||||||
@@ -176,6 +213,11 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
password: values.password,
|
password: values.password,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.message === 'Request failed with status code 406') {
|
||||||
|
setUsername(values.username);
|
||||||
|
setPassword(values.password);
|
||||||
|
setRequiresEmail(1);
|
||||||
|
} else {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
e.message == 'Request failed with status code 401'
|
e.message == 'Request failed with status code 401'
|
||||||
@@ -187,6 +229,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
@@ -262,6 +305,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,18 +62,18 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
|||||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={`w-full py-2 text-sm text-center hover:bg-gray-700 hover:cursor-pointer text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none sm:rounded-t-lg ${
|
className={`w-full py-2 text-sm text-center hover:bg-gray-700 hover:cursor-pointer text-gray-400 transition-colors duration-200 bg-gray-900 cursor-default focus:outline-none sm:rounded-t-lg ${
|
||||||
openIndexes.includes(0) && 'text-indigo-500'
|
openIndexes.includes(0) && 'text-indigo-500'
|
||||||
}`}
|
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
|
||||||
onClick={() => handleClick(0)}
|
onClick={() => handleClick(0)}
|
||||||
>
|
>
|
||||||
<FormattedMessage {...messages.signinWithPlex} />
|
<FormattedMessage {...messages.signinWithPlex} />
|
||||||
</button>
|
</button>
|
||||||
<AccordionContent
|
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||||
className="bg-opacity-90"
|
<div
|
||||||
isOpen={openIndexes.includes(0)}
|
className="px-10 py-8"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
>
|
>
|
||||||
<div className="px-10 py-8">
|
|
||||||
<PlexLoginButton
|
<PlexLoginButton
|
||||||
onAuthToken={(authToken) => {
|
onAuthToken={(authToken) => {
|
||||||
setMediaServerType(MediaServerType.PLEX);
|
setMediaServerType(MediaServerType.PLEX);
|
||||||
@@ -84,7 +84,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none hover:bg-gray-700 hover:cursor-pointer ${
|
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-900 cursor-default focus:outline-none hover:bg-gray-700 hover:cursor-pointer ${
|
||||||
openIndexes.includes(1)
|
openIndexes.includes(1)
|
||||||
? 'text-indigo-500'
|
? 'text-indigo-500'
|
||||||
: 'sm:rounded-b-lg'
|
: 'sm:rounded-b-lg'
|
||||||
@@ -93,11 +93,11 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
|||||||
>
|
>
|
||||||
<FormattedMessage {...messages.signinWithJellyfin} />
|
<FormattedMessage {...messages.signinWithJellyfin} />
|
||||||
</button>
|
</button>
|
||||||
<AccordionContent
|
<AccordionContent isOpen={openIndexes.includes(1)}>
|
||||||
className="bg-opacity-90"
|
<div
|
||||||
isOpen={openIndexes.includes(1)}
|
className="px-10 py-8 rounded-b-lg"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
|
||||||
>
|
>
|
||||||
<div className="px-10 py-8">
|
|
||||||
<JellyfinLogin initial={true} revalidate={revalidate} />
|
<JellyfinLogin initial={true} revalidate={revalidate} />
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ const Setup: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="w-full p-4 mt-10 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md">
|
<div
|
||||||
|
style={{ backdropFilter: 'blur(5px)' }}
|
||||||
|
className="w-full p-4 mt-10 text-white bg-gray-800 border border-gray-600 rounded-md bg-opacity-40"
|
||||||
|
>
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<SetupLogin
|
<SetupLogin
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"components.Layout.UserDropdown.signout": "Sign Out",
|
"components.Layout.UserDropdown.signout": "Sign Out",
|
||||||
"components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!",
|
"components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!",
|
||||||
"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.email": "Email Address",
|
"components.Login.email": "Email Address",
|
||||||
"components.Login.forgotpassword": "Forgot Password?",
|
"components.Login.forgotpassword": "Forgot Password?",
|
||||||
"components.Login.host": "Jellyfin URL",
|
"components.Login.host": "Jellyfin URL",
|
||||||
@@ -44,13 +45,19 @@
|
|||||||
"components.Login.initialsigningin": "Connecting…",
|
"components.Login.initialsigningin": "Connecting…",
|
||||||
"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.save": "Add",
|
||||||
|
"components.Login.saving": "Adding…",
|
||||||
"components.Login.signin": "Sign In",
|
"components.Login.signin": "Sign In",
|
||||||
"components.Login.signingin": "Signing in…",
|
"components.Login.signingin": "Signing in…",
|
||||||
"components.Login.signinheader": "Sign in to continue",
|
"components.Login.signinheader": "Sign in to continue",
|
||||||
"components.Login.signinwithjellyfin": "Use your Jellyfin account",
|
"components.Login.signinwithjellyfin": "Use your Jellyfin account",
|
||||||
"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.username": "Username",
|
"components.Login.username": "Username",
|
||||||
|
"components.Login.validationEmailFormat": "Invalid email",
|
||||||
|
"components.Login.validationEmailRequired": "You must provide an email",
|
||||||
|
"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",
|
||||||
"components.Login.validationhostrequired": "Jellyfin URL required",
|
"components.Login.validationhostrequired": "Jellyfin URL required",
|
||||||
@@ -598,8 +605,8 @@
|
|||||||
"components.Setup.setup": "Setup",
|
"components.Setup.setup": "Setup",
|
||||||
"components.Setup.signin": "Sign In",
|
"components.Setup.signin": "Sign In",
|
||||||
"components.Setup.signinMessage": "Get started by signing in",
|
"components.Setup.signinMessage": "Get started by signing in",
|
||||||
"components.Setup.signinWithJellyfin": "Use Jellyfin",
|
"components.Setup.signinWithJellyfin": "Use your Jellyfin account",
|
||||||
"components.Setup.signinWithPlex": "Sign in with Plex",
|
"components.Setup.signinWithPlex": "Use your Plex account",
|
||||||
"components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.",
|
"components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.",
|
||||||
"components.Setup.tip": "Tip",
|
"components.Setup.tip": "Tip",
|
||||||
"components.Setup.welcome": "Welcome to Overseerr",
|
"components.Setup.welcome": "Welcome to Overseerr",
|
||||||
|
|||||||
Reference in New Issue
Block a user