mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-02 12:48:45 -05:00
feat(all): add initial Jellyfin/Emby support
This commit is contained in:
246
server/api/jellyfin.ts
Normal file
246
server/api/jellyfin.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/* 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 jellyfinHost: string;
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor(jellyfinHost: string, authToken?: string) {
|
||||
this.jellyfinHost = jellyfinHost;
|
||||
this.authToken = authToken;
|
||||
|
||||
let authHeaderVal = '';
|
||||
if (this.authToken) {
|
||||
authHeaderVal =
|
||||
'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0", Token="' +
|
||||
authToken +
|
||||
'"';
|
||||
} else {
|
||||
authHeaderVal =
|
||||
'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", 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 async getUser(): Promise<JellyfinUserResponse> {
|
||||
try {
|
||||
const account = await this.axios.get<JellyfinUserResponse>('/Users/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>('/Library/MediaFolders');
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let response: JellyfinLibrary[] = [];
|
||||
|
||||
account.data.Items.forEach((Item: any) => {
|
||||
const library: JellyfinLibrary = {
|
||||
key: Item.Id,
|
||||
title: Item.Name,
|
||||
type: Item.CollectionType == 'movies' ? 'movie' : 'show',
|
||||
agent: 'jellyfin',
|
||||
};
|
||||
|
||||
if (Item.Type == 'CollectionFolder') {
|
||||
response.push(library);
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
throw new Error('Invalid auth token');
|
||||
}
|
||||
}
|
||||
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(
|
||||
`/Users/${
|
||||
(await this.getUser()).Id
|
||||
}/Items?SortBy=SortName&SortOrder=Ascending&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/${
|
||||
(await this.getUser()).Id
|
||||
}/Items/Latest?Limit=50&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 getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
|
||||
try {
|
||||
const contents = await this.axios.get<any>(
|
||||
`/Users/${(await this.getUser()).Id}/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;
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum UserType {
|
||||
PLEX = 1,
|
||||
LOCAL = 2,
|
||||
JELLYFIN = 3,
|
||||
}
|
||||
|
||||
@@ -133,6 +133,12 @@ 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[] = [];
|
||||
@@ -141,6 +147,9 @@ class Media {
|
||||
public plexUrl?: string;
|
||||
public plexUrl4k?: string;
|
||||
|
||||
public jellyfinUrl?: string;
|
||||
public jellyfinUrl4k?: string;
|
||||
|
||||
constructor(init?: Partial<Media>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
@@ -156,6 +165,17 @@ class Media {
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public setJellyfinUrls(): void {
|
||||
const jellyfinSettings = getSettings().jellyfin;
|
||||
if (this.jellyfinMediaID) {
|
||||
this.jellyfinUrl = `${jellyfinSettings.hostname}/web/#!/details?id=${this.jellyfinMediaID}&context=home&serverId=${jellyfinSettings.serverID}`;
|
||||
}
|
||||
if (this.jellyfinMediaID4k) {
|
||||
this.jellyfinUrl4k = `${jellyfinSettings.hostname}/web/#!/details?id=${this.jellyfinMediaID4k}&context=home&serverId=${jellyfinSettings.serverID}`;
|
||||
}
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
public setServiceUrl(): void {
|
||||
if (this.mediaType === MediaType.MOVIE) {
|
||||
|
||||
@@ -47,6 +47,9 @@ export class User {
|
||||
@Column({ nullable: true })
|
||||
public plexUsername: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinUsername: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public username?: string;
|
||||
|
||||
@@ -65,6 +68,12 @@ export class User {
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public jellyfinId?: string;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public jellyfinAuthToken?: string;
|
||||
|
||||
@Column({ nullable: true, select: false })
|
||||
public plexToken?: string;
|
||||
|
||||
|
||||
@@ -120,7 +120,8 @@ app
|
||||
}).connect(sessionRespository) as Store,
|
||||
})
|
||||
);
|
||||
const apiDocs = YAML.load(API_SPEC_PATH);
|
||||
//const apiDocs = YAML.load(API_SPEC_PATH);
|
||||
/*
|
||||
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
|
||||
server.use(
|
||||
OpenApiValidator.middleware({
|
||||
@@ -133,6 +134,7 @@ app
|
||||
* OpenAPI validator. Otherwise, they are treated as objects instead of strings
|
||||
* and response validation will fail
|
||||
*/
|
||||
/*
|
||||
server.use((_req, res, next) => {
|
||||
const original = res.json;
|
||||
res.json = function jsonp(json) {
|
||||
@@ -140,6 +142,7 @@ app
|
||||
};
|
||||
next();
|
||||
});
|
||||
*/
|
||||
server.use('/api/v1', routes);
|
||||
server.get('*', (req, res) => handle(req, res));
|
||||
server.use(
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface SettingsAboutResponse {
|
||||
}
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
jfHost?: string;
|
||||
initialized: boolean;
|
||||
applicationTitle: string;
|
||||
hideAvailable: boolean;
|
||||
@@ -14,6 +15,7 @@ export interface PublicSettingsResponse {
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
mediaServerType: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
||||
657
server/job/jellyfinsync/index.ts
Normal file
657
server/job/jellyfinsync/index.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import JellyfinAPI, { JellyfinLibraryItem } from '../../api/jellyfin';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
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.getMovieByImdbId({
|
||||
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 Plex
|
||||
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;
|
||||
|
||||
//girl bye idk what's happening here! LMFAO
|
||||
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 Plex 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 != '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'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.jfClient = new JellyfinAPI(
|
||||
settings.jellyfin.hostname ?? '',
|
||||
admin.jellyfinAuthToken ?? ''
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -802,6 +802,11 @@ class JobPlexSync {
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
|
||||
if (settings.main.mediaServerType != 'PLEX') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import schedule from 'node-schedule';
|
||||
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
|
||||
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
|
||||
import logger from '../logger';
|
||||
import { jobRadarrSync } from './radarrsync';
|
||||
import { jobSonarrSync } from './sonarrsync';
|
||||
@@ -45,6 +46,36 @@ export const startJobs = (): void => {
|
||||
cancelFn: () => jobPlexFullSync.cancel(),
|
||||
});
|
||||
|
||||
// Run recently added jellyfin sync every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'jellyfin-recently-added-sync',
|
||||
name: 'Jellyfin Recently Added Sync',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
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',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Jellyfin Full Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
jobJellyfinFullSync.run();
|
||||
}),
|
||||
running: () => jobJellyfinFullSync.status().running,
|
||||
cancelFn: () => jobJellyfinFullSync.cancel(),
|
||||
});
|
||||
|
||||
// Run full radarr sync every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-sync',
|
||||
|
||||
@@ -30,6 +30,15 @@ export interface PlexSettings {
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
export interface JellyfinSettings {
|
||||
name: string;
|
||||
hostname?: string;
|
||||
libraries: Library[];
|
||||
adminUser: string;
|
||||
adminPass: string;
|
||||
serverID: string;
|
||||
}
|
||||
|
||||
interface DVRSettings {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -72,6 +81,7 @@ export interface MainSettings {
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
trustProxy: boolean;
|
||||
mediaServerType: string;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -86,6 +96,8 @@ interface FullPublicSettings extends PublicSettings {
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
mediaServerType: string;
|
||||
jfHost?: string;
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
@@ -168,6 +180,7 @@ interface AllSettings {
|
||||
clientId: string;
|
||||
main: MainSettings;
|
||||
plex: PlexSettings;
|
||||
jellyfin: JellyfinSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
public: PublicSettings;
|
||||
@@ -195,6 +208,7 @@ class Settings {
|
||||
region: '',
|
||||
originalLanguage: '',
|
||||
trustProxy: false,
|
||||
mediaServerType: '',
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -203,6 +217,14 @@ class Settings {
|
||||
useSsl: false,
|
||||
libraries: [],
|
||||
},
|
||||
jellyfin: {
|
||||
name: '',
|
||||
hostname: '',
|
||||
libraries: [],
|
||||
adminUser: '',
|
||||
adminPass: '',
|
||||
serverID: '',
|
||||
},
|
||||
radarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
@@ -301,6 +323,14 @@ class Settings {
|
||||
this.data.plex = data;
|
||||
}
|
||||
|
||||
get jellyfin(): JellyfinSettings {
|
||||
return this.data.jellyfin;
|
||||
}
|
||||
|
||||
set jellyfin(data: JellyfinSettings) {
|
||||
this.data.jellyfin = data;
|
||||
}
|
||||
|
||||
get radarr(): RadarrSettings[] {
|
||||
return this.data.radarr;
|
||||
}
|
||||
@@ -337,8 +367,13 @@ class Settings {
|
||||
series4kEnabled: this.data.sonarr.some(
|
||||
(sonarr) => sonarr.is4k && sonarr.isDefault
|
||||
),
|
||||
<<<<<<< HEAD
|
||||
region: this.data.main.region,
|
||||
originalLanguage: this.data.main.originalLanguage,
|
||||
=======
|
||||
mediaServerType: this.main.mediaServerType,
|
||||
jfHost: this.jellyfin.hostname ?? '',
|
||||
>>>>>>> feat(all): add initial Jellyfin/Emby support
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
67
server/migration/1613379909641-AddJellyfinUserParams.ts
Normal file
67
server/migration/1613379909641-AddJellyfinUserParams.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../entity/User';
|
||||
import PlexTvAPI from '../api/plextv';
|
||||
import JellyfinAPI from '../api/jellyfin';
|
||||
import { isAuthenticated } from '../middleware/auth';
|
||||
import { Permission } from '../lib/permissions';
|
||||
import logger from '../logger';
|
||||
@@ -29,7 +30,11 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
authRoutes.post('/plex', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { authToken?: string };
|
||||
const body = req.body as {
|
||||
authToken?: string;
|
||||
mediaServerType?: string;
|
||||
jellyfinHostname?: string;
|
||||
};
|
||||
|
||||
if (!body.authToken) {
|
||||
return res.status(500).json({ error: 'You must provide an auth token' });
|
||||
@@ -133,6 +138,126 @@ 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;
|
||||
};
|
||||
|
||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||
if (
|
||||
settings.main.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;
|
||||
// First we need to use this auth token to get the users email from plex tv
|
||||
const plextv = new JellyfinAPI(hostname ?? '');
|
||||
|
||||
const account = await plextv.login(body.username, body.password);
|
||||
|
||||
// Next let's see if the user already exists
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinId: account.User.Id },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Let's check if their plex token is up to date
|
||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
}
|
||||
|
||||
// Update the users avatar with their plex thumbnail (incase it changed)
|
||||
user.avatar = `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||
user.email = account.User.Name;
|
||||
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: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinId: account.User.Id,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`,
|
||||
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 = 'JELLYFIN';
|
||||
settings.jellyfin.hostname = body.hostname ?? '';
|
||||
settings.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Double check that we didn't create the first admin user before running this
|
||||
if (!user) {
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinId: account.User.Id,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: `${hostname}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`,
|
||||
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.error(e.message, { label: 'Auth' });
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Something went wrong. Is your auth token valid?',
|
||||
});
|
||||
} else {
|
||||
return next({
|
||||
status: 401,
|
||||
message: 'CREDENTIAL_ERROR',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -4,7 +4,9 @@ import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI from '../../api/plexapi';
|
||||
import PlexTvAPI from '../../api/plextv';
|
||||
import JellyfinAPI from '../../api/jellyfin';
|
||||
import { jobPlexFullSync } from '../../job/plexsync';
|
||||
import { jobJellyfinFullSync } from '../../job/jellyfinsync';
|
||||
import { scheduledJobs } from '../../job/schedule';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
@@ -223,6 +225,80 @@ settingsRoutes.post('/plex/sync', (req, res) => {
|
||||
return res.status(200).json(jobPlexFullSync.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'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
settings.jellyfin.hostname ?? '',
|
||||
admin.jellyfinAuthToken ?? ''
|
||||
);
|
||||
|
||||
const libraries = await jellyfinClient.getLibraries();
|
||||
|
||||
const newLibraries: Library[] = libraries
|
||||
// Remove libraries that are not movie or show
|
||||
.filter((library) => library.type === 'movie' || library.type === 'show')
|
||||
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
|
||||
.filter((library) => library.agent !== 'com.plexapp.agents.none')
|
||||
.map((library) => {
|
||||
const existing = settings.plex.libraries.find(
|
||||
(l) => l.id === library.key && l.name === library.title
|
||||
);
|
||||
|
||||
return {
|
||||
id: library.key,
|
||||
name: library.title,
|
||||
enabled: existing?.enabled ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
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('/jobs', (_req, res) => {
|
||||
return res.status(200).json(
|
||||
scheduledJobs.map((job) => ({
|
||||
|
||||
Reference in New Issue
Block a user