Compare commits
18 Commits
v2.2.2
...
preview-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830ae90d81 | ||
|
|
1b28043f56 | ||
|
|
51126ac1dc | ||
|
|
4242754d61 | ||
|
|
d210d43361 | ||
|
|
f84d752bca | ||
|
|
0b331ca579 | ||
|
|
656cd91c9c | ||
|
|
81d7473c05 | ||
|
|
f718cec23f | ||
|
|
ac908026db | ||
|
|
d67ec571c5 | ||
|
|
f3ebf6028b | ||
|
|
465d42dd60 | ||
|
|
2f0e493257 | ||
|
|
ebe7d11a53 | ||
|
|
7e94ad7210 | ||
|
|
814a7357c0 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/cypress.yml
vendored
2
.github/workflows/cypress.yml
vendored
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
1817
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -207,28 +208,50 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply overrides if the user is not an admin or has the "auto approve" permission
|
// Apply overrides if the user is not an admin or has the "advanced request" permission
|
||||||
const useOverrides = !user.hasPermission(
|
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
||||||
[
|
type: 'or',
|
||||||
requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
});
|
||||||
Permission.MANAGE_REQUESTS,
|
|
||||||
],
|
|
||||||
{ type: 'or' }
|
|
||||||
);
|
|
||||||
|
|
||||||
let rootFolder = requestBody.rootFolder;
|
let rootFolder = requestBody.rootFolder;
|
||||||
let profileId = requestBody.profileId;
|
let profileId = requestBody.profileId;
|
||||||
let tags = requestBody.tags;
|
let tags = requestBody.tags;
|
||||||
|
|
||||||
if (useOverrides) {
|
if (useOverrides) {
|
||||||
|
const defaultRadarrId = requestBody.is4k
|
||||||
|
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
|
||||||
|
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
|
||||||
|
const defaultSonarrId = requestBody.is4k
|
||||||
|
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
|
||||||
|
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
|
||||||
|
|
||||||
const overrideRuleRepository = getRepository(OverrideRule);
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
const overrideRules = await overrideRuleRepository.find({
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
where:
|
where:
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
requestBody.mediaType === MediaType.MOVIE
|
||||||
? { radarrServiceId: requestBody.serverId }
|
? { radarrServiceId: defaultRadarrId }
|
||||||
: { sonarrServiceId: requestBody.serverId },
|
: { sonarrServiceId: defaultSonarrId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const appliedOverrideRules = overrideRules.filter((rule) => {
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
|
const hasAnimeKeyword =
|
||||||
|
'results' in tmdbMedia.keywords &&
|
||||||
|
tmdbMedia.keywords.results.some(
|
||||||
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skip override rules if the media is an anime TV show as anime TV
|
||||||
|
// is handled by default and override rules do not explicitly include
|
||||||
|
// the anime keyword
|
||||||
|
if (
|
||||||
|
requestBody.mediaType === MediaType.TV &&
|
||||||
|
hasAnimeKeyword &&
|
||||||
|
(!rule.keywords ||
|
||||||
|
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rule.users &&
|
rule.users &&
|
||||||
!rule.users
|
!rule.users
|
||||||
@@ -257,31 +280,59 @@ export class MediaRequest {
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
rule.keywords &&
|
||||||
|
!rule.keywords.split(',').some((keywordId) => {
|
||||||
|
let keywordList: TmdbKeyword[] = [];
|
||||||
|
|
||||||
|
if ('keywords' in tmdbMedia.keywords) {
|
||||||
|
keywordList = tmdbMedia.keywords.keywords;
|
||||||
|
} else if ('results' in tmdbMedia.keywords) {
|
||||||
|
keywordList = tmdbMedia.keywords.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywordList
|
||||||
|
.map((keyword: TmdbKeyword) => keyword.id)
|
||||||
|
.includes(Number(keywordId));
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const overrideRootFolder = appliedOverrideRules.find(
|
// hacky way to prioritize rules
|
||||||
(rule) => rule.rootFolder
|
// TODO: make this better
|
||||||
)?.rootFolder;
|
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
|
||||||
if (overrideRootFolder) {
|
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
|
||||||
rootFolder = overrideRootFolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrideProfileId = appliedOverrideRules.find(
|
const aSpecificity = keys.filter((key) => a[key] !== null).length;
|
||||||
(rule) => rule.profileId
|
const bSpecificity = keys.filter((key) => b[key] !== null).length;
|
||||||
)?.profileId;
|
|
||||||
if (overrideProfileId) {
|
|
||||||
profileId = overrideProfileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrideTags = appliedOverrideRules.find((rule) => rule.tags)?.tags;
|
// Take the rule with the most specific condition first
|
||||||
if (overrideTags) {
|
return bSpecificity - aSpecificity;
|
||||||
tags = [
|
})[0];
|
||||||
...new Set([
|
|
||||||
...(tags || []),
|
if (prioritizedRule) {
|
||||||
...overrideTags.split(',').map((tag) => Number(tag)),
|
if (prioritizedRule.rootFolder) {
|
||||||
]),
|
rootFolder = prioritizedRule.rootFolder;
|
||||||
];
|
}
|
||||||
|
if (prioritizedRule.profileId) {
|
||||||
|
profileId = prioritizedRule.profileId;
|
||||||
|
}
|
||||||
|
if (prioritizedRule.tags) {
|
||||||
|
tags = [
|
||||||
|
...new Set([
|
||||||
|
...(tags || []),
|
||||||
|
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Override rule applied.', {
|
||||||
|
label: 'Media Request',
|
||||||
|
overrides: prioritizedRule,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,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
|
||||||
@@ -668,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 (
|
||||||
@@ -954,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',
|
||||||
@@ -1211,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',
|
||||||
@@ -1236,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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const SettingsMain = () => {
|
|||||||
locale: data?.locale ?? 'en',
|
locale: data?.locale ?? 'en',
|
||||||
discoverRegion: data?.discoverRegion,
|
discoverRegion: data?.discoverRegion,
|
||||||
originalLanguage: data?.originalLanguage,
|
originalLanguage: data?.originalLanguage,
|
||||||
streamingRegion: data?.streamingRegion,
|
streamingRegion: data?.streamingRegion || 'US',
|
||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||||
trustProxy: data?.trustProxy,
|
trustProxy: data?.trustProxy,
|
||||||
@@ -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,9 +449,9 @@ 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 || 'US'}
|
value={values.streamingRegion}
|
||||||
name="streamingRegion"
|
name="streamingRegion"
|
||||||
onChange={setFieldValue}
|
onChange={setFieldValue}
|
||||||
regionType="streaming"
|
regionType="streaming"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user