Compare commits

...

16 Commits

Author SHA1 Message Date
fallenbagel
830ae90d81 chore: update @types/node to v22 2025-01-07 03:24:52 +08:00
fallenbagel
1b28043f56 chore: update nodejs version to 22 2025-01-07 02:46:03 +08:00
fallenbagel
51126ac1dc build: update nodejs version to 22 2025-01-07 02:11:17 +08:00
fallenbagel
4242754d61 chore: increase the required node version 2025-01-07 01:57:07 +08:00
fallenbagel
d210d43361 chore: update nodejs to 22 in an attempt to fix undici errors
This is an attempt to fix the undici errors introduced after the switch
from axios to native fetch. The decision was made as it native fetch on
node 20 seems to be "experimental" and
> since native fetch is no longer experimental since Node 21
2025-01-07 01:49:17 +08:00
Fallenbagel
f84d752bca docs: add in missing part in windows docker 2025-01-05 23:45:58 +08:00
Fallenbagel
0b331ca579 fix(setup): fix continue button disabled on refresh in setup 3 (#1211)
This commit resolves an issue where the continue button in setup step 3 remained disabled after a
page refresh even when libraries are toggled. This was happening because
`mediaServerSettingsComplete` state was reset on refresh and not correctly re-initialized.
2025-01-03 12:22:16 +01:00
Fallenbagel
656cd91c9c fix: optimize media status update to avoid lifecycle hook triggers (#1218)
This change optimises the media updates to avoid unneccessary lifecycle hook executions which
results in potential recursion for POSTGRESQL compatibility. This should prevent an issue where
after a TV request, the tv request would get sent to sonarr and notification for it would get sent
over and over and over again
2025-01-03 12:14:39 +01:00
Fallenbagel
81d7473c05 docs: make it clear 2025-01-03 01:07:28 +08:00
Gauthier
f718cec23f fix(externalapi): clear cache after a request is made (#1217)
This PR clears the Radarr/Sonarr cache after a request has been made, because the media status on
Radarr/Sonarr will no longer be good. It also resolves a bug that prevented the media from being
deleted after a request had been sent to Radarr/Sonarr.

fix #1207
2025-01-02 16:44:46 +01:00
Gauthier
ac908026db fix(jellyfinlogin): add proper error message when no admin user exists (#1216)
This PR adds an error message when the database has no admin user and Jellyseerr has already been
set up (i.e. settings.json is filled in), instead of having a generic error message.
2025-01-02 16:03:45 +01:00
Gauthier
d67ec571c5 fix: prevent TypeORM subscribers from calling itself over and over (#1215)
When a series is requested, an event is triggered by TypeORM after the request status has been
updated. The function executed by this event updated the request status to "PROCESSING", even if the
request already had this status. This triggered the same function once again, which repeated the
update, in an endless loop.
2025-01-02 15:46:57 +01:00
Fallenbagel
f3ebf6028b fix(users): correct request count query for PostgreSQL compatibility (#1213)
The request count subquery was causing issues with some PostgreSQL
configurations due to case sensitivity in column aliases. Modified the
query to use an explicit subquery with a properly named alias to ensure
consistent behavior across different database setups.
2025-01-01 19:18:36 +01:00
Fallenbagel
465d42dd60 style(request-list): consistent styling of sort button with the rest (#1212) 2025-01-01 19:17:23 +01:00
Gauthier
2f0e493257 fix(ui): resolve streaming region dropdown overlap (#1210)
fix #1206
2024-12-31 17:08:14 +01:00
Gauthier
ebe7d11a53 fix: correct typos for the special episodes setting (#1209)
Some typos were introduced by #1193, enableSpecialEpisodes and partialRequestsEnabled were mixed up.

fix #1208
2024-12-31 14:15:10 +01:00
26 changed files with 1678 additions and 441 deletions

View File

@@ -13,7 +13,7 @@ jobs:
name: Lint & Test Build name: Lint & Test Build
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: node:20-alpine container: node:22-alpine
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -17,7 +17,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Pnpm Setup - name: Pnpm Setup
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx

View File

@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- HTML/Typescript/Javascript editor - HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install. - [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 20.x) - [NodeJS](https://nodejs.org/en/download/) (Node 22.x)
- [Pnpm](https://pnpm.io/cli/install) - [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS BUILD_IMAGE FROM node:22-alpine AS BUILD_IMAGE
WORKDIR /app WORKDIR /app
@@ -36,7 +36,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:20-alpine FROM node:22-alpine
# Metadata for Github Package Registry # Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr" LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine FROM node:22-alpine
COPY . /app COPY . /app
WORKDIR /app WORKDIR /app

View File

@@ -15,7 +15,7 @@
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **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! It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring additional support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
## Current Features ## Current Features

View File

@@ -12,7 +12,7 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem'; import TabItem from '@theme/TabItem';
### Prerequisites ### Prerequisites
- [Node.js 20.x](https://nodejs.org/en/download/) - [Node.js 22.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation) - [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)

View File

@@ -145,6 +145,16 @@ Then, create and start the Jellyseerr container:
<TabItem value="docker-cli" label="Docker CLI"> <TabItem value="docker-cli" label="Docker CLI">
```bash ```bash
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
``` ```
</TabItem> </TabItem>
@@ -167,6 +177,16 @@ services:
volumes: volumes:
jellyseerr-data: jellyseerr-data:
external: true external: true
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
``` ```
</TabItem> </TabItem>
</Tabs> </Tabs>
@@ -185,3 +205,6 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.) **If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored. Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
:::

View File

@@ -123,7 +123,7 @@
"@types/express-session": "1.17.6", "@types/express-session": "1.17.6",
"@types/lodash": "4.14.191", "@types/lodash": "4.14.191",
"@types/mime": "3", "@types/mime": "3",
"@types/node": "20.14.8", "@types/node": "22.10.5",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
@@ -169,7 +169,7 @@
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "engines": {
"node": "^20.0.0", "node": "^22.0.0",
"pnpm": "^9.0.0" "pnpm": "^9.0.0"
}, },
"overrides": { "overrides": {

1817
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -293,6 +293,14 @@ class ExternalAPI {
return data; return data;
} }
protected removeCache(endpoint: string, params?: Record<string, string>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
this.cache?.del(cacheKey);
}
private formatUrl( private formatUrl(
endpoint: string, endpoint: string,
params?: Record<string, string>, params?: Record<string, string>,

View File

@@ -230,6 +230,23 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
throw new Error(`[Radarr] Failed to remove movie: ${e.message}`); throw new Error(`[Radarr] Failed to remove movie: ${e.message}`);
} }
}; };
public clearCache = ({
tmdbId,
externalId,
}: {
tmdbId?: number | null;
externalId?: number | null;
}) => {
if (tmdbId) {
this.removeCache('/movie/lookup', {
term: `tmdb:${tmdbId}`,
});
}
if (externalId) {
this.removeCache(`/movie/${externalId}`);
}
};
} }
export default RadarrAPI; export default RadarrAPI;

View File

@@ -353,6 +353,30 @@ class SonarrAPI extends ServarrBase<{
throw new Error(`[Radarr] Failed to remove serie: ${e.message}`); throw new Error(`[Radarr] Failed to remove serie: ${e.message}`);
} }
}; };
public clearCache = ({
tvdbId,
externalId,
title,
}: {
tvdbId?: number | null;
externalId?: number | null;
title?: string | null;
}) => {
if (tvdbId) {
this.removeCache('/series/lookup', {
term: `tvdb:${tvdbId}`,
});
}
if (externalId) {
this.removeCache(`/series/${externalId}`);
}
if (title) {
this.removeCache('/series/lookup', {
term: title,
});
}
};
} }
export default SonarrAPI; export default SonarrAPI;

View File

@@ -4,6 +4,7 @@ export enum ApiErrorCode {
InvalidAuthToken = 'INVALID_AUTH_TOKEN', InvalidAuthToken = 'INVALID_AUTH_TOKEN',
InvalidEmail = 'INVALID_EMAIL', InvalidEmail = 'INVALID_EMAIL',
NotAdmin = 'NOT_ADMIN', NotAdmin = 'NOT_ADMIN',
NoAdminUser = 'NO_ADMIN_USER',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN', Unknown = 'UNKNOWN',

View File

@@ -386,14 +386,14 @@ export class MediaRequest {
const tmdbMediaShow = tmdbMedia as Awaited< const tmdbMediaShow = tmdbMedia as Awaited<
ReturnType<typeof tmdb.getTvShow> ReturnType<typeof tmdb.getTvShow>
>; >;
const requestedSeasons = let requestedSeasons =
requestBody.seasons === 'all' requestBody.seasons === 'all'
? settings.main.enableSpecialEpisodes
? tmdbMediaShow.seasons.map((season) => season.season_number) ? tmdbMediaShow.seasons.map((season) => season.season_number)
: tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]); : (requestBody.seasons as number[]);
if (!settings.main.enableSpecialEpisodes) {
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
}
let existingSeasons: number[] = []; let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were // We need to check existing requests on this title to make sure we don't double up on seasons that were
@@ -719,10 +719,15 @@ export class MediaRequest {
// Do not update the status if the item is already partially available or available // Do not update the status if the item is already partially available or available
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE && media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !== media[this.is4k ? 'status4k' : 'status'] !==
MediaStatus.PARTIALLY_AVAILABLE MediaStatus.PARTIALLY_AVAILABLE &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
) { ) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; const statusField = this.is4k ? 'status4k' : 'status';
mediaRepository.save(media);
await mediaRepository.update(
{ id: this.media.id },
{ [statusField]: MediaStatus.PROCESSING }
);
} }
if ( if (
@@ -1005,6 +1010,14 @@ export class MediaRequest {
); );
this.sendNotification(media, Notification.MEDIA_FAILED); this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
radarr.clearCache({
tmdbId: movie.id,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
});
}); });
logger.info('Sent request to Radarr', { logger.info('Sent request to Radarr', {
label: 'Media Request', label: 'Media Request',
@@ -1262,19 +1275,23 @@ export class MediaRequest {
throw new Error('Media data not found'); throw new Error('Media data not found');
} }
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = const updateFields = {
sonarrSeries.id; [this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = sonarrSeries.id,
sonarrSeries.titleSlug; [this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; sonarrSeries.titleSlug,
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
};
await mediaRepository.save(media); await mediaRepository.update({ id: this.media.id }, updateFields);
}) })
.catch(async () => { .catch(async () => {
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED; await requestRepository.update(
await requestRepository.save(this); { id: this.id },
{ status: MediaRequestStatus.FAILED }
);
logger.warn( logger.warn(
'Something went wrong sending series request to Sonarr, marking status as FAILED', 'Something went wrong sending series request to Sonarr, marking status as FAILED',
@@ -1287,6 +1304,15 @@ export class MediaRequest {
); );
this.sendNotification(media, Notification.MEDIA_FAILED); this.sendNotification(media, Notification.MEDIA_FAILED);
})
.finally(() => {
sonarr.clearCache({
tvdbId,
externalId: this.is4k
? media.externalServiceId4k
: media.externalServiceId,
title: series.name,
});
}); });
logger.info('Sent request to Sonarr', { logger.info('Sent request to Sonarr', {
label: 'Media Request', label: 'Media Request',

View File

@@ -107,7 +107,7 @@ class SonarrScanner
const filteredSeasons = sonarrSeries.seasons.filter( const filteredSeasons = sonarrSeries.seasons.filter(
(sn) => (sn) =>
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) && tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) &&
(!settings.main.partialRequestsEnabled ? sn.seasonNumber !== 0 : true) (!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true)
); );
for (const season of filteredSeasons) { for (const season of filteredSeasons) {

View File

@@ -313,7 +313,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
body.serverType !== MediaServerType.JELLYFIN && body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY body.serverType !== MediaServerType.EMBY
) { ) {
throw new Error('select_server_type'); throw new ApiError(500, ApiErrorCode.NoAdminUser);
} }
settings.main.mediaServerType = body.serverType; settings.main.mediaServerType = body.serverType;
@@ -533,6 +533,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
message: e.errorCode, message: e.errorCode,
}); });
case ApiErrorCode.NoAdminUser:
logger.warn(
'Failed login attempt from user without admin permissions and no admin user exists',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default: default:
logger.error(e.message, { label: 'Auth' }); logger.error(e.message, { label: 'Auth' });
return next({ return next({

View File

@@ -70,11 +70,11 @@ router.get('/', async (req, res, next) => {
query = query query = query
.addSelect((subQuery) => { .addSelect((subQuery) => {
return subQuery return subQuery
.select('COUNT(request.id)', 'requestCount') .select('COUNT(request.id)', 'request_count')
.from(MediaRequest, 'request') .from(MediaRequest, 'request')
.where('request.requestedBy.id = user.id'); .where('request.requestedBy.id = user.id');
}, 'requestCount') }, 'request_count')
.orderBy('requestCount', 'DESC'); .orderBy('request_count', 'DESC');
break; break;
default: default:
query = query.orderBy('user.id', 'ASC'); query = query.orderBy('user.id', 'ASC');

View File

@@ -34,6 +34,7 @@ const messages = defineMessages('components.Login', {
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash', validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.', adminerror: 'You must use an admin account to sign in.',
noadminerror: 'No admin user found on the server.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.', invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…', signingin: 'Signing in…',
@@ -157,6 +158,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
case ApiErrorCode.NotAdmin: case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror; errorMessage = messages.adminerror;
break; break;
case ApiErrorCode.NoAdminUser:
errorMessage = messages.noadminerror;
break;
default: default:
errorMessage = messages.loginerror; errorMessage = messages.loginerror;
break; break;
@@ -388,14 +392,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
email: values.username, email: values.username,
}), }),
}); });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error(res.statusText, { cause: res });
} catch (e) { } catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null;
switch (errorData?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;
case ApiErrorCode.InvalidCredentials:
errorMessage = messages.credentialerror;
break;
case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror;
break;
case ApiErrorCode.NoAdminUser:
errorMessage = messages.noadminerror;
break;
default:
errorMessage = messages.loginerror;
break;
}
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(errorMessage, mediaServerFormatValues),
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{ {
autoDismiss: true, autoDismiss: true,
appearance: 'error', appearance: 'error',

View File

@@ -220,8 +220,8 @@ const RequestList = () => {
</select> </select>
<Tooltip content={intl.formatMessage(messages.sortDirection)}> <Tooltip content={intl.formatMessage(messages.sortDirection)}>
<Button <Button
buttonType="ghost" buttonType="default"
className="z-40 mr-2 rounded-l-none" className="z-40 mr-2 rounded-l-none border !border-gray-500 !bg-gray-800 !px-3 !text-gray-500 hover:!bg-gray-400 hover:!text-white"
buttonSize="md" buttonSize="md"
onClick={() => onClick={() =>
setCurrentSortDirection( setCurrentSortDirection(
@@ -230,9 +230,9 @@ const RequestList = () => {
} }
> >
{currentSortDirection === 'asc' ? ( {currentSortDirection === 'asc' ? (
<ArrowUpIcon className="h-3" /> <ArrowUpIcon className="h-6 w-6" />
) : ( ) : (
<ArrowDownIcon className="h-3" /> <ArrowDownIcon className="h-6 w-6" />
)} )}
</Button> </Button>
</Tooltip> </Tooltip>

View File

@@ -256,8 +256,8 @@ const TvRequestModal = ({
let allSeasons = (data?.seasons ?? []).filter( let allSeasons = (data?.seasons ?? []).filter(
(season) => season.episodeCount !== 0 (season) => season.episodeCount !== 0
); );
if (!settings.currentSettings.partialRequestsEnabled) { if (!settings.currentSettings.enableSpecialEpisodes) {
allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0); allSeasons = allSeasons.filter((season) => season.seasonNumber > 0);
} }
return allSeasons.map((season) => season.seasonNumber); return allSeasons.map((season) => season.seasonNumber);
}; };

View File

@@ -433,7 +433,7 @@ const SettingsMain = () => {
</span> </span>
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field relative z-30">
<LanguageSelector <LanguageSelector
setFieldValue={setFieldValue} setFieldValue={setFieldValue}
value={values.originalLanguage} value={values.originalLanguage}
@@ -449,7 +449,7 @@ const SettingsMain = () => {
</span> </span>
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <div className="form-input-field relative z-20">
<RegionSelector <RegionSelector
value={values.streamingRegion} value={values.streamingRegion}
name="streamingRegion" name="streamingRegion"

View File

@@ -14,10 +14,12 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages'; import defineMessages from '@app/utils/defineMessages';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { Library } from '@server/lib/settings';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
import SetupLogin from './SetupLogin'; import SetupLogin from './SetupLogin';
@@ -35,6 +37,8 @@ const messages = defineMessages('components.Setup', {
signin: 'Sign In', signin: 'Sign In',
configuremediaserver: 'Configure Media Server', configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services', configureservices: 'Configure Services',
librarieserror:
'Validation failed. Please toggle the libraries again to continue.',
}); });
const Setup = () => { const Setup = () => {
@@ -49,6 +53,7 @@ const Setup = () => {
const router = useRouter(); const router = useRouter();
const { locale } = useLocale(); const { locale } = useLocale();
const settings = useSettings(); const settings = useSettings();
const toasts = useToasts();
const finishSetup = async () => { const finishSetup = async () => {
setIsUpdating(true); setIsUpdating(true);
@@ -87,6 +92,7 @@ const Setup = () => {
if (settings.currentSettings.initialized) { if (settings.currentSettings.initialized) {
router.push('/'); router.push('/');
} }
if ( if (
settings.currentSettings.mediaServerType !== settings.currentSettings.mediaServerType !==
MediaServerType.NOT_CONFIGURED MediaServerType.NOT_CONFIGURED
@@ -94,12 +100,62 @@ const Setup = () => {
setCurrentStep(3); setCurrentStep(3);
setMediaServerType(settings.currentSettings.mediaServerType); setMediaServerType(settings.currentSettings.mediaServerType);
} }
if (currentStep === 3) {
const validateLibraries = async () => {
try {
const endpoint =
settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN || MediaServerType.EMBY
? '/api/v1/settings/jellyfin'
: '/api/v1/settings/plex';
const res = await fetch(endpoint);
if (!res.ok) throw new Error('Fetch failed');
const data = await res.json();
const hasEnabledLibraries = data?.libraries?.some(
(library: Library) => library.enabled
);
setMediaServerSettingsComplete(hasEnabledLibraries);
if (hasEnabledLibraries) {
localStorage.setItem('mediaServerSettingsComplete', 'true');
} else {
localStorage.removeItem('mediaServerSettingsComplete');
}
} catch (e) {
toasts.addToast(intl.formatMessage(messages.librarieserror), {
autoDismiss: true,
appearance: 'error',
});
setMediaServerSettingsComplete(false);
localStorage.removeItem('mediaServerSettingsComplete');
}
};
validateLibraries();
} else {
// Initialize from localStorage on mount
const storedState =
localStorage.getItem('mediaServerSettingsComplete') === 'true';
setMediaServerSettingsComplete(storedState);
}
}, [ }, [
settings.currentSettings.mediaServerType, settings.currentSettings.mediaServerType,
settings.currentSettings.initialized, settings.currentSettings.initialized,
router, router,
currentStep,
toasts,
intl,
]); ]);
const handleComplete = () => {
setMediaServerSettingsComplete(true);
localStorage.setItem('mediaServerSettingsComplete', 'true');
};
if (settings.currentSettings.initialized) return <></>; if (settings.currentSettings.initialized) return <></>;
return ( return (
@@ -225,14 +281,9 @@ const Setup = () => {
{currentStep === 3 && ( {currentStep === 3 && (
<div className="p-2"> <div className="p-2">
{mediaServerType === MediaServerType.PLEX ? ( {mediaServerType === MediaServerType.PLEX ? (
<SettingsPlex <SettingsPlex onComplete={handleComplete} />
onComplete={() => setMediaServerSettingsComplete(true)}
/>
) : ( ) : (
<SettingsJellyfin <SettingsJellyfin isSetupSettings onComplete={handleComplete} />
isSetupSettings
onComplete={() => setMediaServerSettingsComplete(true)}
/>
)} )}
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -303,7 +303,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const showHasSpecials = data.seasons.some( const showHasSpecials = data.seasons.some(
(season) => (season) =>
season.seasonNumber === 0 && season.seasonNumber === 0 &&
settings.currentSettings.partialRequestsEnabled settings.currentSettings.enableSpecialEpisodes
); );
const isComplete = const isComplete =

View File

@@ -1137,6 +1137,7 @@
"components.Setup.continue": "Continue", "components.Setup.continue": "Continue",
"components.Setup.finish": "Finish Setup", "components.Setup.finish": "Finish Setup",
"components.Setup.finishing": "Finishing…", "components.Setup.finishing": "Finishing…",
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
"components.Setup.servertype": "Choose Server Type", "components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup", "components.Setup.setup": "Setup",
"components.Setup.signin": "Sign In", "components.Setup.signin": "Sign In",