diff --git a/.all-contributorsrc b/.all-contributorsrc index 55ae43786..bd0f0f18f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -295,6 +295,33 @@ "contributions": [ "doc" ] + }, + { + "login": "aleksasiriski", + "name": "Aleksa Siriški", + "avatar_url": "https://avatars.githubusercontent.com/u/31509435?v=4", + "profile": "https://aleksasiriski.dev", + "contributions": [ + "infra" + ] + }, + { + "login": "Danish-H", + "name": "Danish Humair", + "avatar_url": "https://avatars.githubusercontent.com/u/121830048?v=4", + "profile": "http://danishhumair.com", + "contributions": [ + "code" + ] + }, + { + "login": "trackmastersteve", + "name": "Stephen Harris", + "avatar_url": "https://avatars.githubusercontent.com/u/16858514?v=4", + "profile": "https://arm0.red", + "contributions": [ + "doc" + ] } ] } diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index fa71c2941..600551f0a 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -29,7 +29,7 @@ jobs: with: context: . file: ./Dockerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true build-args: | COMMIT_TAG=${{ github.sha }} diff --git a/README.md b/README.md index ae78660fc..1b7540c66 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. -It is a a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers! +It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers! _The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_ @@ -40,11 +40,11 @@ With more features on the way! Check out our [issue tracker](https://github.com/ #### Pre-requisite (Important) -_*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_ +_*On Jellyfin/Emby, ensure the `Settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_ ### Launching Jellyseerr using Docker (Recommended) -Check out our dockerhub for instructions on how to install and run Jellyseerr: +Check out our docker hub for instructions on how to install and run Jellyseerr: https://hub.docker.com/r/fallenbagel/jellyseerr ### Database configuration @@ -99,16 +99,16 @@ yarn run build yarn start ``` -(you can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background) +(You can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background) -_to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_ +_To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_ #### Linux **Pre-requisites:** - Nodejs [v18](https://nodejs.org/en/download/package-manager) -- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`) +- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`) - Git **Steps:** @@ -119,7 +119,7 @@ _to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i cd /opt ``` -2. Then clone the follow commands to clone and checkout to the stable version +2. Then execute the following commands to clone and checkout to the stable version ```bash git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr @@ -138,9 +138,9 @@ yarn run build 5. If you want to run jellyseerr as a _Systemd-service:_ - assuming jellyseerr was cloned to `/opt/` -- first create the environmentfile at `/etc/jellyseerr/jellyseerr.conf` +- first create the environment file at `/etc/jellyseerr/jellyseerr.conf` -Environmentfile: +Environment file: ``` # Jellyseerr's default port is 5055, if you want to use both, change this. @@ -260,6 +260,9 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Athfan Khaleel
Athfan Khaleel

📖 Michael Dallinger
Michael Dallinger

🌍 Janek
Janek

📖 + Aleksa Siriški
Aleksa Siriški

🚇 + Danish Humair
Danish Humair

💻 + Stephen Harris
Stephen Harris

📖 diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index a2fc4b224..9f7309654 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import availabilitySync from '@server/lib/availabilitySync'; import logger from '@server/logger'; import type { AxiosInstance } from 'axios'; import axios from 'axios'; @@ -241,7 +242,9 @@ class JellyfinAPI { } } - public async getItemData(id: string): Promise { + public async getItemData( + id: string + ): Promise { try { const contents = await this.axios.get( `/Users/${this.userId}/Items/${id}` @@ -249,6 +252,11 @@ class JellyfinAPI { return contents.data; } catch (e) { + if (availabilitySync.running) { + if (e.response && e.response.status === 500) { + return undefined; + } + } logger.error( `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API' } @@ -261,9 +269,7 @@ class JellyfinAPI { try { const contents = await this.axios.get(`/Shows/${seriesID}/Seasons`); - return contents.data.Items.filter( - (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' - ); + return contents.data.Items; } catch (e) { logger.error( `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, diff --git a/server/job/schedule.ts b/server/job/schedule.ts index f65cdebbd..b358130ce 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,4 +1,5 @@ import { MediaServerType } from '@server/constants/server'; +import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; import { @@ -167,7 +168,7 @@ export const startJobs = (): void => { }); // Checks if media is still available in plex/sonarr/radarr libs - /* scheduledJobs.push({ + scheduledJobs.push({ id: 'availability-sync', name: 'Media Availability Sync', type: 'process', @@ -182,7 +183,6 @@ export const startJobs = (): void => { running: () => availabilitySync.running, cancelFn: () => availabilitySync.cancel(), }); -*/ // Run download sync every minute scheduledJobs.push({ diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0a16302cc..5bdbf593e 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -1,9 +1,12 @@ +import type { JellyfinLibraryItem } from '@server/api/jellyfin'; +import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; @@ -18,14 +21,20 @@ class AvailabilitySync { public running = false; private plexClient: PlexAPI; private plexSeasonsCache: Record; + + private jellyfinClient: JellyfinAPI; + private jellyfinSeasonsCache: Record; + private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; async run() { const settings = getSettings(); + const mediaServerType = getSettings().main.mediaServerType; this.running = true; this.plexSeasonsCache = {}; + this.jellyfinSeasonsCache = {}; this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); @@ -37,13 +46,53 @@ class AvailabilitySync { const pageSize = 50; const userRepository = getRepository(User); - const admin = await userRepository.findOne({ - select: { id: true, plexToken: true }, - where: { id: 1 }, - }); - if (admin) { - this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + // If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID + + let admin = null; + + if (mediaServerType === MediaServerType.PLEX) { + admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + admin = await userRepository.findOne({ + where: { id: 1 }, + select: [ + 'id', + 'jellyfinAuthToken', + 'jellyfinUserId', + 'jellyfinDeviceId', + ], + order: { id: 'ASC' }, + }); + } + + if (mediaServerType === MediaServerType.PLEX) { + if (admin && admin.plexToken) { + this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); + } else { + logger.error('Plex admin is not configured.'); + } + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + if (admin) { + this.jellyfinClient = new JellyfinAPI( + settings.jellyfin.hostname ?? '', + admin.jellyfinAuthToken, + admin.jellyfinDeviceId + ); + + this.jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); + } else { + logger.error('Jellyfin admin is not configured.'); + } } else { logger.error('An admin is not configured.'); } @@ -60,41 +109,84 @@ class AvailabilitySync { let movieExists = false; let movieExists4k = false; - const { existsInPlex } = await this.mediaExistsInPlex(media, false); - const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex( - media, - true - ); + // if (mediaServerType === MediaServerType.PLEX) { + // await this.mediaExistsInPlex(media, false); + // } else if ( + // mediaServerType === MediaServerType.JELLYFIN || + // mediaServerType === MediaServerType.EMBY + // ) { + // await this.mediaExistsInJellyfin(media, false); + // } const existsInRadarr = await this.mediaExistsInRadarr(media, false); const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); - if (existsInPlex || existsInRadarr) { - movieExists = true; - logger.info( - `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, - { - label: 'AvailabilitySync', - } - ); + // plex + if (mediaServerType === MediaServerType.PLEX) { + const { existsInPlex } = await this.mediaExistsInPlex(media, false); + const { existsInPlex: existsInPlex4k } = + await this.mediaExistsInPlex(media, true); + + if (existsInPlex || existsInRadarr) { + movieExists = true; + logger.info( + `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + + if (existsInPlex4k || existsInRadarr4k) { + movieExists4k = true; + logger.info( + `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } } - if (existsInPlex4k || existsInRadarr4k) { - movieExists4k = true; - logger.info( - `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, - { - label: 'AvailabilitySync', - } + //jellyfin + if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + const { existsInJellyfin } = await this.mediaExistsInJellyfin( + media, + false ); + const { existsInJellyfin: existsInJellyfin4k } = + await this.mediaExistsInJellyfin(media, true); + + if (existsInJellyfin || existsInRadarr) { + movieExists = true; + logger.info( + `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + + if (existsInJellyfin4k || existsInRadarr4k) { + movieExists4k = true; + logger.info( + `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } } if (!movieExists && media.status === MediaStatus.AVAILABLE) { - await this.mediaUpdater(media, false); + await this.mediaUpdater(media, false, mediaServerType); } if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { - await this.mediaUpdater(media, true); + await this.mediaUpdater(media, true, mediaServerType); } } @@ -104,6 +196,8 @@ class AvailabilitySync { let showExists = false; let showExists4k = false; + //plex + const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = await this.mediaExistsInPlex(media, false); const { @@ -111,6 +205,16 @@ class AvailabilitySync { seasonsMap: plexSeasonsMap4k = new Map(), } = await this.mediaExistsInPlex(media, true); + //jellyfin + const { + existsInJellyfin, + seasonsMap: jellyfinSeasonsMap = new Map(), + } = await this.mediaExistsInJellyfin(media, false); + const { + existsInJellyfin: existsInJellyfin4k, + seasonsMap: jellyfinSeasonsMap4k = new Map(), + } = await this.mediaExistsInJellyfin(media, true); + const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = await this.mediaExistsInSonarr(media, false); const { @@ -118,24 +222,60 @@ class AvailabilitySync { seasonsMap: sonarrSeasonsMap4k, } = await this.mediaExistsInSonarr(media, true); - if (existsInPlex || existsInSonarr) { - showExists = true; - logger.info( - `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, - { - label: 'AvailabilitySync', - } - ); + //plex + if (mediaServerType === MediaServerType.PLEX) { + if (existsInPlex || existsInSonarr) { + showExists = true; + logger.info( + `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } } - if (existsInPlex4k || existsInSonarr4k) { - showExists4k = true; - logger.info( - `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, - { - label: 'AvailabilitySync', - } - ); + if (mediaServerType === MediaServerType.PLEX) { + if (existsInPlex4k || existsInSonarr4k) { + showExists4k = true; + logger.info( + `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + } + + //jellyfin + if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + if (existsInJellyfin || existsInSonarr) { + showExists = true; + logger.info( + `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } + } + + if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + if (existsInJellyfin4k || existsInSonarr4k) { + showExists4k = true; + logger.info( + `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, + { + label: 'AvailabilitySync', + } + ); + } } // Here we will create a final map that will cross compare @@ -155,11 +295,45 @@ class AvailabilitySync { filteredSeasonsMap.set(season.seasonNumber, false) ); - const finalSeasons = new Map([ - ...filteredSeasonsMap, - ...plexSeasonsMap, - ...sonarrSeasonsMap, - ]); + // non-4k + const finalSeasons: Map = new Map(); + + if (mediaServerType === MediaServerType.PLEX) { + plexSeasonsMap.forEach((value, key) => { + finalSeasons.set(key, value); + }); + + filteredSeasonsMap.forEach((value, key) => { + if (!finalSeasons.has(key)) { + finalSeasons.set(key, value); + } + }); + + sonarrSeasonsMap.forEach((value, key) => { + if (!finalSeasons.has(key)) { + finalSeasons.set(key, value); + } + }); + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + jellyfinSeasonsMap.forEach((value, key) => { + finalSeasons.set(key, value); + }); + + filteredSeasonsMap.forEach((value, key) => { + if (!finalSeasons.has(key)) { + finalSeasons.set(key, value); + } + }); + + sonarrSeasonsMap.forEach((value, key) => { + if (!finalSeasons.has(key)) { + finalSeasons.set(key, value); + } + }); + } const filteredSeasonsMap4k: Map = new Map(); @@ -173,18 +347,64 @@ class AvailabilitySync { filteredSeasonsMap4k.set(season.seasonNumber, false) ); - const finalSeasons4k = new Map([ - ...filteredSeasonsMap4k, - ...plexSeasonsMap4k, - ...sonarrSeasonsMap4k, - ]); + // 4k + const finalSeasons4k: Map = new Map(); + + if (mediaServerType === MediaServerType.PLEX) { + plexSeasonsMap4k.forEach((value, key) => { + finalSeasons4k.set(key, value); + }); + + filteredSeasonsMap4k.forEach((value, key) => { + if (!finalSeasons4k.has(key)) { + finalSeasons4k.set(key, value); + } + }); + + sonarrSeasonsMap4k.forEach((value, key) => { + if (!finalSeasons4k.has(key)) { + finalSeasons4k.set(key, value); + } + }); + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + jellyfinSeasonsMap4k.forEach((value, key) => { + finalSeasons4k.set(key, value); + }); + + filteredSeasonsMap4k.forEach((value, key) => { + if (!finalSeasons4k.has(key)) { + finalSeasons4k.set(key, value); + } + }); + + sonarrSeasonsMap4k.forEach((value, key) => { + if (!finalSeasons4k.has(key)) { + finalSeasons4k.set(key, value); + } + }); + } + + // TODO: Figure out how to run seasonUpdater for each season if ([...finalSeasons.values()].includes(false)) { - await this.seasonUpdater(media, finalSeasons, false); + await this.seasonUpdater( + media, + finalSeasons, + false, + mediaServerType + ); } if ([...finalSeasons4k.values()].includes(false)) { - await this.seasonUpdater(media, finalSeasons4k, true); + await this.seasonUpdater( + media, + finalSeasons4k, + true, + mediaServerType + ); } if ( @@ -192,7 +412,7 @@ class AvailabilitySync { (media.status === MediaStatus.AVAILABLE || media.status === MediaStatus.PARTIALLY_AVAILABLE) ) { - await this.mediaUpdater(media, false); + await this.mediaUpdater(media, false, mediaServerType); } if ( @@ -200,7 +420,7 @@ class AvailabilitySync { (media.status4k === MediaStatus.AVAILABLE || media.status4k === MediaStatus.PARTIALLY_AVAILABLE) ) { - await this.mediaUpdater(media, true); + await this.mediaUpdater(media, true, mediaServerType); } } } @@ -272,7 +492,11 @@ class AvailabilitySync { return mediaStatus; } - private async mediaUpdater(media: Media, is4k: boolean): Promise { + private async mediaUpdater( + media: Media, + is4k: boolean, + mediaServerType: MediaServerType + ): Promise { const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); @@ -320,17 +544,32 @@ class AvailabilitySync { mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] : null; - media[is4k ? 'ratingKey4k' : 'ratingKey'] = - mediaStatus === MediaStatus.PROCESSING - ? media[is4k ? 'ratingKey4k' : 'ratingKey'] - : null; - + if (mediaServerType === MediaServerType.PLEX) { + media[is4k ? 'ratingKey4k' : 'ratingKey'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'ratingKey4k' : 'ratingKey'] + : undefined; + } else if ( + mediaServerType === MediaServerType.JELLYFIN || + mediaServerType === MediaServerType.EMBY + ) { + media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] = + mediaStatus === MediaStatus.PROCESSING + ? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] + : undefined; + } logger.info( `The ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'movie' ? 'movie' : 'show' } [TMDB ID ${media.tmdbId}] was not found in any ${ media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' - } and Plex instance. Status will be changed to unknown.`, + } and ${ + mediaServerType === MediaServerType.PLEX + ? 'plex' + : mediaServerType === MediaServerType.JELLYFIN + ? 'jellyfin' + : 'emby' + } instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); @@ -358,7 +597,8 @@ class AvailabilitySync { private async seasonUpdater( media: Media, seasons: Map, - is4k: boolean + is4k: boolean, + mediaServerType: MediaServerType ): Promise { const mediaRepository = getRepository(Media); const seasonRequestRepository = getRepository(SeasonRequest); @@ -370,6 +610,8 @@ class AvailabilitySync { ); const seasonKeys = [...seasonsPendingRemoval.keys()]; + // let isSeasonRemoved = false; + try { // Need to check and see if there are any related season // requests. If they are, we will need to delete them. @@ -420,7 +662,13 @@ class AvailabilitySync { media.tmdbId }] was not found in any ${ media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' - } and Plex instance. Status will be changed to unknown.`, + } and ${ + mediaServerType === MediaServerType.PLEX + ? 'plex' + : mediaServerType === MediaServerType.JELLYFIN + ? 'jellyfin' + : 'emby' + } instance. Status will be changed to unknown.`, { label: 'AvailabilitySync' } ); } catch (ex) { @@ -604,6 +852,7 @@ class AvailabilitySync { return seasonExists; } + // Plex private async mediaExistsInPlex( media: Media, is4k: boolean @@ -719,6 +968,123 @@ class AvailabilitySync { return seasonExistsInPlex; } + + // Jellyfin + private async mediaExistsInJellyfin( + media: Media, + is4k: boolean + ): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map }> { + const ratingKey = media.jellyfinMediaId; + const ratingKey4k = media.jellyfinMediaId4k; + let existsInJellyfin = false; + let preventSeasonSearch = false; + + // Check each jellyfin instance to see if the media still exists + // If found, we will assume the media exists and prevent removal + // We can use the cache we built when we fetched the series with mediaExistsInJellyfin + try { + let jellyfinMedia: JellyfinLibraryItem | undefined; + + if (ratingKey && !is4k) { + jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey); + + if (media.mediaType === 'tv' && jellyfinMedia !== undefined) { + this.jellyfinSeasonsCache[ratingKey] = + await this.jellyfinClient?.getSeasons(ratingKey); + } + } + + if (ratingKey4k && is4k) { + jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k); + + if (media.mediaType === 'tv' && jellyfinMedia !== undefined) { + this.jellyfinSeasonsCache[ratingKey4k] = + await this.jellyfinClient?.getSeasons(ratingKey4k); + } + } + + if (jellyfinMedia) { + existsInJellyfin = true; + } + } catch (ex) { + if (!ex.message.includes('404' || '500')) { + existsInJellyfin = false; + preventSeasonSearch = true; + logger.debug( + `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ + media.mediaType === 'tv' ? 'show' : 'movie' + } [TMDB ID ${media.tmdbId}] from Jellyfin.`, + { + errorMessage: ex.message, + label: 'AvailabilitySync', + } + ); + } + } + + // Here we check each season in jellyfin for availability + // If the API returns an error other than a 404, + // we will have to prevent the season check from happening + if (media.mediaType === 'tv') { + const seasonsMap: Map = new Map(); + + if (!preventSeasonSearch) { + const filteredSeasons = media.seasons.filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE + ); + + for (const season of filteredSeasons) { + const seasonExists = await this.seasonExistsInJellyfin( + media, + season, + is4k + ); + + if (seasonExists) { + seasonsMap.set(season.seasonNumber, true); + } + } + } + + return { existsInJellyfin, seasonsMap }; + } + + return { existsInJellyfin }; + } + + private async seasonExistsInJellyfin( + media: Media, + season: Season, + is4k: boolean + ): Promise { + const ratingKey = media.jellyfinMediaId; + const ratingKey4k = media.jellyfinMediaId4k; + let seasonExistsInJellyfin = false; + + // Check each jellyfin instance to see if the season exists + let jellyfinSeasons: JellyfinLibraryItem[] | undefined; + + if (ratingKey && !is4k) { + jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey]; + } + + if (ratingKey4k && is4k) { + jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k]; + } + + const seasonIsAvailable = jellyfinSeasons?.find( + (jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber + ); + + if (seasonIsAvailable) { + seasonExistsInJellyfin = true; + } + + return seasonExistsInJellyfin; + } } const availabilitySync = new AvailabilitySync(); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index c231ec0dc..193882ed5 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -62,7 +62,7 @@ class JellyfinScanner { const metadata = await this.jfClient.getItemData(jellyfinitem.Id); const newMedia = new Media(); - if (!metadata.Id) { + if (!metadata?.Id) { logger.debug('No Id metadata for this title. Skipping', { label: 'Plex Sync', ratingKey: jellyfinitem.Id, @@ -197,6 +197,14 @@ class JellyfinScanner { jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id; const metadata = await this.jfClient.getItemData(Id); + if (!metadata?.Id) { + logger.debug('No Id metadata for this title. Skipping', { + label: 'Plex Sync', + ratingKey: jellyfinitem.Id, + }); + return; + } + if (metadata.ProviderIds.Tvdb) { tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(metadata.ProviderIds.Tvdb), @@ -275,7 +283,7 @@ class JellyfinScanner { episode.Id ); - ExtendedEpisodeData.MediaSources?.some((MediaSource) => { + ExtendedEpisodeData?.MediaSources?.some((MediaSource) => { return MediaSource.MediaStreams.some((MediaStream) => { if (MediaStream.Type === 'Video') { if ((MediaStream.Width ?? 0) >= 2000) { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 7adcc73ab..c3d9d0f61 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -11,6 +11,7 @@ import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; const authRoutes = Router(); @@ -274,24 +275,82 @@ authRoutes.post('/jellyfin', async (req, res, next) => { where: { jellyfinUserId: account.User.Id }, }); - if (user) { + if (!user && !(await userRepository.count())) { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); + + // User doesn't exist, and there are no users in the database, we'll create the user + // with admin permission + settings.main.mediaServerType = MediaServerType.JELLYFIN; + 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 + ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }), + userType: UserType.JELLYFIN, + }); + + settings.jellyfin.hostname = body.hostname ?? ''; + settings.jellyfin.serverId = account.User.ServerId; + settings.save(); + startJobs(); + + await userRepository.save(user); + } + // User already exists, let's update their information + else if (body.username === user?.jellyfinUsername) { + logger.info( + `Found matching ${ + settings.main.mediaServerType === MediaServerType.JELLYFIN + ? 'Jellyfin' + : 'Emby' + } user; updating user with ${ + settings.main.mediaServerType === MediaServerType.JELLYFIN + ? 'Jellyfin' + : 'Emby' + }`, + { + label: 'API', + ip: req.ip, + jellyfinUsername: account.User.Name, + } + ); // 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 = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; } else { - user.avatar = '/os_logo_square.png'; + user.avatar = gravatarUrl(user.email, { + default: 'mm', + size: 200, + }); } - user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { user.username = ''; } + + // If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY + if (process.env.JELLYFIN_TYPE === 'emby') { + settings.main.mediaServerType = MediaServerType.EMBY; + settings.save(); + } + await userRepository.save(user); } else if (!settings.main.newPlexLogin) { logger.warn( @@ -307,69 +366,38 @@ authRoutes.post('/jellyfin', async (req, res, next) => { status: 403, message: 'Access denied.', }); - } 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) { - logger.info( - 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', - { - label: 'API', - ip: req.ip, - jellyfinUsername: account.User.Name, - } - ); - user = new User({ - email: body.email, + } else if (!user) { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user', + { + label: 'API', + ip: req.ip, jellyfinUsername: account.User.Name, - jellyfinUserId: account.User.Id, - jellyfinDeviceId: deviceId, - jellyfinAuthToken: account.AccessToken, - permissions: Permission.ADMIN, - avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/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(); - startJobs(); } + ); + + if (!body.email) { + throw new Error('add_email'); } - 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 - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : '/os_logo_square.png', - userType: UserType.JELLYFIN, - }); - //initialize Jellyfin/Emby users with local login - const passedExplicitPassword = - body.password && body.password.length > 0; - if (passedExplicitPassword) { - await user.setPassword(body.password ?? ''); - } - await userRepository.save(user); + 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 + ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + : gravatarUrl(body.email, { default: 'mm', size: 200 }), + userType: UserType.JELLYFIN, + }); + //initialize Jellyfin/Emby users with local login + const passedExplicitPassword = body.password && body.password.length > 0; + if (passedExplicitPassword) { + await user.setPassword(body.password ?? ''); } + await userRepository.save(user); } // Set logged in session @@ -400,6 +428,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => { status: 406, message: 'CREDENTIAL_ERROR_ADD_EMAIL', }); + } else if (e.message === 'select_server_type') { + return next({ + status: 406, + message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE', + }); } else { logger.error(e.message, { label: 'Auth' }); return next({ diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 5703cccc0..de86ed71b 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -29,6 +29,7 @@ import { getAppVersion } from '@server/utils/appVersion'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; +import gravatarUrl from 'gravatar-url'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; @@ -337,7 +338,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { id: user.Id, thumb: user.PrimaryImageTag ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` - : '/os_logo_square.png', + : gravatarUrl(user.Name, { default: 'mm', size: 200 }), email: user.Name, })); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 9d9370cf2..789c90765 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -537,7 +537,10 @@ router.post( permissions: settings.main.defaultPermissions, avatar: jellyfinUser?.PrimaryImageTag ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` - : '/os_logo_square.png', + : gravatarUrl(jellyfinUser?.Name ?? '', { + default: 'mm', + size: 200, + }), userType: UserType.JELLYFIN, }); diff --git a/src/assets/services/letterboxd.svg b/src/assets/services/letterboxd.svg new file mode 100644 index 000000000..ccce42b5a --- /dev/null +++ b/src/assets/services/letterboxd.svg @@ -0,0 +1,20 @@ + + + + letterboxd-logo-alt-neg + Created with Sketch. + + + + + + + + + + + + + + + diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx index 1de0b5a36..9782d186c 100644 --- a/src/components/ExternalLinkBlock/index.tsx +++ b/src/components/ExternalLinkBlock/index.tsx @@ -1,6 +1,7 @@ import EmbyLogo from '@app/assets/services/emby.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; import JellyfinLogo from '@app/assets/services/jellyfin.svg'; +import LetterboxdLogo from '@app/assets/services/letterboxd.svg'; import PlexLogo from '@app/assets/services/plex.svg'; import RTLogo from '@app/assets/services/rt.svg'; import TmdbLogo from '@app/assets/services/tmdb.svg'; @@ -103,6 +104,16 @@ const ExternalLinkBlock = ({ )} + {tmdbId && mediaType === MediaType.MOVIE && ( + + + + )} ); };