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 @@
-
+
**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 📖
Michael Dallinger 🌍
Janek 📖
+ Aleksa Siriški 🚇
+ Danish Humair 💻
+ 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 && (
+
+
+
+ )}
);
};