diff --git a/.all-contributorsrc b/.all-contributorsrc
index bec843263..bd0f0f18f 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -277,6 +277,51 @@
"contributions": [
"doc"
]
+ },
+ {
+ "login": "mdll23",
+ "name": "Michael Dallinger",
+ "avatar_url": "https://avatars.githubusercontent.com/u/142844478?v=4",
+ "profile": "https://github.com/mdll23",
+ "contributions": [
+ "translation"
+ ]
+ },
+ {
+ "login": "xeruf",
+ "name": "Janek",
+ "avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
+ "profile": "https://github.com/xeruf",
+ "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/CONTRIBUTING.md b/CONTRIBUTING.md
index 96e67c8ac..5b4032065 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,4 +1,4 @@
-# Contributing to Overseerr
+# Contributing to Jellyseerr
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
@@ -17,7 +17,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
```bash
- git clone https://github.com/YOUR_USERNAME/overseerr.git
+ git clone https://github.com/YOUR_USERNAME/jellyseerr.git
cd overseerr/
```
@@ -97,9 +97,9 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation
-We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
+We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
-
+
## Attribution
diff --git a/README.md b/README.md
index 0dc45fe73..f07ac5e3e 100644
--- a/README.md
+++ b/README.md
@@ -2,23 +2,28 @@
-
+
+
+
+
+
+
-
+
-**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
+**Jellyseerr** is a free and open source software application for managing requests for your media library.
+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!_
## Current Features
-- Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
-- Supports Movies, Shows, Mixed Libraries!
+- Full Jellyfin/Emby/Plex integration including authentication with user import & management
+- Supports Movies, Shows and Mixed Libraries
- Ability to change email addresses for smtp purposes
-- Ability to import all jellyfin/emby users
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
@@ -35,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
### Building from source (ADVANCED):
@@ -49,7 +54,7 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
Pre-requisites:
- Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
-- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
+- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
- Download/git clone the source code from the github (Either develop branch or main for stable)
```cmd
@@ -59,16 +64,17 @@ yarn install --frozen-lockfile --network-timeout 1000000
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)
-_to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
+(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_
#### 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:**
@@ -79,7 +85,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
@@ -98,9 +104,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.
@@ -136,6 +142,7 @@ ExecStart=/usr/bin/node dist/index.js
[Install]
WantedBy=multi-user.target
```
+
### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
@@ -217,6 +224,11 @@ 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/overseerr-api.yml b/overseerr-api.yml
index e070361be..7a7ea490a 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -368,6 +368,9 @@ components:
externalHostname:
type: string
example: 'http://my.jellyfin.host'
+ jellyfinForgotPasswordUrl:
+ type: string
+ example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html'
adminUser:
type: string
example: 'admin'
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/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts
index b709a0c4b..1bf40cdbc 100644
--- a/server/interfaces/api/settingsInterfaces.ts
+++ b/server/interfaces/api/settingsInterfaces.ts
@@ -24,6 +24,7 @@ export interface PublicSettingsResponse {
jellyfinHost?: string;
jellyfinExternalHost?: string;
jellyfinServerName?: string;
+ jellyfinForgotPasswordUrl?: string;
initialized: boolean;
applicationTitle: string;
applicationUrl: string;
diff --git a/server/job/schedule.ts b/server/job/schedule.ts
index 15bf033ed..b358130ce 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -1,6 +1,11 @@
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 {
+ jellyfinFullScanner,
+ jellyfinRecentScanner,
+} from '@server/lib/scanners/jellyfin';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -10,7 +15,6 @@ import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
-import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob {
id: JobId;
@@ -73,38 +77,38 @@ export const startJobs = (): void => {
// Run recently added jellyfin sync every 5 minutes
scheduledJobs.push({
id: 'jellyfin-recently-added-scan',
- name: 'Jellyfin Recently Added Sync',
+ name: 'Jellyfin Recently Added Scan',
type: 'process',
interval: 'minutes',
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
job: schedule.scheduleJob(
jobs['jellyfin-recently-added-scan'].schedule,
() => {
- logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
+ logger.info('Starting scheduled job: Jellyfin Recently Added Scan', {
label: 'Jobs',
});
- jobJellyfinRecentSync.run();
+ jellyfinRecentScanner.run();
}
),
- running: () => jobJellyfinRecentSync.status().running,
- cancelFn: () => jobJellyfinRecentSync.cancel(),
+ running: () => jellyfinRecentScanner.status().running,
+ cancelFn: () => jellyfinRecentScanner.cancel(),
});
// Run full jellyfin sync every 24 hours
scheduledJobs.push({
id: 'jellyfin-full-scan',
- name: 'Jellyfin Full Library Sync',
+ name: 'Jellyfin Full Library Scan',
type: 'process',
interval: 'hours',
cronSchedule: jobs['jellyfin-full-scan'].schedule,
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
- logger.info('Starting scheduled job: Jellyfin Full Sync', {
+ logger.info('Starting scheduled job: Jellyfin Full Scan', {
label: 'Jobs',
});
- jobJellyfinFullSync.run();
+ jellyfinFullScanner.run();
}),
- running: () => jobJellyfinFullSync.status().running,
- cancelFn: () => jobJellyfinFullSync.cancel(),
+ running: () => jellyfinFullScanner.status().running,
+ cancelFn: () => jellyfinFullScanner.cancel(),
});
}
@@ -164,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',
@@ -179,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/job/jellyfinsync/index.ts b/server/lib/scanners/jellyfin/index.ts
similarity index 98%
rename from server/job/jellyfinsync/index.ts
rename to server/lib/scanners/jellyfin/index.ts
index b263ec6e4..193882ed5 100644
--- a/server/job/jellyfinsync/index.ts
+++ b/server/lib/scanners/jellyfin/index.ts
@@ -26,7 +26,7 @@ interface SyncStatus {
libraries: Library[];
}
-class JobJellyfinSync {
+class JellyfinScanner {
private sessionId: string;
private tmdb: TheMovieDb;
private jfClient: JellyfinAPI;
@@ -62,7 +62,7 @@ class JobJellyfinSync {
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 JobJellyfinSync {
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 JobJellyfinSync {
episode.Id
);
- ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
+ ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') {
if ((MediaStream.Width ?? 0) >= 2000) {
@@ -675,7 +683,7 @@ class JobJellyfinSync {
}
}
-export const jobJellyfinFullSync = new JobJellyfinSync();
-export const jobJellyfinRecentSync = new JobJellyfinSync({
+export const jellyfinFullScanner = new JellyfinScanner();
+export const jellyfinRecentScanner = new JellyfinScanner({
isRecentOnly: true,
});
diff --git a/server/lib/settings.ts b/server/lib/settings.ts
index 133d0e327..63f952363 100644
--- a/server/lib/settings.ts
+++ b/server/lib/settings.ts
@@ -40,6 +40,7 @@ export interface JellyfinSettings {
name: string;
hostname: string;
externalHostname?: string;
+ jellyfinForgotPasswordUrl?: string;
libraries: Library[];
serverId: string;
}
@@ -131,6 +132,7 @@ interface FullPublicSettings extends PublicSettings {
mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string;
+ jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string;
partialRequestsEnabled: boolean;
cacheImages: boolean;
@@ -331,6 +333,7 @@ class Settings {
name: '',
hostname: '',
externalHostname: '',
+ jellyfinForgotPasswordUrl: '',
libraries: [],
serverId: '',
},
@@ -534,6 +537,7 @@ class Settings {
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin,
+ jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),
diff --git a/server/routes/auth.ts b/server/routes/auth.ts
index b709bd78b..a63ef6e70 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();
@@ -278,19 +279,70 @@ 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) {
@@ -318,86 +370,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 === '') {
- // If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
- if (
- process.env.JELLYFIN_TYPE === 'emby' ||
- body.selectedservice === 'Emby'
- ) {
- settings.main.mediaServerType = MediaServerType.EMBY;
- } else if (body.selectedservice === 'Jellyfin') {
- 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');
- }
-
- if (
- !body.selectedservice &&
- (body.selectedservice !== 'Emby' || 'Jellyfin')
- ) {
- throw new Error('select_server_type');
- }
-
- 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
diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts
index dc3724207..de86ed71b 100644
--- a/server/routes/settings/index.ts
+++ b/server/routes/settings/index.ts
@@ -12,12 +12,12 @@ import type {
LogsResultsResponse,
SettingsAboutResponse,
} from '@server/interfaces/api/settingsInterfaces';
-import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
+import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
@@ -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,
}));
@@ -345,16 +346,16 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
});
settingsRoutes.get('/jellyfin/sync', (_req, res) => {
- return res.status(200).json(jobJellyfinFullSync.status());
+ return res.status(200).json(jellyfinFullScanner.status());
});
settingsRoutes.post('/jellyfin/sync', (req, res) => {
if (req.body.cancel) {
- jobJellyfinFullSync.cancel();
+ jellyfinFullScanner.cancel();
} else if (req.body.start) {
- jobJellyfinFullSync.run();
+ jellyfinFullScanner.run();
}
- return res.status(200).json(jobJellyfinFullSync.status());
+ return res.status(200).json(jellyfinFullScanner.status());
});
settingsRoutes.get('/tautulli', (_req, res) => {
const settings = getSettings();
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/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx
index 907cc8e24..46c946ae2 100644
--- a/src/components/Common/ListView/index.tsx
+++ b/src/components/Common/ListView/index.tsx
@@ -19,6 +19,7 @@ type ListViewProps = {
isLoading?: boolean;
isReachingEnd?: boolean;
onScrollBottom: () => void;
+ mutateParent?: () => void;
};
const ListView = ({
@@ -28,6 +29,7 @@ const ListView = ({
onScrollBottom,
isReachingEnd,
plexItems,
+ mutateParent,
}: ListViewProps) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@@ -46,7 +48,9 @@ const ListView = ({
id={title.tmdbId}
tmdbId={title.tmdbId}
type={title.mediaType}
+ isAddedToWatchlist={true}
canExpand
+ mutateParent={mutateParent}
/>
);
diff --git a/src/components/Discover/DiscoverWatchlist/index.tsx b/src/components/Discover/DiscoverWatchlist/index.tsx
index 775da757a..61d4f5c44 100644
--- a/src/components/Discover/DiscoverWatchlist/index.tsx
+++ b/src/components/Discover/DiscoverWatchlist/index.tsx
@@ -30,6 +30,7 @@ const DiscoverWatchlist = () => {
titles,
fetchMore,
error,
+ mutate,
} = useDiscover(
`/api/v1/${
router.pathname.startsWith('/profile')
@@ -76,6 +77,7 @@ const DiscoverWatchlist = () => {
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
+ mutateParent={mutate}
/>
>
);
diff --git a/src/components/ExternalLinkBlock/index.tsx b/src/components/ExternalLinkBlock/index.tsx
index b27ea6956..9199da7d0 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';
@@ -102,6 +103,16 @@ const ExternalLinkBlock = ({
)}
+ {tmdbId && mediaType === MediaType.MOVIE && (
+
+
+
+ )}
);
};
diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx
index b743edb5d..e246fb286 100644
--- a/src/components/Login/JellyfinLogin.tsx
+++ b/src/components/Login/JellyfinLogin.tsx
@@ -6,7 +6,6 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
-import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import type React from 'react';
@@ -303,6 +302,8 @@ const JellyfinLogin: React.FC = ({
const baseUrl = settings.currentSettings.jellyfinExternalHost
? settings.currentSettings.jellyfinExternalHost
: settings.currentSettings.jellyfinHost;
+ const jellyfinForgotPasswordUrl =
+ settings.currentSettings.jellyfinForgotPasswordUrl;
return (
= ({
{intl.formatMessage(messages.forgotpassword)}
diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx
index fb439a562..42c3bd737 100644
--- a/src/components/Settings/SettingsJellyfin.tsx
+++ b/src/components/Settings/SettingsJellyfin.tsx
@@ -31,9 +31,10 @@ const messages = defineMessages({
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: '{mediaServerName} Settings',
jellyfinSettingsDescription:
- 'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL.',
+ 'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
externalUrl: 'External URL',
internalUrl: 'Internal URL',
+ jellyfinForgotPasswordUrl: 'Forgot Password URL',
validationUrl: 'You must provide a valid URL',
syncing: 'Syncing',
syncJellyfin: 'Sync Libraries',
@@ -95,6 +96,10 @@ const SettingsJellyfin: React.FC = ({
/^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
intl.formatMessage(messages.validationUrl)
),
+ jellyfinForgotPasswordUrl: Yup.string().matches(
+ /^(https?:\/\/)?(?:[\w-]+\.)*[\w-]+(?::\d{2,5})?(?:\/[\w-]+)*(?:\/)?$/gm,
+ intl.formatMessage(messages.validationUrl)
+ ),
});
const activeLibraries =
@@ -348,6 +353,7 @@ const SettingsJellyfin: React.FC = ({
initialValues={{
jellyfinInternalUrl: data?.hostname || '',
jellyfinExternalUrl: data?.externalHostname || '',
+ jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
}}
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
@@ -355,6 +361,7 @@ const SettingsJellyfin: React.FC = ({
await axios.post('/api/v1/settings/jellyfin', {
hostname: values.jellyfinInternalUrl,
externalHostname: values.jellyfinExternalUrl,
+ jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
} as JellyfinSettings);
addToast(
@@ -431,6 +438,30 @@ const SettingsJellyfin: React.FC = ({
)}
+
+
+ {intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
+
+
+
+
+
+ {errors.jellyfinForgotPasswordUrl &&
+ touched.jellyfinForgotPasswordUrl && (
+
+ {errors.jellyfinForgotPasswordUrl}
+
+ )}
+
+
diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx
index 729a40a7b..5267ef4e3 100644
--- a/src/components/Settings/SonarrModal/index.tsx
+++ b/src/components/Settings/SonarrModal/index.tsx
@@ -1,8 +1,10 @@
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
+import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
-import type { SonarrSettings } from '@server/lib/settings';
+import { MediaServerType } from '@server/constants/server';
+import { type SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -109,6 +111,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
+ const settings = useSettings();
const [testResponse, setTestResponse] = useState({
profiles: [],
rootFolders: [],
@@ -255,7 +258,9 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
- enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
+ enableSeasonFolders:
+ sonarr?.enableSeasonFolders ??
+ settings.currentSettings.mediaServerType !== MediaServerType.PLEX,
externalUrl: sonarr?.externalUrl,
syncEnabled: sonarr?.syncEnabled ?? false,
enableSearch: !sonarr?.preventSearch,
@@ -961,11 +966,24 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
>
{intl.formatMessage(messages.seasonfolders)}
-
diff --git a/src/components/TitleCard/TmdbTitleCard.tsx b/src/components/TitleCard/TmdbTitleCard.tsx
index 0764f3aa1..825e52ccf 100644
--- a/src/components/TitleCard/TmdbTitleCard.tsx
+++ b/src/components/TitleCard/TmdbTitleCard.tsx
@@ -12,6 +12,7 @@ export interface TmdbTitleCardProps {
type: 'movie' | 'tv';
canExpand?: boolean;
isAddedToWatchlist?: boolean;
+ mutateParent?: () => void;
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -25,6 +26,7 @@ const TmdbTitleCard = ({
type,
canExpand,
isAddedToWatchlist = false,
+ mutateParent,
}: TmdbTitleCardProps) => {
const { hasPermission } = useUser();
@@ -71,6 +73,7 @@ const TmdbTitleCard = ({
year={title.releaseDate}
mediaType={'movie'}
canExpand={canExpand}
+ mutateParent={mutateParent}
/>
) : (
);
};
diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx
index 410036cca..30a62c16e 100644
--- a/src/components/TitleCard/index.tsx
+++ b/src/components/TitleCard/index.tsx
@@ -38,6 +38,7 @@ interface TitleCardProps {
canExpand?: boolean;
inProgress?: boolean;
isAddedToWatchlist?: number | boolean;
+ mutateParent?: () => void;
}
const messages = defineMessages({
@@ -61,6 +62,7 @@ const TitleCard = ({
isAddedToWatchlist = false,
inProgress = false,
canExpand = false,
+ mutateParent,
}: TitleCardProps) => {
const isTouch = useIsTouch();
const intl = useIntl();
@@ -148,6 +150,9 @@ const TitleCard = ({
} finally {
setIsUpdating(false);
mutate('/api/v1/discover/watchlist');
+ if (mutateParent) {
+ mutateParent();
+ }
setToggleWatchlist((prevState) => !prevState);
}
};
diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts
index f9aff8e29..2a2acd02f 100644
--- a/src/hooks/useDiscover.ts
+++ b/src/hooks/useDiscover.ts
@@ -25,6 +25,7 @@ interface DiscoverResult {
error: unknown;
titles: T[];
firstResultData?: BaseSearchResult & S;
+ mutate?: () => void;
}
const extraEncodes: [RegExp, string][] = [
@@ -54,7 +55,7 @@ const useDiscover = <
{ hideAvailable = true } = {}
): DiscoverResult => {
const settings = useSettings();
- const { data, error, size, setSize, isValidating } = useSWRInfinite<
+ const { data, error, size, setSize, isValidating, mutate } = useSWRInfinite<
BaseSearchResult & S
>(
(pageIndex: number, previousPageData) => {
@@ -119,6 +120,7 @@ const useDiscover = <
error,
titles,
firstResultData: data?.[0],
+ mutate,
};
};
diff --git a/src/i18n/locale/de.json b/src/i18n/locale/de.json
index f29940add..94938f0c9 100644
--- a/src/i18n/locale/de.json
+++ b/src/i18n/locale/de.json
@@ -1235,7 +1235,7 @@
"components.Discover.tmdbmoviestreamingservices": "TMDB Film-Streaming-Dienste",
"components.Discover.tmdbtvstreamingservices": "TMDB TV-Streaming-Dienste",
"i18n.collection": "Sammlung",
- "components.Discover.FilterSlideover.tmdbuservotecount": "TMDB Kullanıcı Oy Sayısı",
+ "components.Discover.FilterSlideover.tmdbuservotecount": "Anzahl an TMDB Benutzerbewertungen",
"components.Settings.RadarrModal.tagRequestsInfo": "Füge automatisch ein Tag hinzu mit der ID und dem Namen des anfordernden Nutzers",
"components.MovieDetails.imdbuserscore": "IMDB Nutzer Bewertung",
"components.Settings.SonarrModal.tagRequests": "Tag Anforderungen",
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 906892b04..71126cc2d 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -938,7 +938,7 @@
"components.Settings.internalUrl": "Internal URL",
"components.Settings.is4k": "4K",
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
- "components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL.",
+ "components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.",
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
"components.Settings.jellyfinlibraries": "{mediaServerName} Libraries",
diff --git a/tailwind.config.js b/tailwind.config.js
index e94cb96c5..b8b70fd54 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -3,7 +3,6 @@ const defaultTheme = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */
module.exports = {
- important: true,
mode: 'jit',
content: [
'./node_modules/react-tailwindcss-datepicker-sct/dist/index.esm.js',