added Support for Jellyfin Media Server

This commit is contained in:
Juan D. Jara
2021-09-27 02:56:02 +02:00
committed by notfakie
parent 5125abdbf0
commit 3661eea8bb
36 changed files with 2862 additions and 86 deletions

View File

@@ -55,10 +55,13 @@ components:
readOnly: true
username:
type: string
plexUsername:
type: string
readOnly: true
plexToken:
type: string
readOnly: true
plexUsername:
jellyfinAuthToken:
type: string
readOnly: true
userType:
@@ -127,6 +130,9 @@ components:
localLogin:
type: boolean
example: true
mediaServerType:
type: number
example: 1
newPlexLogin:
type: boolean
example: true
@@ -302,6 +308,47 @@ components:
- provides
- owned
- connection
JellyfinLibrary:
type: object
properties:
id:
type: string
name:
type: string
example: Movies
enabled:
type: boolean
example: false
required:
- id
- name
- enabled
JellyfinSettings:
type: object
properties:
name:
type: string
example: 'Main Server'
readOnly: true
hostname:
type: string
example: 'http://my.jellyfin.host'
adminUser:
type: string
example: 'admin'
adminPass:
type: string
example: 'mypassword'
libraries:
type: array
readOnly: true
items:
$ref: '#/components/schemas/JellyfinLibrary'
serverID:
type: string
readOnly: true
required:
- hostname
TautulliSettings:
type: object
properties:
@@ -1876,6 +1923,136 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/jellyfin:
get:
summary: Get Jellyfin settings
description: Retrieves current Jellyfin settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/JellyfinSettings'
post:
summary: Update Jellyfin settings
description: Updates Jellyfin settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/JellyfinSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/JellyfinSettings'
/settings/jellyfin/library:
get:
summary: Get Jellyfin libraries
description: Returns a list of Jellyfin libraries in a JSON array.
tags:
- settings
parameters:
- in: query
name: sync
description: Syncs the current libraries with the current Jellyfin server
schema:
type: string
nullable: true
- in: query
name: enable
explode: false
allowReserved: true
description: Comma separated list of libraries to enable. Any libraries not passed will be disabled!
schema:
type: string
nullable: true
responses:
'200':
description: 'Jellyfin libraries returned'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/JellyfinLibrary'
/settings/jellyfin/sync:
get:
summary: Get status of full Jellyfin library sync
description: Returns sync progress in a JSON array.
tags:
- settings
responses:
'200':
description: Status of Jellyfin sync
content:
application/json:
schema:
type: object
properties:
running:
type: boolean
example: false
progress:
type: number
example: 0
total:
type: number
example: 100
currentLibrary:
$ref: '#/components/schemas/JellyfinLibrary'
libraries:
type: array
items:
$ref: '#/components/schemas/JellyfinLibrary'
post:
summary: Start full Jellyfin library sync
description: Runs a full Jellyfin library sync and returns the progress in a JSON array.
tags:
- settings
requestBody:
content:
application/json:
schema:
type: object
properties:
cancel:
type: boolean
example: false
start:
type: boolean
example: false
responses:
'200':
description: Status of Jellyfin sync
content:
application/json:
schema:
type: object
properties:
running:
type: boolean
example: false
progress:
type: number
example: 0
total:
type: number
example: 100
currentLibrary:
$ref: '#/components/schemas/JellyfinLibrary'
libraries:
type: array
items:
$ref: '#/components/schemas/JellyfinLibrary'
/settings/plex:
get:
summary: Get Plex settings
@@ -3084,6 +3261,38 @@ paths:
type: string
required:
- authToken
/auth/jellyfin:
post:
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 Jellyfin server, they will also have an account created, but without any permissions.
security: []
tags:
- auth
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
password:
type: string
hostname:
type: string
email:
type: string
required:
- username
- password
/auth/local:
post:
summary: Sign in using a local account

267
server/api/jellyfin.ts Normal file
View File

@@ -0,0 +1,267 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance } from 'axios';
import logger from '../logger';
export interface JellyfinUserResponse {
Name: string;
ServerId: string;
ServerName: string;
Id: string;
PrimaryImageTag?: string;
}
export interface JellyfinLoginResponse {
User: JellyfinUserResponse;
AccessToken: string;
}
export interface JellyfinLibrary {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
}
export interface JellyfinLibraryItem {
Name: string;
Id: string;
HasSubtitles: boolean;
Type: 'Movie' | 'Episode' | 'Season' | 'Series';
SeriesName?: string;
SeriesId?: string;
SeasonId?: string;
SeasonName?: string;
IndexNumber?: number;
ParentIndexNumber?: number;
MediaType: string;
}
export interface JellyfinMediaStream {
Codec: string;
Type: 'Video' | 'Audio' | 'Subtitle';
Height?: number;
Width?: number;
AverageFrameRate?: number;
RealFrameRate?: number;
Language?: string;
DisplayTitle: string;
}
export interface JellyfinMediaSource {
Protocol: string;
Id: string;
Path: string;
Type: string;
VideoType: string;
MediaStreams: JellyfinMediaStream[];
}
export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
ProviderIds: {
Tmdb?: string;
Imdb?: string;
Tvdb?: string;
};
MediaSources?: JellyfinMediaSource[];
Width?: number;
Height?: number;
IsHD?: boolean;
DateCreated?: string;
}
class JellyfinAPI {
private authToken?: string;
private userId?: string;
private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
let authHeaderVal = '';
if (this.authToken) {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
}
this.axios = axios.create({
baseURL: this.jellyfinHost,
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
public async login(
Username?: string,
Password?: string
): Promise<JellyfinLoginResponse> {
try {
const account = await this.axios.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName',
{
Username: Username,
Pw: Password,
}
);
return account.data;
} catch (e) {
throw new Error('Unauthorized');
}
}
public setUserId(userId: string): void {
this.userId = userId;
return;
}
public async getServerName(): Promise<string> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
"/System/Info/Public'}"
);
return account.data.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('girl idk');
}
}
public async getUser(): Promise<JellyfinUserResponse> {
try {
const account = await this.axios.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}`
);
return account.data;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
public async getLibraries(): Promise<JellyfinLibrary[]> {
try {
const account = await this.axios.get<any>(
`/Users/${this.userId ?? 'Me'}/Views`
);
const response: JellyfinLibrary[] = account.data.Items.filter(
(Item: any) => {
return (
Item.Type === 'CollectionFolder' &&
(Item.CollectionType === 'tvshows' ||
Item.CollectionType === 'movies')
);
}
).map((Item: any) => {
return <JellyfinLibrary>{
key: Item.Id,
title: Item.Name,
type: Item.CollectionType === 'movies' ? 'movie' : 'show',
agent: 'jellyfin',
};
});
return response;
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
}
}
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie&Recursive=true&StartIndex=0&ParentId=${id}`
);
return contents.data.Items;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
);
return contents.data;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
try {
const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items/${id}`
);
return contents.data;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
return contents.data.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
public async getEpisodes(
seriesID: string,
seasonID: string
): Promise<JellyfinLibraryItem[]> {
try {
const contents = await this.axios.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
);
return contents.data.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new Error('Invalid auth token');
}
}
}
export default JellyfinAPI;

View File

@@ -0,0 +1,6 @@
export enum MediaServerType {
PLEX = 1,
JELLYFIN,
EMBY,
NOT_CONFIGURED,
}

View File

@@ -1,4 +1,5 @@
export enum UserType {
PLEX = 1,
LOCAL = 2,
JELLYFIN = 3,
}

View File

@@ -13,6 +13,7 @@ import {
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import { MediaServerType } from '../constants/server';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
@@ -137,13 +138,19 @@ class Media {
@Column({ nullable: true })
public ratingKey4k?: string;
@Column({ nullable: true })
public jellyfinMediaId?: string;
@Column({ nullable: true })
public jellyfinMediaId4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
public mediaUrl?: string;
public mediaUrl4k?: string;
public tautulliUrl?: string;
public tautulliUrl4k?: string;
@@ -157,8 +164,9 @@ class Media {
const { machineId, webAppUrl } = getSettings().plex;
const { externalUrl: tautulliUrl } = getSettings().tautulli;
if (getSettings().main.mediaServerType == MediaServerType.PLEX) {
if (this.ratingKey) {
this.plexUrl = `${
this.mediaUrl = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey
@@ -170,7 +178,7 @@ class Media {
}
if (this.ratingKey4k) {
this.plexUrl4k = `${
this.mediaUrl4k = `${
webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop'
}#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${
this.ratingKey4k
@@ -180,6 +188,22 @@ class Media {
this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`;
}
}
} else {
if (this.jellyfinMediaId) {
this.mediaUrl = `${
getSettings().jellyfin.hostname
}/web/index.html#!/details?id=${
this.jellyfinMediaId
}&context=home&serverId=${getSettings().jellyfin.serverId}`;
}
if (this.jellyfinMediaId4k) {
this.mediaUrl4k = `${
getSettings().jellyfin.hostname
}/web/index.html#!/details?id=${
this.jellyfinMediaId4k
}&context=home&serverId=${getSettings().jellyfin.serverId}`;
}
}
}
@AfterLoad()

View File

@@ -61,6 +61,9 @@ export class User {
@Column({ nullable: true })
public plexUsername?: string;
@Column({ nullable: true })
public jellyfinUsername?: string;
@Column({ nullable: true })
public username?: string;
@@ -76,10 +79,19 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public plexId?: number;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public jellyfinUserId?: string;
@Column({ nullable: true })
public jellyfinDeviceId?: string;
@Column({ nullable: true })
public jellyfinAuthToken?: string;
@Column({ nullable: true })
public plexToken?: string;
@Column({ type: 'integer', default: 0 })
@@ -233,7 +245,8 @@ export class User {
@AfterLoad()
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername || this.email;
this.displayName =
this.username || this.plexUsername || this.jellyfinUsername || this.email;
}
public async getQuota(): Promise<QuotaResponse> {

View File

@@ -21,6 +21,8 @@ export interface SettingsAboutResponse {
}
export interface PublicSettingsResponse {
jellyfinHost?: string;
jellyfinServerName?: string;
initialized: boolean;
applicationTitle: string;
applicationUrl: string;
@@ -30,6 +32,7 @@ export interface PublicSettingsResponse {
series4kEnabled: boolean;
region: string;
originalLanguage: string;
mediaServerType: number;
partialRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;

View File

@@ -0,0 +1,665 @@
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin';
import TheMovieDb from '../../api/themoviedb';
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '../../constants/media';
import { MediaServerType } from '../../constants/server';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { User } from '../../entity/User';
import { getSettings, Library } from '../../lib/settings';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library;
libraries: Library[];
}
class JobJellyfinSync {
private sessionId: string;
private tmdb: TheMovieDb;
private jfClient: JellyfinAPI;
private items: JellyfinLibraryItem[] = [];
private progress = 0;
private libraries: Library[];
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb();
this.isRecentOnly = isRecentOnly ?? false;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
try {
const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media();
if (!metadata.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: jellyfinitem.Id,
});
return;
}
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.imdbId = metadata.ProviderIds.Imdb;
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;
}
if (!newMedia.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
return (MediaStream.Width ?? 0) > 2000;
});
});
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
return (MediaStream.Width ?? 0) <= 2000;
});
});
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.jellyfinMediaId !== metadata.Id
) {
existing.jellyfinMediaId = metadata.Id;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.jellyfinMediaId4k !== metadata.Id
) {
existing.jellyfinMediaId4k = metadata.Id;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.Name}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id
: undefined;
newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : undefined;
await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`);
}
});
} catch (e) {
this.log(
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
'error',
{
errorMessage: e.message,
jellyfinitem,
}
);
}
}
private async processShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null;
try {
const Id =
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
const metadata = await this.jfClient.getItemData(Id);
if (metadata.ProviderIds.Tvdb) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb),
});
} else if (metadata.ProviderIds.Tmdb) {
tvShow = await this.tmdb.getTvShow({
tvId: Number(metadata.ProviderIds.Tmdb),
});
}
if (tvShow) {
await this.asyncLock.dispatch(tvShow.id, async () => {
if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
// Lets get the available seasons from Jellyfin
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
const matchedJellyfinSeason = JellyfinSeasons.find(
(md) => Number(md.IndexNumber) === season.season_number
);
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) {
// If we have a matched Jellyfin season, get its children metadata so we can check details
const episodes = await this.jfClient.getEpisodes(
Id,
matchedJellyfinSeason.Id
);
//Get count of episodes that are HD and 4K
let totalStandard = 0;
let total4k = 0;
//use for loop to make sure this loop _completes_ in full
//before the next section
for (const episode of episodes) {
if (!this.enable4kShow) {
totalStandard++;
} else {
const ExtendedEpisodeData = await this.jfClient.getItemData(
episode.Id
);
ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
if (MediaStream.Width ?? 0 < 2000) {
totalStandard++;
}
} else {
total4k++;
}
});
});
}
}
if (
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.jellyfinMediaId !== Id
) {
media.jellyfinMediaId = Id;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== Id
) {
media.jellyfinMediaId4k = Id;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
}
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: Id,
jellyfinMediaId4k: Id,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
}
});
} else {
this.log(`failed show: ${metadata.Name}`);
}
} catch (e) {
this.log(
`Failed to process Jellyfin item. Id: ${
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
}`,
'error',
{
errorMessage: e.message,
jellyfinitem,
}
);
}
}
private async processItems(slicedItems: JellyfinLibraryItem[]) {
await Promise.all(
slicedItems.map(async (item) => {
if (item.Type === 'Movie') {
await this.processMovie(item);
} else if (item.Type === 'Series') {
await this.processShow(item);
}
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Jellyfin Sync', ...optional });
}
public async run(): Promise<void> {
const settings = getSettings();
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
return;
}
const sessionId = uuid();
this.sessionId = sessionId;
logger.info('Jellyfin Sync Starting', {
sessionId,
label: 'Jellyfin Sync',
});
try {
this.running = true;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
}
this.jfClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);
this.jfClient.setUserId(admin.jellyfinUserId ?? '');
this.libraries = settings.jellyfin.libraries.filter(
(library) => library.enabled
);
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
const libraryItems = await this.jfClient.getRecentlyAdded(library.id);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.SeriesId && mediaB.SeriesId) {
return mediaA.SeriesId === mediaB.SeriesId;
}
if (mediaA.SeasonId && mediaB.SeasonId) {
return mediaA.SeasonId === mediaB.SeasonId;
}
return mediaA.Id === mediaB.Id;
});
await this.loop({ sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.jfClient.getLibraryContents(library.id);
await this.loop({ sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
logger.error('Sync interrupted', {
label: 'Jellyfin Sync',
errorMessage: e.message,
});
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public cancel(): void {
this.running = false;
}
}
export const jobJellyfinFullSync = new JobJellyfinSync();
export const jobJellyfinRecentSync = new JobJellyfinSync({
isRecentOnly: true,
});

View File

@@ -5,6 +5,7 @@ import { radarrScanner } from '../lib/scanners/radarr';
import { sonarrScanner } from '../lib/scanners/sonarr';
import { getSettings, JobId } from '../lib/settings';
import logger from '../logger';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob {
id: JobId;
@@ -53,6 +54,41 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// Run recently added jellyfin sync every 5 minutes
scheduledJobs.push({
id: 'jellyfin-recently-added-sync',
name: 'Jellyfin Recently Added Sync',
type: 'process',
interval: 'long',
job: schedule.scheduleJob(
jobs['jellyfin-recently-added-sync'].schedule,
() => {
logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
label: 'Jobs',
});
jobJellyfinRecentSync.run();
}
),
running: () => jobJellyfinRecentSync.status().running,
cancelFn: () => jobJellyfinRecentSync.cancel(),
});
// Run full jellyfin sync every 24 hours
scheduledJobs.push({
id: 'jellyfin-full-sync',
name: 'Jellyfin Full Library Sync',
type: 'process',
interval: 'long',
job: schedule.scheduleJob(jobs['jellyfin-full-sync'].schedule, () => {
logger.info('Starting scheduled job: Jellyfin Full Sync', {
label: 'Jobs',
});
jobJellyfinFullSync.run();
}),
running: () => jobJellyfinFullSync.status().running,
cancelFn: () => jobJellyfinFullSync.cancel(),
});
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',

View File

@@ -3,6 +3,7 @@ import fs from 'fs';
import { merge } from 'lodash';
import path from 'path';
import webpush from 'web-push';
import { MediaServerType } from '../constants/server';
import { Permission } from './permissions';
export interface Library {
@@ -35,6 +36,12 @@ export interface PlexSettings {
webAppUrl?: string;
}
export interface JellyfinSettings {
name: string;
hostname?: string;
libraries: Library[];
serverId: string;
}
export interface TautulliSettings {
hostname?: string;
port?: number;
@@ -99,6 +106,7 @@ export interface MainSettings {
region: string;
originalLanguage: string;
trustProxy: boolean;
mediaServerType: number;
partialRequestsEnabled: boolean;
locale: string;
}
@@ -116,6 +124,9 @@ interface FullPublicSettings extends PublicSettings {
series4kEnabled: boolean;
region: string;
originalLanguage: string;
mediaServerType: number;
jellyfinHost?: string;
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
@@ -246,7 +257,9 @@ export type JobId =
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset';
| 'download-sync-reset'
| 'jellyfin-recently-added-sync'
| 'jellyfin-full-sync';
interface AllSettings {
clientId: string;
@@ -254,6 +267,7 @@ interface AllSettings {
vapidPrivate: string;
main: MainSettings;
plex: PlexSettings;
jellyfin: JellyfinSettings;
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
@@ -291,6 +305,7 @@ class Settings {
region: '',
originalLanguage: '',
trustProxy: false,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
locale: 'en',
},
@@ -301,6 +316,12 @@ class Settings {
useSsl: false,
libraries: [],
},
jellyfin: {
name: '',
hostname: '',
libraries: [],
serverId: '',
},
tautulli: {},
radarr: [],
sonarr: [],
@@ -410,6 +431,12 @@ class Settings {
'download-sync-reset': {
schedule: '0 0 1 * * *',
},
'jellyfin-recently-added-sync': {
schedule: '0 */5 * * * *',
},
'jellyfin-full-sync': {
schedule: '0 0 3 * * *',
},
},
};
if (initialSettings) {
@@ -437,6 +464,14 @@ class Settings {
this.data.plex = data;
}
get jellyfin(): JellyfinSettings {
return this.data.jellyfin;
}
set jellyfin(data: JellyfinSettings) {
this.data.jellyfin = data;
}
get tautulli(): TautulliSettings {
return this.data.tautulli;
}
@@ -484,6 +519,8 @@ class Settings {
),
region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic,

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddJellyfinUserParams1613379909641 implements MigrationInterface {
name = 'AddJellyfinUserParams1613379909641';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"'
);
await queryRunner.query('DROP TABLE "user"');
await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query(
'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"'
);
await queryRunner.query('DROP TABLE "media"');
await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"');
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"');
await queryRunner.query(
'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"'
);
await queryRunner.query('DROP TABLE "temporary_media"');
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"');
await queryRunner.query(
'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"'
);
await queryRunner.query('DROP TABLE "temporary_user"');
}
}

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerTypeEnum1613412948344 implements MigrationInterface {
name = 'ServerTypeEnum1613412948344';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query(
'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"'
);
await queryRunner.query('DROP TABLE "media"');
await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"');
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query(
'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"'
);
await queryRunner.query('DROP TABLE "media"');
await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"');
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"');
await queryRunner.query(
'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"'
);
await queryRunner.query('DROP TABLE "temporary_media"');
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"');
await queryRunner.query(
'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"'
);
await queryRunner.query('DROP TABLE "temporary_media"');
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
}
}

View File

@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddJellyfinDeviceId1613670041760 implements MigrationInterface {
name = 'AddJellyfinDeviceId1613670041760';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"'
);
await queryRunner.query('DROP TABLE "user"');
await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query(
'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"'
);
await queryRunner.query('DROP TABLE "media"');
await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"');
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"'
);
await queryRunner.query('DROP TABLE "user"');
await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query(
'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"'
);
await queryRunner.query('DROP TABLE "media"');
await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"');
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"');
await queryRunner.query(
'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"'
);
await queryRunner.query('DROP TABLE "temporary_media"');
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"');
await queryRunner.query(
'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"'
);
await queryRunner.query('DROP TABLE "temporary_user"');
await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"');
await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"');
await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"');
await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"');
await queryRunner.query(
'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))'
);
await queryRunner.query(
'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"'
);
await queryRunner.query('DROP TABLE "temporary_media"');
await queryRunner.query(
'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") '
);
await queryRunner.query(
'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") '
);
await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"');
await queryRunner.query(
'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'
);
await queryRunner.query(
'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"'
);
await queryRunner.query('DROP TABLE "temporary_user"');
}
}

View File

@@ -5,10 +5,10 @@ export class AddUserQuotaFields1616576677254 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"`
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
@@ -17,10 +17,10 @@ export class AddUserQuotaFields1616576677254 implements MigrationInterface {
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"`
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}

View File

@@ -79,7 +79,7 @@ export interface MovieDetails {
};
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
mediaUrl?: string;
watchProviders?: WatchProviders[];
}

View File

@@ -1,6 +1,8 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import JellyfinAPI from '../api/jellyfin';
import PlexTvAPI from '../api/plextv';
import { MediaServerType } from '../constants/server';
import { UserType } from '../constants/user';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
@@ -36,6 +38,13 @@ authRoutes.post('/plex', async (req, res, next) => {
message: 'Authentication token required.',
});
}
if (
settings.main.mediaServerType != MediaServerType.PLEX &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
) {
return res.status(500).json({ error: 'Plex login is disabled' });
}
try {
// First we need to use this auth token to get the user's email from plex.tv
const plextv = new PlexTvAPI(body.authToken);
@@ -171,6 +180,169 @@ authRoutes.post('/plex', async (req, res, next) => {
}
});
authRoutes.post('/jellyfin', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as {
username?: string;
password?: string;
hostname?: string;
email?: string;
};
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.jellyfin.hostname !== ''
) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username || !body.password) {
return res
.status(500)
.json({ error: 'You must provide an username and a password' });
} else if (settings.jellyfin.hostname !== '' && body.hostname) {
return res
.status(500)
.json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.hostname === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' });
}
try {
const hostname =
settings.jellyfin.hostname !== ''
? settings.jellyfin.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(`BOT_overseerr_${body.username ?? ''}`).toString(
'base64'
);
}
// First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists
user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id },
});
if (user) {
// Let's check if their authtoken is up to date
if (user.jellyfinAuthToken !== account.AccessToken) {
user.jellyfinAuthToken = account.AccessToken;
}
// Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) {
user.avatar = `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else {
user.avatar = '/os_logo_square.png';
}
user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) {
user.username = '';
}
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
// and give them admin permissions
const totalUsers = await userRepository.count();
if (totalUsers === 0) {
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});
await userRepository.save(user);
//Update hostname in settings if it doesn't exist (initial configuration)
//Also set mediaservertype to JELLYFIN
if (settings.jellyfin.hostname === '') {
settings.main.mediaServerType = MediaServerType.JELLYFIN;
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.serverId = account.User.ServerId;
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: account.User.PrimaryImageTag
? `${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
if (req.session) {
req.session.userId = user?.id;
}
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
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' });
return next({
status: 500,
message: 'Something went wrong.',
});
}
}
});
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);

View File

@@ -7,6 +7,7 @@ import path from 'path';
import semver from 'semver';
import { getRepository } from 'typeorm';
import { URL } from 'url';
import JellyfinAPI from '../../api/jellyfin';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
import TautulliAPI from '../../api/tautulli';
@@ -19,11 +20,12 @@ import {
LogsResultsResponse,
SettingsAboutResponse,
} from '../../interfaces/api/settingsInterfaces';
import { jobJellyfinFullSync } from '../../job/jellyfinsync';
import { scheduledJobs } from '../../job/schedule';
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { Permission } from '../../lib/permissions';
import { plexFullScanner } from '../../lib/scanners/plex';
import { getSettings, MainSettings } from '../../lib/settings';
import { getSettings, Library, MainSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
import { appDataPath } from '../../utils/appDataVolume';
@@ -238,6 +240,79 @@ settingsRoutes.post('/plex/sync', (req, res) => {
return res.status(200).json(plexFullScanner.status());
});
settingsRoutes.get('/jellyfin', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.jellyfin);
});
settingsRoutes.post('/jellyfin', (req, res) => {
const settings = getSettings();
settings.jellyfin = merge(settings.jellyfin, req.body);
settings.save();
return res.status(200).json(settings.jellyfin);
});
settingsRoutes.get('/jellyfin/library', async (req, res) => {
const settings = getSettings();
if (req.query.sync) {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const libraries = await jellyfinClient.getLibraries();
const newLibraries: Library[] = libraries.map((library) => {
const existing = settings.jellyfin.libraries.find(
(l) => l.id === library.key && l.name === library.title
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
type: library.type,
};
});
settings.jellyfin.libraries = newLibraries;
}
const enabledLibraries = req.query.enable
? (req.query.enable as string).split(',')
: [];
settings.jellyfin.libraries = settings.jellyfin.libraries.map((library) => ({
...library,
enabled: enabledLibraries.includes(library.id),
}));
settings.save();
return res.status(200).json(settings.jellyfin.libraries);
});
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
return res.status(200).json(jobJellyfinFullSync.status());
});
settingsRoutes.post('/jellyfin/sync', (req, res) => {
if (req.body.cancel) {
jobJellyfinFullSync.cancel();
} else if (req.body.start) {
jobJellyfinFullSync.run();
}
return res.status(200).json(jobJellyfinFullSync.status());
});
settingsRoutes.get('/tautulli', (_req, res) => {
const settings = getSettings();

View File

@@ -0,0 +1,24 @@
<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
<defs>
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#aa5cc3"/>
<stop offset="1" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<title>banner-dark</title>
<g id="banner-dark">
<g id="banner-dark-icon">
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
</g>
<g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
<path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
<path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
<path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
<path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
<path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
<path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
<path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { MediaType } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import ImdbLogo from '../../assets/services/imdb.svg';
import JellyfinLogo from '../../assets/services/jellyfin.svg';
import PlexLogo from '../../assets/services/plex.svg';
import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TraktLogo from '../../assets/services/trakt.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@@ -14,7 +17,7 @@ interface ExternalLinkBlockProps {
tvdbId?: number;
imdbId?: string;
rtUrl?: string;
plexUrl?: string;
mediaUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
@@ -23,20 +26,25 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
tvdbId,
imdbId,
rtUrl,
plexUrl,
mediaUrl,
}) => {
const settings = useSettings();
const { locale } = useLocale();
return (
<div className="flex w-full items-center justify-center space-x-5">
{plexUrl && (
{mediaUrl && (
<a
href={plexUrl}
href={mediaUrl}
className="w-12 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
<PlexLogo />
) : (
<JellyfinLogo />
)}
</a>
)}
{tmdbId && (

View File

@@ -352,10 +352,10 @@ const IssueDetails: React.FC = () => {
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.plexUrl && (
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.plexUrl}
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -385,10 +385,10 @@ const IssueDetails: React.FC = () => {
</span>
</Button>
)}
{issueData?.media.plexUrl4k && (
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.plexUrl4k}
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -588,10 +588,10 @@ const IssueDetails: React.FC = () => {
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.plexUrl && (
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.plexUrl}
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -621,10 +621,10 @@ const IssueDetails: React.FC = () => {
</span>
</Button>
)}
{issueData?.media.plexUrl4k && (
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.plexUrl4k}
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"

View 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:col-span-2 sm:mt-0">
<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;

View File

@@ -0,0 +1,313 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import { Field, Form, Formik } from 'formik';
import * as Yup from 'yup';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import useSettings from '../../hooks/useSettings';
import AddEmailModal from './AddEmailModal';
const messages = defineMessages({
username: 'Username',
password: 'Password',
host: 'Jellyfin URL',
email: 'Email',
validationhostrequired: 'Jellyfin URL required',
validationhostformat: 'Valid URL required',
validationemailrequired: 'Email required',
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
loginerror: 'Something went wrong while trying to sign in.',
credentialerror: 'The username or password is incorrect.',
signingin: 'Signing in…',
signin: 'Sign In',
initialsigningin: 'Connecting…',
initialsignin: 'Connect',
forgotpassword: 'Forgot Password?',
});
interface JellyfinLoginProps {
revalidate: () => void;
initial?: boolean;
}
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
initial,
}) => {
const [requiresEmail, setRequiresEmail] = useState<number>(0);
const [username, setUsername] = useState<string>();
const [password, setPassword] = useState<string>();
const toasts = useToasts();
const intl = useIntl();
const settings = useSettings();
if (initial) {
const LoginSchema = Yup.object().shape({
host: Yup.string()
.url(intl.formatMessage(messages.validationhostformat))
.required(intl.formatMessage(messages.validationhostrequired)),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired)
),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
});
return (
<Formik
initialValues={{
username: '',
password: '',
host: '',
email: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
hostname: values.host,
email: values.email,
});
} catch (e) {
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="host"
name="host"
type="text"
placeholder={intl.formatMessage(messages.host)}
/>
</div>
{errors.host && touched.host && (
<div className="error">{errors.host}</div>
)}
</div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<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">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flexrounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
</div>
</div>
</Form>
)}
</Formik>
);
} else {
const LoginSchema = Yup.object().shape({
username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired)
),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
});
return (
<div>
{requiresEmail == 1 && (
<AddEmailModal
username={username ?? ''}
password={password ?? ''}
onSave={revalidate}
onClose={() => setRequiresEmail(0)}
></AddEmailModal>
)}
<Formik
initialValues={{
username: '',
password: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
});
} catch (e) {
if (e.message === 'Request failed with status code 406') {
setUsername(values.username);
setPassword(values.password);
setRequiresEmail(1);
} else {
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
return (
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
as="a"
buttonType="ghost"
href={
settings.currentSettings.jellyfinHost +
'/web/#!/forgotpassword.html'
}
>
{intl.formatMessage(messages.forgotpassword)}
</Button>
</span>
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
</div>
</div>
</Form>
</>
);
}}
</Formik>
</div>
);
}
};
export default JellyfinLogin;

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/dist/client/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
import Accordion from '../Common/Accordion';
@@ -12,12 +13,14 @@ import PageTitle from '../Common/PageTitle';
import LanguagePicker from '../Layout/LanguagePicker';
import PlexLoginButton from '../PlexLoginButton';
import Transition from '../Transition';
import JellyfinLogin from './JellyfinLogin';
import LocalLogin from './LocalLogin';
const messages = defineMessages({
signin: 'Sign In',
signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account',
signinwithjellyfin: 'Use your Jellyfin account',
signinwithoverseerr: 'Use your {applicationTitle} account',
});
@@ -127,14 +130,22 @@ const Login: React.FC = () => {
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{intl.formatMessage(messages.signinwithplex)}
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(messages.signinwithjellyfin)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX ? (
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
) : (
<JellyfinLogin revalidate={revalidate} />
)}
</div>
</AccordionContent>
{settings.currentSettings.localLogin && (

View File

@@ -22,6 +22,7 @@ import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
@@ -64,8 +65,11 @@ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
studio: '{studioCount, plural, one {Studio} other {Studios}}',
viewfullcrew: 'View Full Crew',
playonplex: 'Play on Plex',
play4konplex: 'Play in 4K on Plex',
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
play: 'Play on {mediaServerName}',
play4k: 'Play 4K on {mediaServerName}',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K',
showmore: 'Show More',
@@ -124,29 +128,29 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
const mediaLinks: PlayButtonLink[] = [];
if (
data.mediaInfo?.plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
if (data.mediaInfo?.mediaUrl) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: data.mediaInfo?.plexUrl,
text:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />,
});
}
if (
settings.currentSettings.movie4kEnabled &&
data.mediaInfo?.plexUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
data.mediaInfo?.mediaUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.play4konplex),
url: data.mediaInfo?.plexUrl4k,
text:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />,
});
}
@@ -291,7 +295,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.plexUrl}
plexUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
@@ -312,7 +316,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.plexUrl4k}
plexUrl={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
@@ -713,7 +717,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/>
</div>
</div>

View File

@@ -299,7 +299,9 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[requestData.is4k ? 'plexUrl4k' : 'plexUrl']
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>
)}

View File

@@ -302,7 +302,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'plexUrl4k' : 'plexUrl'
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>

View File

@@ -0,0 +1,285 @@
import axios from 'axios';
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { JellyfinSettings } from '../../../server/lib/settings';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
import LibraryItem from './LibraryItem';
const messages = defineMessages({
jellyfinsettings: 'Jellyfin Settings',
jellyfinsettingsDescription:
'Configure the settings for your Jellyfin server. Overseerr scans your Jellyfin libraries to see what content is available.',
timeout: 'Timeout',
save: 'Save Changes',
saving: 'Saving…',
jellyfinlibraries: 'Jellyfin Libraries',
jellyfinlibrariesDescription:
'The libraries Overseerr scans for titles. Click the button below if no libraries are listed.',
syncing: 'Syncing',
syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan',
manualscanDescriptionJellyfin:
"Normally, this will only be run once every 24 hours. Overseerr will check your Jellyfin server's recently added more aggressively. If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended!",
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan',
cancelscan: 'Cancel Scan',
});
interface Library {
id: string;
name: string;
enabled: boolean;
}
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary?: Library;
libraries: Library[];
}
interface SettingsJellyfinProps {
onComplete?: () => void;
}
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
const [isSyncing, setIsSyncing] = useState(false);
const {
data: data,
error: error,
// revalidate: revalidate,
} = useSWR<JellyfinSettings>('/api/v1/settings/jellyfin');
const revalidate = () => undefined; //TODO
const revalidateSync = () => undefined; //TODO
const {
data: dataSync, //, revalidate: revalidateSync
} = useSWR<SyncStatus>('/api/v1/settings/jellyfin/sync', {
refreshInterval: 1000,
});
const intl = useIntl();
const activeLibraries =
data?.libraries
.filter((library) => library.enabled)
.map((library) => library.id) ?? [];
const syncLibraries = async () => {
setIsSyncing(true);
const params: { sync: boolean; enable?: string } = {
sync: true,
};
if (activeLibraries.length > 0) {
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
setIsSyncing(false);
revalidate();
};
const startScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
start: true,
});
revalidateSync();
};
const cancelScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
cancel: true,
});
revalidateSync();
};
const toggleLibrary = async (libraryId: string) => {
setIsSyncing(true);
if (activeLibraries.includes(libraryId)) {
const params: { enable?: string } = {};
if (activeLibraries.length > 1) {
params.enable = activeLibraries
.filter((id) => id !== libraryId)
.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
} else {
await axios.get('/api/v1/settings/jellyfin/library', {
params: {
enable: [...activeLibraries, libraryId].join(','),
},
});
}
if (onComplete) {
onComplete();
}
setIsSyncing(false);
revalidate();
};
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<div className="mb-6">
<h3 className="heading">
<FormattedMessage {...messages.jellyfinlibraries} />
</h3>
<p className="description">
<FormattedMessage {...messages.jellyfinlibrariesDescription} />
</p>
</div>
<div className="section">
<Button onClick={() => syncLibraries()} disabled={isSyncing}>
<svg
className={`${isSyncing ? 'animate-spin' : ''} mr-1 h-5 w-5`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
{isSyncing
? intl.formatMessage(messages.syncing)
: intl.formatMessage(messages.syncJellyfin)}
</Button>
<ul className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{data?.libraries.map((library) => (
<LibraryItem
name={library.name}
isEnabled={library.enabled}
key={`setting-library-${library.id}`}
onToggle={() => toggleLibrary(library.id)}
/>
))}
</ul>
</div>
<div className="mt-10 mb-6">
<h3 className="heading">
<FormattedMessage {...messages.manualscanJellyfin} />
</h3>
<p className="description">
<FormattedMessage {...messages.manualscanDescriptionJellyfin} />
</p>
</div>
<div className="section">
<div className="rounded-md bg-gray-800 p-4">
<div className="relative mb-6 h-8 w-full overflow-hidden rounded-full bg-gray-600">
{dataSync?.running && (
<div
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
style={{
width: `${Math.round(
(dataSync.progress / dataSync.total) * 100
)}%`,
}}
/>
)}
<div className="absolute inset-0 flex h-8 w-full items-center justify-center text-sm">
<span>
{dataSync?.running
? `${dataSync.progress} of ${dataSync.total}`
: 'Not running'}
</span>
</div>
</div>
<div className="flex w-full flex-col sm:flex-row">
{dataSync?.running && (
<>
{dataSync.currentLibrary && (
<div className="mb-2 mr-0 flex items-center sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}
values={{ name: dataSync.currentLibrary.name }}
/>
</Badge>
</div>
)}
<div className="flex items-center">
<Badge badgeType="warning">
<FormattedMessage
{...messages.librariesRemaining}
values={{
count: dataSync.currentLibrary
? dataSync.libraries.slice(
dataSync.libraries.findIndex(
(library) =>
library.id === dataSync.currentLibrary?.id
) + 1
).length
: 0,
}}
/>
</Badge>
</div>
</>
)}
<div className="flex-1 text-right">
{!dataSync?.running && (
<Button buttonType="warning" onClick={() => startScan()}>
<svg
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<FormattedMessage {...messages.startscan} />
</Button>
)}
{dataSync?.running && (
<Button buttonType="danger" onClick={() => cancelScan()}>
<svg
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<FormattedMessage {...messages.cancelscan} />
</Button>
)}
</div>
</div>
</div>
</div>
</>
);
};
export default SettingsJellyfin;

View File

@@ -8,6 +8,7 @@ const messages = defineMessages({
menuGeneralSettings: 'General',
menuUsers: 'Users',
menuPlexSettings: 'Plex',
menuJellyfinSettings: 'Jellyfin',
menuServices: 'Services',
menuNotifications: 'Notifications',
menuLogs: 'Logs',
@@ -34,6 +35,11 @@ const SettingsLayout: React.FC = ({ children }) => {
route: '/settings/plex',
regex: /^\/settings\/plex/,
},
{
text: intl.formatMessage(messages.menuJellyfinSettings),
route: '/settings/jellyfin',
regex: /^\/settings\/jellyfin/,
},
{
text: intl.formatMessage(messages.menuServices),
route: '/settings/services',

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { useUser } from '../../hooks/useUser';
import PlexLoginButton from '../PlexLoginButton';
import JellyfinLogin from '../Login/JellyfinLogin';
import axios from 'axios';
import { defineMessages, FormattedMessage } from 'react-intl';
import Accordion from '../Common/Accordion';
import { MediaServerType } from '../../../server/constants/server';
const messages = defineMessages({
welcome: 'Welcome to Overseerr',
signinMessage: 'Get started by signing in',
signinWithJellyfin: 'Use your Jellyfin account',
signinWithPlex: 'Use your Plex account',
});
interface LoginWithMediaServerProps {
onComplete: () => void;
}
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [mediaServerType, setMediaServerType] = useState<number>(
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
// ask swr to revalidate the user which _shouid_ come back with a valid user.
useEffect(() => {
const login = async () => {
const response = await axios.post('/api/v1/auth/plex', {
authToken: authToken,
});
if (response.data?.email) {
revalidate();
}
};
if (authToken && mediaServerType == MediaServerType.PLEX) {
login();
}
}, [authToken, mediaServerType, revalidate]);
useEffect(() => {
if (user) {
onComplete();
}
}, [user, onComplete]);
return (
<div>
<div className="mb-2 flex justify-center text-xl font-bold">
<FormattedMessage {...messages.welcome} />
</div>
<div className="mb-2 flex justify-center pb-6 text-sm">
<FormattedMessage {...messages.signinMessage} />
</div>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
onClick={() => handleClick(0)}
>
<FormattedMessage {...messages.signinWithPlex} />
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<PlexLoginButton
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
}}
/>
</div>
</AccordionContent>
<div>
<button
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(1)}
>
<FormattedMessage {...messages.signinWithJellyfin} />
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div
className="rounded-b-lg px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<JellyfinLogin initial={true} revalidate={revalidate} />
</div>
</AccordionContent>
</div>
</>
)}
</Accordion>
</div>
);
};
export default SetupLogin;

View File

@@ -10,9 +10,10 @@ import Button from '../Common/Button';
import ImageFader from '../Common/ImageFader';
import PageTitle from '../Common/PageTitle';
import LanguagePicker from '../Layout/LanguagePicker';
import SettingsJellyfin from '../Settings/SettingsJellyfin';
import SettingsPlex from '../Settings/SettingsPlex';
import SettingsServices from '../Settings/SettingsServices';
import LoginWithPlex from './LoginWithPlex';
import SetupLogin from './SetupLogin';
import SetupSteps from './SetupSteps';
const messages = defineMessages({
@@ -20,8 +21,8 @@ const messages = defineMessages({
finish: 'Finish Setup',
finishing: 'Finishing…',
continue: 'Continue',
loginwithplex: 'Sign in with Plex',
configureplex: 'Configure Plex',
signin: 'Sign In',
configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services',
tip: 'Tip',
scanbackground:
@@ -32,7 +33,9 @@ const Setup: React.FC = () => {
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [plexSettingsComplete, setPlexSettingsComplete] = useState(false);
const [mediaServerSettingsComplete, setMediaServerSettingsComplete] =
useState(false);
const [mediaServerType, setMediaServerType] = useState('');
const router = useRouter();
const { locale } = useLocale();
@@ -51,6 +54,11 @@ const Setup: React.FC = () => {
}
};
const getMediaServerType = async () => {
const MainSettings = await axios.get('/api/v1/settings/main');
setMediaServerType(MainSettings.data.mediaServerType);
return;
};
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
@@ -84,13 +92,13 @@ const Setup: React.FC = () => {
>
<SetupSteps
stepNumber={1}
description={intl.formatMessage(messages.loginwithplex)}
description={intl.formatMessage(messages.signin)}
active={currentStep === 1}
completed={currentStep > 1}
/>
<SetupSteps
stepNumber={2}
description={intl.formatMessage(messages.configureplex)}
description={intl.formatMessage(messages.configuremediaserver)}
active={currentStep === 2}
completed={currentStep > 2}
/>
@@ -104,11 +112,25 @@ const Setup: React.FC = () => {
</nav>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
{currentStep === 1 && (
<LoginWithPlex onComplete={() => setCurrentStep(2)} />
<SetupLogin
onComplete={() => {
getMediaServerType().then(() => {
setCurrentStep(2);
});
}}
/>
)}
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
{mediaServerType == 'PLEX' ? (
<SettingsPlex
onComplete={() => setMediaServerSettingsComplete(true)}
/>
) : (
<SettingsJellyfin
onComplete={() => setMediaServerSettingsComplete(true)}
/>
)}
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
@@ -120,7 +142,7 @@ const Setup: React.FC = () => {
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!plexSettingsComplete}
disabled={!mediaServerSettingsComplete}
onClick={() => setCurrentStep(3)}
>
{intl.formatMessage(messages.continue)}

View File

@@ -117,28 +117,28 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const mediaLinks: PlayButtonLink[] = [];
if (
data.mediaInfo?.plexUrl &&
data.mediaInfo?.mediaUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: data.mediaInfo?.plexUrl,
url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />,
});
}
if (
settings.currentSettings.series4kEnabled &&
data.mediaInfo?.plexUrl4k &&
data.mediaInfo?.mediaUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.play4konplex),
url: data.mediaInfo?.plexUrl4k,
url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />,
});
}
@@ -298,7 +298,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.plexUrl}
plexUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.series4kEnabled &&
hasPermission(
@@ -319,7 +319,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.plexUrl4k}
plexUrl={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
@@ -637,7 +637,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import { MediaServerType } from '../../../../../server/constants/server';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import {
availableLanguages,
@@ -29,6 +30,9 @@ const messages = defineMessages({
general: 'General',
generalsettings: 'General Settings',
displayName: 'Display Name',
save: 'Save Changes',
saving: 'Saving…',
mediaServerUser: '{mediaServerName} User',
accounttype: 'Account Type',
plexuser: 'Plex User',
localuser: 'Local User',
@@ -55,6 +59,7 @@ const messages = defineMessages({
const UserGeneralSettings: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { addToast } = useToasts();
const { locale, setLocale } = useLocale();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
@@ -184,11 +189,17 @@ const UserGeneralSettings: React.FC = () => {
<div className="flex max-w-lg items-center">
{user?.userType === UserType.PLEX ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
{intl.formatMessage(messages.localuser)}
</Badge>
) : (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</Badge>
)}
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import useSWR from 'swr';
import { MediaServerType } from '../../server/constants/server';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
export interface SettingsContextProps {
@@ -16,6 +17,7 @@ const defaultSettings = {
series4kEnabled: false,
region: '',
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
cacheImages: false,
vapidPublic: '',

View File

@@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react';
import { IntlProvider } from 'react-intl';
import { ToastProvider } from 'react-toast-notifications';
import { SWRConfig } from 'swr';
import { MediaServerType } from '../../server/constants/server';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import Layout from '../components/Layout';
import LoadingBar from '../components/LoadingBar';
@@ -165,6 +166,7 @@ CoreApp.getInitialProps = async (initialProps) => {
localLogin: true,
region: '',
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
cacheImages: false,
vapidPublic: '',

View File

@@ -0,0 +1,17 @@
import React from 'react';
import type { NextPage } from 'next';
import SettingsLayout from '../../components/Settings/SettingsLayout';
import SettingsJellyfin from '../../components/Settings/SettingsJellyfin';
import { Permission } from '../../hooks/useUser';
import useRouteGuard from '../../hooks/useRouteGuard';
const JellyfinSettingsPage: NextPage = () => {
useRouteGuard(Permission.MANAGE_SETTINGS);
return (
<SettingsLayout>
<SettingsJellyfin />
</SettingsLayout>
);
};
export default JellyfinSettingsPage;

50
src/utils/jellyfin.ts Normal file
View File

@@ -0,0 +1,50 @@
import axios, { AxiosError, AxiosResponse } from 'axios';
interface JellyfinAuthenticationResult {
Id: string;
AccessToken: string;
ServerId: string;
}
class JellyAPI {
public login(
Hostname?: string,
Username?: string,
Password?: string
): Promise<JellyfinAuthenticationResult> {
return new Promise(
(
resolve: (result: JellyfinAuthenticationResult) => void,
reject: (e: Error) => void
) => {
axios
.post(
Hostname + '/Users/AuthenticateByName',
{
Username: Username,
Pw: Password,
},
{
headers: {
'X-Emby-Authorization':
'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0"',
},
}
)
.then((resp: AxiosResponse) => {
const response: JellyfinAuthenticationResult = {
Id: resp.data.User.Id,
AccessToken: resp.data.AccessToken,
ServerId: resp.data.ServerId,
};
resolve(response);
})
.catch((e: AxiosError) => {
reject(e);
});
}
);
}
}
export default JellyAPI;