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). -Translation status +Translation status ## Attribution diff --git a/README.md b/README.md index 0dc45fe73..f07ac5e3e 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,28 @@ Jellyseerr

-Discord +Jellyseerr Release +Jellyseerr CI +

+

+Discord Docker pulls +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 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
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/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 (

= ({ 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 = ({ )}
+
+ +
+
+ +
+ {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',