mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-28 20:49:05 -05:00
Compare commits
17 Commits
v2.2.3
...
preview-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e43901a6 | ||
|
|
425f0c854e | ||
|
|
b8dbfaaed0 | ||
|
|
24c6208a3c | ||
|
|
d71ee58302 | ||
|
|
1755877e66 | ||
|
|
f84d752bca | ||
|
|
0b331ca579 | ||
|
|
656cd91c9c | ||
|
|
81d7473c05 | ||
|
|
f718cec23f | ||
|
|
ac908026db | ||
|
|
d67ec571c5 | ||
|
|
f3ebf6028b | ||
|
|
465d42dd60 | ||
|
|
2f0e493257 | ||
|
|
ebe7d11a53 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
container: node:20-alpine
|
||||
container: node:22-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
2
.github/workflows/cypress.yml
vendored
2
.github/workflows/cypress.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
|
||||
- HTML/Typescript/Javascript editor
|
||||
- [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)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS BUILD_IMAGE
|
||||
FROM node:22-alpine AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN touch config/DOCKER
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
# Metadata for Github Package Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -56,6 +56,6 @@ If you don't have or don't want to use docker, you can build the working pgloade
|
||||
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
|
||||
:::
|
||||
```bash
|
||||
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro -v pgloader/pgloader.load:/pgloader.load ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
```
|
||||
5. Start Jellyseerr
|
||||
|
||||
@@ -12,7 +12,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
### 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)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
|
||||
@@ -145,6 +145,16 @@ Then, create and start the Jellyseerr container:
|
||||
<TabItem value="docker-cli" label="Docker CLI">
|
||||
```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
|
||||
```
|
||||
|
||||
#### 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>
|
||||
|
||||
@@ -167,6 +177,16 @@ services:
|
||||
volumes:
|
||||
jellyseerr-data:
|
||||
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>
|
||||
</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.)
|
||||
|
||||
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.
|
||||
:::
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
"node": ">=22.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/mime": "3",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "22.10.5",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -169,7 +169,7 @@
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0",
|
||||
"node": "^22.0.0",
|
||||
"pnpm": "^9.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
1817
pnpm-lock.yaml
generated
1817
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ interface ExternalAPIOptions {
|
||||
rateLimit?: RateLimitOptions;
|
||||
}
|
||||
|
||||
interface CustomRequestConfig extends RequestInit {
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class ExternalAPI {
|
||||
protected fetch: typeof fetch;
|
||||
protected params: Record<string, string>;
|
||||
@@ -67,12 +71,11 @@ class ExternalAPI {
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
ttl?: number,
|
||||
config?: RequestInit
|
||||
config?: CustomRequestConfig
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
...this.params,
|
||||
...params,
|
||||
});
|
||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params, headers);
|
||||
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
if (cachedItem) {
|
||||
return cachedItem;
|
||||
@@ -293,6 +296,14 @@ class ExternalAPI {
|
||||
return data;
|
||||
}
|
||||
|
||||
protected removeCache(endpoint: string, params?: Record<string, string>) {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
...this.params,
|
||||
...params,
|
||||
});
|
||||
this.cache?.del(cacheKey);
|
||||
}
|
||||
|
||||
private formatUrl(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>,
|
||||
@@ -317,13 +328,15 @@ class ExternalAPI {
|
||||
|
||||
private serializeCacheKey(
|
||||
endpoint: string,
|
||||
params?: Record<string, unknown>
|
||||
params?: Record<string, unknown>,
|
||||
headers?: Record<string, unknown>
|
||||
) {
|
||||
if (!params) {
|
||||
return `${this.baseUrl}${endpoint}`;
|
||||
const key = `${this.baseUrl}${endpoint}`;
|
||||
if (!params && !headers) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
||||
return `${key}${JSON.stringify({ params, headers })}`;
|
||||
}
|
||||
|
||||
private async getDataFromResponse(response: Response) {
|
||||
|
||||
@@ -230,6 +230,23 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
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;
|
||||
|
||||
@@ -353,6 +353,30 @@ class SonarrAPI extends ServarrBase<{
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum ApiErrorCode {
|
||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||
InvalidEmail = 'INVALID_EMAIL',
|
||||
NotAdmin = 'NOT_ADMIN',
|
||||
NoAdminUser = 'NO_ADMIN_USER',
|
||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
Unknown = 'UNKNOWN',
|
||||
|
||||
@@ -386,14 +386,14 @@ export class MediaRequest {
|
||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||
ReturnType<typeof tmdb.getTvShow>
|
||||
>;
|
||||
const requestedSeasons =
|
||||
let requestedSeasons =
|
||||
requestBody.seasons === 'all'
|
||||
? settings.main.enableSpecialEpisodes
|
||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
||||
: tmdbMediaShow.seasons
|
||||
.map((season) => season.season_number)
|
||||
.filter((sn) => sn > 0)
|
||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
||||
: (requestBody.seasons as number[]);
|
||||
if (!settings.main.enableSpecialEpisodes) {
|
||||
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
||||
}
|
||||
|
||||
let existingSeasons: number[] = [];
|
||||
|
||||
// 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
|
||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||
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;
|
||||
mediaRepository.save(media);
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
|
||||
await mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.PROCESSING }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1005,6 +1010,14 @@ export class MediaRequest {
|
||||
);
|
||||
|
||||
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', {
|
||||
label: 'Media Request',
|
||||
@@ -1262,19 +1275,23 @@ export class MediaRequest {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
sonarrSeries.id;
|
||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
sonarrSeries.titleSlug;
|
||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
||||
const updateFields = {
|
||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||
sonarrSeries.id,
|
||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||
sonarrSeries.titleSlug,
|
||||
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
|
||||
};
|
||||
|
||||
await mediaRepository.save(media);
|
||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
await requestRepository.save(this);
|
||||
await requestRepository.update(
|
||||
{ id: this.id },
|
||||
{ status: MediaRequestStatus.FAILED }
|
||||
);
|
||||
|
||||
logger.warn(
|
||||
'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);
|
||||
})
|
||||
.finally(() => {
|
||||
sonarr.clearCache({
|
||||
tvdbId,
|
||||
externalId: this.is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId,
|
||||
title: series.name,
|
||||
});
|
||||
});
|
||||
logger.info('Sent request to Sonarr', {
|
||||
label: 'Media Request',
|
||||
|
||||
@@ -107,7 +107,7 @@ class SonarrScanner
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
(sn) =>
|
||||
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) {
|
||||
|
||||
@@ -313,7 +313,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
body.serverType !== MediaServerType.JELLYFIN &&
|
||||
body.serverType !== MediaServerType.EMBY
|
||||
) {
|
||||
throw new Error('select_server_type');
|
||||
throw new ApiError(500, ApiErrorCode.NoAdminUser);
|
||||
}
|
||||
settings.main.mediaServerType = body.serverType;
|
||||
|
||||
@@ -533,6 +533,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
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:
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
return next({
|
||||
|
||||
@@ -70,11 +70,11 @@ router.get('/', async (req, res, next) => {
|
||||
query = query
|
||||
.addSelect((subQuery) => {
|
||||
return subQuery
|
||||
.select('COUNT(request.id)', 'requestCount')
|
||||
.select('COUNT(request.id)', 'request_count')
|
||||
.from(MediaRequest, 'request')
|
||||
.where('request.requestedBy.id = user.id');
|
||||
}, 'requestCount')
|
||||
.orderBy('requestCount', 'DESC');
|
||||
}, 'request_count')
|
||||
.orderBy('request_count', 'DESC');
|
||||
break;
|
||||
default:
|
||||
query = query.orderBy('user.id', 'ASC');
|
||||
|
||||
@@ -34,6 +34,7 @@ const messages = defineMessages('components.Login', {
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
loginerror: 'Something went wrong while trying 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.',
|
||||
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||
signingin: 'Signing in…',
|
||||
@@ -157,6 +158,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
case ApiErrorCode.NotAdmin:
|
||||
errorMessage = messages.adminerror;
|
||||
break;
|
||||
case ApiErrorCode.NoAdminUser:
|
||||
errorMessage = messages.noadminerror;
|
||||
break;
|
||||
default:
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
@@ -388,14 +392,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
email: values.username,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
} 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(
|
||||
intl.formatMessage(
|
||||
e.message == 'Request failed with status code 401'
|
||||
? messages.credentialerror
|
||||
: messages.loginerror
|
||||
),
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
|
||||
@@ -1063,14 +1063,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
</div>
|
||||
)}
|
||||
{!!streamingProviders.length && (
|
||||
<div className="media-fact">
|
||||
<div className="media-fact flex-col gap-1">
|
||||
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||
<span className="media-fact-value">
|
||||
<span className="media-fact-value flex flex-row flex-wrap gap-5">
|
||||
{streamingProviders.map((p) => {
|
||||
return (
|
||||
<span className="block" key={`provider-${p.id}`}>
|
||||
{p.name}
|
||||
</span>
|
||||
<Tooltip content={p.name}>
|
||||
<span
|
||||
className="opacity-50 transition duration-300 hover:opacity-100"
|
||||
key={`provider-${p.id}`}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
|
||||
alt={p.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -220,8 +220,8 @@ const RequestList = () => {
|
||||
</select>
|
||||
<Tooltip content={intl.formatMessage(messages.sortDirection)}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
className="z-40 mr-2 rounded-l-none"
|
||||
buttonType="default"
|
||||
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"
|
||||
onClick={() =>
|
||||
setCurrentSortDirection(
|
||||
@@ -230,9 +230,9 @@ const RequestList = () => {
|
||||
}
|
||||
>
|
||||
{currentSortDirection === 'asc' ? (
|
||||
<ArrowUpIcon className="h-3" />
|
||||
<ArrowUpIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<ArrowDownIcon className="h-3" />
|
||||
<ArrowDownIcon className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -256,8 +256,8 @@ const TvRequestModal = ({
|
||||
let allSeasons = (data?.seasons ?? []).filter(
|
||||
(season) => season.episodeCount !== 0
|
||||
);
|
||||
if (!settings.currentSettings.partialRequestsEnabled) {
|
||||
allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0);
|
||||
if (!settings.currentSettings.enableSpecialEpisodes) {
|
||||
allSeasons = allSeasons.filter((season) => season.seasonNumber > 0);
|
||||
}
|
||||
return allSeasons.map((season) => season.seasonNumber);
|
||||
};
|
||||
|
||||
@@ -433,7 +433,7 @@ const SettingsMain = () => {
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<div className="form-input-field relative z-30">
|
||||
<LanguageSelector
|
||||
setFieldValue={setFieldValue}
|
||||
value={values.originalLanguage}
|
||||
@@ -449,7 +449,7 @@ const SettingsMain = () => {
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<div className="form-input-field relative z-20">
|
||||
<RegionSelector
|
||||
value={values.streamingRegion}
|
||||
name="streamingRegion"
|
||||
|
||||
@@ -350,6 +350,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
setIsSyncing(false);
|
||||
revalidate();
|
||||
};
|
||||
@@ -435,10 +439,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
|
||||
@@ -14,10 +14,12 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import SetupLogin from './SetupLogin';
|
||||
|
||||
@@ -35,6 +37,8 @@ const messages = defineMessages('components.Setup', {
|
||||
signin: 'Sign In',
|
||||
configuremediaserver: 'Configure Media Server',
|
||||
configureservices: 'Configure Services',
|
||||
librarieserror:
|
||||
'Validation failed. Please toggle the libraries again to continue.',
|
||||
});
|
||||
|
||||
const Setup = () => {
|
||||
@@ -49,6 +53,7 @@ const Setup = () => {
|
||||
const router = useRouter();
|
||||
const { locale } = useLocale();
|
||||
const settings = useSettings();
|
||||
const toasts = useToasts();
|
||||
|
||||
const finishSetup = async () => {
|
||||
setIsUpdating(true);
|
||||
@@ -87,19 +92,65 @@ const Setup = () => {
|
||||
if (settings.currentSettings.initialized) {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
if (
|
||||
settings.currentSettings.mediaServerType !==
|
||||
MediaServerType.NOT_CONFIGURED
|
||||
) {
|
||||
setCurrentStep(3);
|
||||
setMediaServerType(settings.currentSettings.mediaServerType);
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep === 3) {
|
||||
validateLibraries();
|
||||
}
|
||||
}, [
|
||||
settings.currentSettings.mediaServerType,
|
||||
settings.currentSettings.initialized,
|
||||
router,
|
||||
toasts,
|
||||
intl,
|
||||
currentStep,
|
||||
mediaServerType,
|
||||
]);
|
||||
|
||||
const validateLibraries = async () => {
|
||||
try {
|
||||
const endpointMap: Record<MediaServerType, string> = {
|
||||
[MediaServerType.JELLYFIN]: '/api/v1/settings/jellyfin',
|
||||
[MediaServerType.EMBY]: '/api/v1/settings/jellyfin',
|
||||
[MediaServerType.PLEX]: '/api/v1/settings/plex',
|
||||
[MediaServerType.NOT_CONFIGURED]: '',
|
||||
};
|
||||
|
||||
const endpoint = endpointMap[mediaServerType];
|
||||
if (!endpoint) return;
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
toasts.addToast(intl.formatMessage(messages.librarieserror), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
|
||||
setMediaServerSettingsComplete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
validateLibraries();
|
||||
};
|
||||
|
||||
if (settings.currentSettings.initialized) return <></>;
|
||||
|
||||
return (
|
||||
@@ -225,14 +276,9 @@ const Setup = () => {
|
||||
{currentStep === 3 && (
|
||||
<div className="p-2">
|
||||
{mediaServerType === MediaServerType.PLEX ? (
|
||||
<SettingsPlex
|
||||
onComplete={() => setMediaServerSettingsComplete(true)}
|
||||
/>
|
||||
<SettingsPlex onComplete={handleComplete} />
|
||||
) : (
|
||||
<SettingsJellyfin
|
||||
isSetupSettings
|
||||
onComplete={() => setMediaServerSettingsComplete(true)}
|
||||
/>
|
||||
<SettingsJellyfin isSetupSettings onComplete={handleComplete} />
|
||||
)}
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -303,7 +303,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
const showHasSpecials = data.seasons.some(
|
||||
(season) =>
|
||||
season.seasonNumber === 0 &&
|
||||
settings.currentSettings.partialRequestsEnabled
|
||||
settings.currentSettings.enableSpecialEpisodes
|
||||
);
|
||||
|
||||
const isComplete =
|
||||
@@ -1243,14 +1243,26 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
</div>
|
||||
)}
|
||||
{!!streamingProviders.length && (
|
||||
<div className="media-fact">
|
||||
<div className="media-fact flex-col gap-1">
|
||||
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||
<span className="media-fact-value">
|
||||
<span className="media-fact-value flex flex-row flex-wrap gap-5">
|
||||
{streamingProviders.map((p) => {
|
||||
return (
|
||||
<span className="block" key={`provider-${p.id}`}>
|
||||
{p.name}
|
||||
</span>
|
||||
<Tooltip content={p.name}>
|
||||
<span
|
||||
className="opacity-50 transition duration-300 hover:opacity-100"
|
||||
key={`provider-${p.id}`}
|
||||
>
|
||||
<CachedImage
|
||||
type="tmdb"
|
||||
src={'https://image.tmdb.org/t/p/w45/' + p.logoPath}
|
||||
alt={p.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -1137,6 +1137,7 @@
|
||||
"components.Setup.continue": "Continue",
|
||||
"components.Setup.finish": "Finish Setup",
|
||||
"components.Setup.finishing": "Finishing…",
|
||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
|
||||
Reference in New Issue
Block a user