Compare commits
1 Commits
preview-un
...
preview-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a4b70ea0 |
@@ -403,159 +403,6 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "mobihen",
|
|
||||||
"name": "Nir Israel Hen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/35529491?v=4",
|
|
||||||
"profile": "https://mobihen.com",
|
|
||||||
"contributions": [
|
|
||||||
"translation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "XDark187",
|
|
||||||
"name": "Baraa",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/39034192?v=4",
|
|
||||||
"profile": "https://github.com/XDark187",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "franciscofsales",
|
|
||||||
"name": "Francisco Sales",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/7977645?v=4",
|
|
||||||
"profile": "https://github.com/franciscofsales",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "myselfolli",
|
|
||||||
"name": "Oliver Laing",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37535998?v=4",
|
|
||||||
"profile": "https://github.com/myselfolli",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "M0NsTeRRR",
|
|
||||||
"name": "Ludovic Ortega",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4",
|
|
||||||
"profile": "https://github.com/M0NsTeRRR",
|
|
||||||
"contributions": [
|
|
||||||
"security"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "demrich",
|
|
||||||
"name": "David Emrich",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
|
||||||
"profile": "https://github.com/demrich",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "maxnatamo",
|
|
||||||
"name": "Max T. Kristiansen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
|
||||||
"profile": "https://maxtrier.dk",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "DamsDev1",
|
|
||||||
"name": "Damien Fajole",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
|
||||||
"profile": "https://damsdev.me",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "AhmedNSidd",
|
|
||||||
"name": "Ahmed Siddiqui",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
|
||||||
"profile": "https://github.com/AhmedNSidd",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Zariel",
|
|
||||||
"name": "Chris Bannister",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/2213?v=4",
|
|
||||||
"profile": "https://github.com/Zariel",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "C4J3",
|
|
||||||
"name": "Joe",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/13005453?v=4",
|
|
||||||
"profile": "https://github.com/C4J3",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "guillaumearnx",
|
|
||||||
"name": "Guillaume ARNOUX",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37373941?v=4",
|
|
||||||
"profile": "https://me.garnx.fr",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "dr-carrot",
|
|
||||||
"name": "dr-carrot",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/17272571?v=4",
|
|
||||||
"profile": "https://github.com/dr-carrot",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "gageorsburn",
|
|
||||||
"name": "Gage Orsburn",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4692734?v=4",
|
|
||||||
"profile": "https://github.com/gageorsburn",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ config/logs/*
|
|||||||
config/*.json
|
config/*.json
|
||||||
dist
|
dist
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
compose.yaml
|
docker-compose.yml
|
||||||
docs
|
docs
|
||||||
LICENSE
|
LICENSE
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -40,7 +40,7 @@ docs export-ignore
|
|||||||
.all-contributorsrc export-ignore
|
.all-contributorsrc export-ignore
|
||||||
.editorconfig export-ignore
|
.editorconfig export-ignore
|
||||||
Dockerfile.local export-ignore
|
Dockerfile.local export-ignore
|
||||||
compose.yaml export-ignore
|
docker-compose.yml export-ignore
|
||||||
stylelint.config.js export-ignore
|
stylelint.config.js export-ignore
|
||||||
|
|
||||||
public/os_logo_filled.png export-ignore
|
public/os_logo_filled.png export-ignore
|
||||||
|
|||||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,2 +1,2 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
* @Fallenbagel @gauthier-th
|
* @Fallenbagel
|
||||||
|
|||||||
10
.github/ISSUE_TEMPLATE/bug.yml
vendored
10
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
description: Report a problem
|
description: Report a problem
|
||||||
labels: ['bug', 'awaiting triage']
|
labels: ['type:bug', 'awaiting-triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@@ -55,14 +55,6 @@ body:
|
|||||||
- tablet
|
- tablet
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
id: database
|
|
||||||
attributes:
|
|
||||||
options:
|
|
||||||
- SQLite (default)
|
|
||||||
- PostgreSQL
|
|
||||||
label: Database
|
|
||||||
description: Which database backend are you using?
|
|
||||||
- type: input
|
- type: input
|
||||||
id: device
|
id: device
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
2
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
description: Suggest an idea
|
description: Suggest an idea
|
||||||
labels: ['enhancement', 'awaiting triage']
|
labels: ['type:enhancement', 'awaiting-triage']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
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:22-alpine
|
container: node:20-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: 22
|
node-version: 20
|
||||||
- name: Pnpm Setup
|
- name: Pnpm Setup
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
33
.github/workflows/lint-helm-charts.yml
vendored
33
.github/workflows/lint-helm-charts.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Lint and Test Charts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- '.github/workflows/lint-helm-charts.yml'
|
|
||||||
- 'charts/**'
|
|
||||||
jobs:
|
|
||||||
lint-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Set up Helm
|
|
||||||
uses: azure/setup-helm@v4.2.0
|
|
||||||
- name: Ensure documentation is updated
|
|
||||||
uses: docker://jnorwood/helm-docs:v1.14.2
|
|
||||||
- name: Set up chart-testing
|
|
||||||
uses: helm/chart-testing-action@v2.6.1
|
|
||||||
- name: Run chart-testing (list-changed)
|
|
||||||
id: list-changed
|
|
||||||
run: |
|
|
||||||
changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }})
|
|
||||||
if [[ -n "$changed" ]]; then
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Run chart-testing
|
|
||||||
if: steps.list-changed.outputs.changed == 'true'
|
|
||||||
run: ct lint --target-branch ${{ github.event.repository.default_branch }} --validate-maintainers=false
|
|
||||||
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: 22
|
node-version: 20
|
||||||
- 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
|
||||||
|
|||||||
2
.github/workflows/test-docs-deploy.yml
vendored
2
.github/workflows/test-docs-deploy.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
paths:
|
path:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'gen-docs/**'
|
- 'gen-docs/**'
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-error.log*
|
|||||||
# database
|
# database
|
||||||
config/db/*.sqlite3*
|
config/db/*.sqlite3*
|
||||||
config/settings.json
|
config/settings.json
|
||||||
config/settings.old.json
|
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
config/logs/*.log*
|
config/logs/*.log*
|
||||||
|
|||||||
@@ -9,6 +9,3 @@ pnpm-lock.yaml
|
|||||||
src/assets/
|
src/assets/
|
||||||
public/
|
public/
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
# helm charts
|
|
||||||
**/charts
|
|
||||||
|
|||||||
@@ -15,11 +15,5 @@ module.exports = {
|
|||||||
rangeEnd: 0, // default: Infinity
|
rangeEnd: 0, // default: Infinity
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
files: 'charts/**',
|
|
||||||
options: {
|
|
||||||
rangeEnd: 0, // default: Infinity
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 22.x)
|
- [NodeJS](https://nodejs.org/en/download/) (Node 20.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)
|
||||||
|
|
||||||
@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
4. Run the development environment:
|
4. Run the development environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||||
|
|
||||||
5. Create your patch and test your changes.
|
5. Create your patch and test your changes.
|
||||||
|
|
||||||
@@ -101,46 +101,6 @@ We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-f
|
|||||||
|
|
||||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
If you are adding a new feature that requires a database migration, you will need to create 2 migrations: one for SQLite and one for PostgreSQL. Here is how you could do it:
|
|
||||||
|
|
||||||
1. Create a PostgreSQL database or use an existing one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo docker run --name postgres-jellyseerr -e POSTGRES_PASSWORD=postgres -d -p 127.0.0.1:5432:5432/tcp postgres:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Reset the SQLite database and the PostgreSQL database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm config/db/db.*
|
|
||||||
rm config/settings.*
|
|
||||||
PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "DROP DATABASE IF EXISTS jellyseerr;"
|
|
||||||
PGPASSWORD=postgres sudo docker exec -it postgres-jellyseerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE jellyseerr;"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Checkout the `develop` branch and create the original database for SQLite and PostgreSQL so that TypeORM can automatically generate the migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout develop
|
|
||||||
pnpm i
|
|
||||||
rm -r .next dist; pnpm build
|
|
||||||
pnpm start
|
|
||||||
DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
(You can shutdown the server once the message "Server ready on 5055" appears)
|
|
||||||
|
|
||||||
4. Let TypeORM generate the migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b your-feature-branch
|
|
||||||
pnpm i
|
|
||||||
pnpm migration:generate server/migration/sqlite/YourMigrationName
|
|
||||||
DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate server/migration/postgres/YourMigrationName
|
|
||||||
```
|
|
||||||
|
|
||||||
## Attribution
|
## Attribution
|
||||||
|
|
||||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides.
|
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine AS BUILD_IMAGE
|
FROM node:20-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:22-alpine
|
FROM node:20-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:22-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -8,14 +8,14 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
||||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-60-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||||
<!-- 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 additional 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 support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
|
||||||
|
|
||||||
## Current Features
|
## Current Features
|
||||||
|
|
||||||
@@ -137,32 +137,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -307,12 +281,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=demrich" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Patterns to ignore when building packages.
|
|
||||||
# This supports shell glob matching, relative path matching, and
|
|
||||||
# negation (prefixed with !). Only one pattern per line.
|
|
||||||
.DS_Store
|
|
||||||
# Common VCS dirs
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
.bzr/
|
|
||||||
.bzrignore
|
|
||||||
.hg/
|
|
||||||
.hgignore
|
|
||||||
.svn/
|
|
||||||
# Common backup files
|
|
||||||
*.swp
|
|
||||||
*.bak
|
|
||||||
*.tmp
|
|
||||||
*.orig
|
|
||||||
*~
|
|
||||||
# Various IDEs
|
|
||||||
.project
|
|
||||||
.idea/
|
|
||||||
*.tmproj
|
|
||||||
.vscode/
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
apiVersion: v2
|
|
||||||
kubeVersion: ">=1.23.0-0"
|
|
||||||
name: Jellyseerr
|
|
||||||
description: Jellyseerr helm chart for Kubernetes
|
|
||||||
type: application
|
|
||||||
version: 1.1.0
|
|
||||||
appVersion: "2.1.0"
|
|
||||||
maintainers:
|
|
||||||
- name: Jellyseerr
|
|
||||||
url: https://github.com/Fallenbagel/jellyseerr
|
|
||||||
sources:
|
|
||||||
- https://github.com/Fallenbagel/jellyseerr/tree/main/charts/jellyseerr
|
|
||||||
home: https://github.com/Fallenbagel/jellyseerr
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Jellyseerr
|
|
||||||
|
|
||||||
  
|
|
||||||
|
|
||||||
Jellyseerr helm chart for Kubernetes
|
|
||||||
|
|
||||||
**Homepage:** <https://github.com/Fallenbagel/jellyseerr>
|
|
||||||
|
|
||||||
## Maintainers
|
|
||||||
|
|
||||||
| Name | Email | Url |
|
|
||||||
| ---- | ------ | --- |
|
|
||||||
| Jellyseerr | | <https://github.com/Fallenbagel/jellyseerr> |
|
|
||||||
|
|
||||||
## Source Code
|
|
||||||
|
|
||||||
* <https://github.com/Fallenbagel/jellyseerr/tree/main/charts/jellyseerr>
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
Kubernetes: `>=1.23.0-0`
|
|
||||||
|
|
||||||
## Values
|
|
||||||
|
|
||||||
| Key | Type | Default | Description |
|
|
||||||
|-----|------|---------|-------------|
|
|
||||||
| affinity | object | `{}` | |
|
|
||||||
| autoscaling.enabled | bool | `false` | |
|
|
||||||
| autoscaling.maxReplicas | int | `100` | |
|
|
||||||
| autoscaling.minReplicas | int | `1` | |
|
|
||||||
| autoscaling.targetCPUUtilizationPercentage | int | `80` | |
|
|
||||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
|
||||||
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
|
||||||
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
|
|
||||||
| config.persistence.name | string | `""` | Config name |
|
|
||||||
| config.persistence.size | string | `"5Gi"` | Size of persistent disk |
|
|
||||||
| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. |
|
|
||||||
| extraEnv | list | `[]` | Environment variables to add to the jellyseerr pods |
|
|
||||||
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods |
|
|
||||||
| fullnameOverride | string | `""` | |
|
|
||||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
|
||||||
| image.registry | string | `"docker.io"` | |
|
|
||||||
| image.repository | string | `"fallenbagel/jellyseerr"` | |
|
|
||||||
| image.sha | string | `""` | |
|
|
||||||
| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. |
|
|
||||||
| imagePullSecrets | list | `[]` | |
|
|
||||||
| ingress.annotations | object | `{}` | |
|
|
||||||
| ingress.enabled | bool | `false` | |
|
|
||||||
| ingress.hosts[0].host | string | `"chart-example.local"` | |
|
|
||||||
| ingress.hosts[0].paths[0].path | string | `"/"` | |
|
|
||||||
| ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | |
|
|
||||||
| ingress.ingressClassName | string | `""` | |
|
|
||||||
| ingress.tls | list | `[]` | |
|
|
||||||
| nameOverride | string | `""` | |
|
|
||||||
| nodeSelector | object | `{}` | |
|
|
||||||
| podAnnotations | object | `{}` | |
|
|
||||||
| podLabels | object | `{}` | |
|
|
||||||
| podSecurityContext | object | `{}` | |
|
|
||||||
| replicaCount | int | `1` | |
|
|
||||||
| resources | object | `{}` | |
|
|
||||||
| securityContext | object | `{}` | |
|
|
||||||
| service.port | int | `80` | |
|
|
||||||
| service.type | string | `"ClusterIP"` | |
|
|
||||||
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
|
|
||||||
| serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? |
|
|
||||||
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
|
|
||||||
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
|
|
||||||
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
|
|
||||||
| tolerations | list | `[]` | |
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{{ template "chart.header" . }}
|
|
||||||
|
|
||||||
{{ template "chart.deprecationWarning" . }}
|
|
||||||
|
|
||||||
{{ template "chart.badgesSection" . }}
|
|
||||||
|
|
||||||
{{ template "chart.description" . }}
|
|
||||||
|
|
||||||
{{ template "chart.homepageLine" . }}
|
|
||||||
|
|
||||||
{{ template "chart.maintainersSection" . }}
|
|
||||||
|
|
||||||
{{ template "chart.sourcesSection" . }}
|
|
||||||
|
|
||||||
{{ template "chart.requirementsSection" . }}
|
|
||||||
|
|
||||||
{{ template "chart.valuesSection" . }}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
***********************************************************************
|
|
||||||
Welcome to {{ .Chart.Name }}
|
|
||||||
Chart version: {{ .Chart.Version }}
|
|
||||||
App version: {{ .Chart.AppVersion }}
|
|
||||||
***********************************************************************
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{{/*
|
|
||||||
Expand the name of the chart.
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.name" -}}
|
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create a default fully qualified app name.
|
|
||||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
|
||||||
If release name contains chart name it will be used as a full name.
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create chart name and version as used by the chart label.
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.chart" -}}
|
|
||||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Common labels
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.labels" -}}
|
|
||||||
helm.sh/chart: {{ include "jellyseerr.chart" . }}
|
|
||||||
{{ include "jellyseerr.selectorLabels" . }}
|
|
||||||
{{- if .Chart.AppVersion }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
||||||
{{- end }}
|
|
||||||
app.kubernetes.io/part-of: {{ .Chart.Name }}
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Selector labels
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.selectorLabels" -}}
|
|
||||||
app.kubernetes.io/name: {{ include "jellyseerr.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create the name of the service account to use
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.serviceAccountName" -}}
|
|
||||||
{{- if .Values.serviceAccount.create }}
|
|
||||||
{{- default (include "jellyseerr.fullname" .) .Values.serviceAccount.name }}
|
|
||||||
{{- else }}
|
|
||||||
{{- default "default" .Values.serviceAccount.name }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create the name of the pvc config to use
|
|
||||||
*/}}
|
|
||||||
{{- define "jellyseerr.configPersistenceName" -}}
|
|
||||||
{{- default (printf "%s-config" (include "jellyseerr.fullname" .)) .Values.config.persistence.name }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
{{- if not .Values.autoscaling.enabled }}
|
|
||||||
replicas: {{ .Values.replicaCount }}
|
|
||||||
{{- end }}
|
|
||||||
strategy:
|
|
||||||
type: {{ .Values.strategy.type }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "jellyseerr.selectorLabels" . | nindent 6 }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
{{- with .Values.podAnnotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 8 }}
|
|
||||||
{{- with .Values.podLabels }}
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.imagePullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
serviceAccountName: {{ include "jellyseerr.serviceAccountName" . }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
|
||||||
containers:
|
|
||||||
- name: {{ .Chart.Name }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
|
||||||
{{- if .Values.image.sha }}
|
|
||||||
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}@sha256:{{ .Values.image.sha }}"
|
|
||||||
{{- else }}
|
|
||||||
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
|
||||||
{{- end }}
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: 5055
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: http
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: http
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
|
||||||
{{- with .Values.extraEnv }}
|
|
||||||
env:
|
|
||||||
{{- toYaml . | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.extraEnvFrom }}
|
|
||||||
envFrom:
|
|
||||||
{{- toYaml . | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
volumeMounts:
|
|
||||||
- name: config
|
|
||||||
mountPath: /app/config
|
|
||||||
volumes:
|
|
||||||
- name: config
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ include "jellyseerr.configPersistenceName" . }}
|
|
||||||
{{- with .Values.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{- if .Values.autoscaling.enabled }}
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
|
||||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
|
||||||
metrics:
|
|
||||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: memory
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{{- if .Values.ingress.enabled -}}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.ingress.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- if .Values.ingress.ingressClassName }}
|
|
||||||
ingressClassName: {{ .Values.ingress.ingressClassName }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.ingress.tls }}
|
|
||||||
tls:
|
|
||||||
{{- range .Values.ingress.tls }}
|
|
||||||
- hosts:
|
|
||||||
{{- range .hosts }}
|
|
||||||
- {{ . | quote }}
|
|
||||||
{{- end }}
|
|
||||||
secretName: {{ .secretName }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
rules:
|
|
||||||
{{- range .Values.ingress.hosts }}
|
|
||||||
- host: {{ .host | quote }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
{{- range .paths }}
|
|
||||||
- path: {{ .path }}
|
|
||||||
pathType: {{ .pathType }}
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ include "jellyseerr.fullname" $ }}
|
|
||||||
port:
|
|
||||||
number: {{ $.Values.service.port }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.configPersistenceName" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.config.persistence.accessModes }}
|
|
||||||
accessModes:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.config.persistence.volumeName }}
|
|
||||||
volumeName: {{ .Values.config.persistence.volumeName }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.config.persistence.storageClass }}
|
|
||||||
storageClassName: {{ if (eq "-" .) }}""{{ else }}{{ . }}{{ end }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: "{{ .Values.config.persistence.size }}"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
type: {{ .Values.service.type }}
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.service.port }}
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
{{- include "jellyseerr.selectorLabels" . | nindent 4 }}
|
|
||||||
ipFamilyPolicy: PreferDualStack
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{{- if .Values.serviceAccount.create -}}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: {{ include "jellyseerr.serviceAccountName" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
{{- with .Values.serviceAccount.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
metadata:
|
|
||||||
name: "{{ include "jellyseerr.fullname" . }}-test-connection"
|
|
||||||
labels:
|
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
|
||||||
annotations:
|
|
||||||
"helm.sh/hook": test
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: wget
|
|
||||||
image: busybox
|
|
||||||
command: ['wget']
|
|
||||||
args: ['{{ include "jellyseerr.fullname" . }}:{{ .Values.service.port }}']
|
|
||||||
restartPolicy: Never
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
replicaCount: 1
|
|
||||||
|
|
||||||
image:
|
|
||||||
registry: docker.io
|
|
||||||
repository: fallenbagel/jellyseerr
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
# -- Overrides the image tag whose default is the chart appVersion.
|
|
||||||
tag: ""
|
|
||||||
sha: ""
|
|
||||||
|
|
||||||
imagePullSecrets: []
|
|
||||||
nameOverride: ""
|
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
# -- Deployment strategy
|
|
||||||
strategy:
|
|
||||||
type: Recreate
|
|
||||||
|
|
||||||
# -- Environment variables to add to the jellyseerr pods
|
|
||||||
extraEnv: []
|
|
||||||
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
|
||||||
extraEnvFrom: []
|
|
||||||
|
|
||||||
serviceAccount:
|
|
||||||
# -- Specifies whether a service account should be created
|
|
||||||
create: true
|
|
||||||
# -- Automatically mount a ServiceAccount's API credentials?
|
|
||||||
automount: true
|
|
||||||
# -- Annotations to add to the service account
|
|
||||||
annotations: {}
|
|
||||||
# -- The name of the service account to use.
|
|
||||||
# -- If not set and create is true, a name is generated using the fullname template
|
|
||||||
name: ""
|
|
||||||
|
|
||||||
podAnnotations: {}
|
|
||||||
podLabels: {}
|
|
||||||
|
|
||||||
podSecurityContext: {}
|
|
||||||
# fsGroup: 2000
|
|
||||||
|
|
||||||
securityContext: {}
|
|
||||||
# capabilities:
|
|
||||||
# drop:
|
|
||||||
# - ALL
|
|
||||||
# readOnlyRootFilesystem: true
|
|
||||||
# runAsNonRoot: true
|
|
||||||
# runAsUser: 1000
|
|
||||||
|
|
||||||
service:
|
|
||||||
type: ClusterIP
|
|
||||||
port: 80
|
|
||||||
|
|
||||||
# -- Creating PVC to store configuration
|
|
||||||
config:
|
|
||||||
persistence:
|
|
||||||
# -- Size of persistent disk
|
|
||||||
size: 5Gi
|
|
||||||
# -- Annotations for PVCs
|
|
||||||
annotations: {}
|
|
||||||
# -- Access modes of persistent disk
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
# -- Config name
|
|
||||||
name: ""
|
|
||||||
# -- Name of the permanent volume to reference in the claim.
|
|
||||||
# Can be used to bind to existing volumes.
|
|
||||||
volumeName: ""
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: false
|
|
||||||
ingressClassName: ""
|
|
||||||
annotations: {}
|
|
||||||
# kubernetes.io/ingress.class: nginx
|
|
||||||
# kubernetes.io/tls-acme: "true"
|
|
||||||
hosts:
|
|
||||||
- host: chart-example.local
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: ImplementationSpecific
|
|
||||||
tls: []
|
|
||||||
# - secretName: chart-example-tls
|
|
||||||
# hosts:
|
|
||||||
# - chart-example.local
|
|
||||||
|
|
||||||
resources: {}
|
|
||||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
|
||||||
# choice for the user. This also increases chances charts run on environments with little
|
|
||||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
|
||||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
|
||||||
# limits:
|
|
||||||
# cpu: 100m
|
|
||||||
# memory: 128Mi
|
|
||||||
# requests:
|
|
||||||
# cpu: 100m
|
|
||||||
# memory: 128Mi
|
|
||||||
|
|
||||||
autoscaling:
|
|
||||||
enabled: false
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 100
|
|
||||||
targetCPUUtilizationPercentage: 80
|
|
||||||
# targetMemoryUtilizationPercentage: 80
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
|
|
||||||
tolerations: []
|
|
||||||
|
|
||||||
affinity: {}
|
|
||||||
@@ -16,13 +16,11 @@
|
|||||||
"hideAvailable": false,
|
"hideAvailable": false,
|
||||||
"localLogin": true,
|
"localLogin": true,
|
||||||
"newPlexLogin": true,
|
"newPlexLogin": true,
|
||||||
"discoverRegion": "",
|
"region": "",
|
||||||
"streamingRegion": "",
|
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"mediaServerType": 1,
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"enableSpecialEpisodes": false,
|
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
@@ -77,7 +75,6 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
"webhookRoleId": "",
|
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -101,7 +98,6 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"botAPI": "",
|
"botAPI": "",
|
||||||
"chatId": "",
|
"chatId": "",
|
||||||
"messageThreadId": "",
|
|
||||||
"sendSilently": false
|
"sendSilently": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const testUser = {
|
const testUser = {
|
||||||
username: 'Test User',
|
displayName: 'Test User',
|
||||||
emailAddress: 'test@seeerr.dev',
|
emailAddress: 'test@seeerr.dev',
|
||||||
password: 'test1234',
|
password: 'test1234',
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,7 @@ describe('User List', () => {
|
|||||||
|
|
||||||
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
||||||
|
|
||||||
cy.get('#username').type(testUser.username);
|
cy.get('#displayName').type(testUser.displayName);
|
||||||
cy.get('#email').type(testUser.emailAddress);
|
cy.get('#email').type(testUser.emailAddress);
|
||||||
cy.get('#password').type(testUser.password);
|
cy.get('#password').type(testUser.password);
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.local
|
|
||||||
ports:
|
|
||||||
- '5055:5055'
|
|
||||||
environment:
|
|
||||||
DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres"
|
|
||||||
DB_HOST: 'postgres' # The host (url) of the database
|
|
||||||
DB_PORT: '5432' # The port to connect to
|
|
||||||
DB_USER: 'jellyseerr' # Username used to connect to the database
|
|
||||||
DB_PASS: 'jellyseerr' # Password of the user used to connect to the database
|
|
||||||
DB_NAME: 'jellyseerr' # The name of the database to connect to
|
|
||||||
DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging
|
|
||||||
DB_USE_SSL: 'false' # Whether to enable ssl for database connection
|
|
||||||
volumes:
|
|
||||||
- .:/app:rw,cached
|
|
||||||
- /app/node_modules
|
|
||||||
- /app/.next
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
links:
|
|
||||||
- postgres
|
|
||||||
postgres:
|
|
||||||
image: postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: jellyseerr
|
|
||||||
POSTGRES_PASSWORD: jellyseerr
|
|
||||||
POSTGRES_DB: jellyseerr
|
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
volumes:
|
|
||||||
- postgres:/var/lib/postgresql/data
|
|
||||||
volumes:
|
|
||||||
postgres:
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
version: '3'
|
||||||
services:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
build:
|
build:
|
||||||
@@ -17,7 +17,6 @@ Welcome to the Jellyseerr Documentation.
|
|||||||
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
- **Mobile-friendly design**, for when you need to approve requests on the go.
|
||||||
- Granular permission system.
|
- Granular permission system.
|
||||||
- Localization into other languages.
|
- Localization into other languages.
|
||||||
- Support for PostgreSQL and SQLite databases.
|
|
||||||
- More features to come!
|
- More features to come!
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
---
|
|
||||||
title: Configuring the Database (Advanced)
|
|
||||||
description: Configure the database for Jellyseerr
|
|
||||||
sidebar_position: 2
|
|
||||||
---
|
|
||||||
# Configuring the Database
|
|
||||||
|
|
||||||
Jellyseerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables:
|
|
||||||
|
|
||||||
## SQLite Options
|
|
||||||
|
|
||||||
If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used.
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
|
||||||
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
|
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
|
||||||
```
|
|
||||||
|
|
||||||
## PostgreSQL Options
|
|
||||||
|
|
||||||
### TCP Connection
|
|
||||||
|
|
||||||
If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port.
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
|
||||||
DB_HOST="localhost" # (optional) The host (URL) of the database. The default is "localhost".
|
|
||||||
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
|
|
||||||
DB_USER= # (required) Username used to connect to the database.
|
|
||||||
DB_PASS= # (required) Password of the user used to connect to the database.
|
|
||||||
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unix Socket Connection
|
|
||||||
|
|
||||||
If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket.
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
|
||||||
DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory.
|
|
||||||
DB_USER= # (required) Username used to connect to the database.
|
|
||||||
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
|
|
||||||
DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The default is "jellyseerr".
|
|
||||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL configuration
|
|
||||||
|
|
||||||
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false".
|
|
||||||
DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true".
|
|
||||||
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
|
|
||||||
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
|
|
||||||
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
|
|
||||||
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
|
|
||||||
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
|
|
||||||
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
|
|
||||||
```
|
|
||||||
---
|
|
||||||
|
|
||||||
### Migrating from SQLite to PostgreSQL
|
|
||||||
|
|
||||||
1. Set up your PostgreSQL database and configure Jellyseerr to use it
|
|
||||||
2. Run Jellyseerr to create the tables in the PostgreSQL database
|
|
||||||
3. Stop Jellyseerr
|
|
||||||
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
|
|
||||||
:::info
|
|
||||||
Edit the postgres connection string to match your setup.
|
|
||||||
|
|
||||||
If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
|
|
||||||
:::
|
|
||||||
:::caution
|
|
||||||
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 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
|
|
||||||
@@ -95,8 +95,6 @@ location ^~ /jellyseerr {
|
|||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
sub_filter '/images/' '/$app/images/';
|
sub_filter '/images/' '/$app/images/';
|
||||||
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
|
||||||
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
|
||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
@@ -192,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
|||||||
|
|
||||||
## Traefik (v2)
|
## Traefik (v2)
|
||||||
|
|
||||||
Add the following labels to the Jellyseerr service in your `compose.yaml` file:
|
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ sidebar_position: 4
|
|||||||
|
|
||||||
# AUR (Arch User Repository)
|
# AUR (Arch User Repository)
|
||||||
|
|
||||||
:::note Disclaimer
|
|
||||||
This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
|
This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution.
|
||||||
:::
|
:::
|
||||||
|
|||||||
@@ -6,20 +6,55 @@ sidebar_position: 2
|
|||||||
# Build from Source (Advanced)
|
# Build from Source (Advanced)
|
||||||
:::warning
|
:::warning
|
||||||
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
|
This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure.
|
||||||
|
|
||||||
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
import Tabs from '@theme/Tabs';
|
||||||
import TabItem from '@theme/TabItem';
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- [Node.js 22.x](https://nodejs.org/en/download/)
|
<Tabs groupId="versions" queryString>
|
||||||
|
<TabItem value="latest" label="Latest">
|
||||||
|
- [Node.js 18.x](https://nodejs.org/en/download/)
|
||||||
|
- [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="develop" label="Develop">
|
||||||
|
- [Node.js 20.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)
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
|
||||||
## Unix (Linux, macOS)
|
## Unix (Linux, macOS)
|
||||||
### Installation
|
### Installation
|
||||||
|
<Tabs groupId="versions" queryString>
|
||||||
|
<TabItem value="latest" label="latest">
|
||||||
|
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
||||||
|
```
|
||||||
|
2. Clone the Jellyseerr repository and checkout the latest release:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Fallenbagel/jellyseerr.git
|
||||||
|
cd jellyseerr
|
||||||
|
git checkout main
|
||||||
|
```
|
||||||
|
3. Install the dependencies:
|
||||||
|
```bash
|
||||||
|
CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
|
```
|
||||||
|
4. Build the project:
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
5. Start Jellyseerr:
|
||||||
|
```bash
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="develop" label="develop">
|
||||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
||||||
@@ -28,7 +63,7 @@ sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Fallenbagel/jellyseerr.git
|
git clone https://github.com/Fallenbagel/jellyseerr.git
|
||||||
cd jellyseerr
|
cd jellyseerr
|
||||||
git checkout main
|
git checkout develop # by default, you are on the develop branch so this step is not necessary
|
||||||
```
|
```
|
||||||
3. Install the dependencies:
|
3. Install the dependencies:
|
||||||
```bash
|
```bash
|
||||||
@@ -42,6 +77,8 @@ pnpm build
|
|||||||
```bash
|
```bash
|
||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||||
@@ -60,6 +97,9 @@ PORT=5055
|
|||||||
## specify on which interface to listen, by default jellyseerr listens on all interfaces
|
## specify on which interface to listen, by default jellyseerr listens on all interfaces
|
||||||
#HOST=127.0.0.1
|
#HOST=127.0.0.1
|
||||||
|
|
||||||
|
## Uncomment if your media server is emby instead of jellyfin.
|
||||||
|
# JELLYFIN_TYPE=emby
|
||||||
|
|
||||||
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
|
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
|
||||||
# FORCE_IPV4_FIRST=true
|
# FORCE_IPV4_FIRST=true
|
||||||
```
|
```
|
||||||
@@ -194,6 +234,32 @@ pm2 status jellyseerr
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
### Installation
|
### Installation
|
||||||
|
<Tabs groupId="versions" queryString>
|
||||||
|
<TabItem value="latest" label="latest">
|
||||||
|
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||||
|
```powershell
|
||||||
|
mkdir C:\jellyseerr
|
||||||
|
cd C:\jellyseerr
|
||||||
|
```
|
||||||
|
2. Clone the Jellyseerr repository and checkout the latest release:
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/Fallenbagel/jellyseerr.git .
|
||||||
|
git checkout main
|
||||||
|
```
|
||||||
|
3. Install the dependencies:
|
||||||
|
```powershell
|
||||||
|
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
|
```
|
||||||
|
4. Build the project:
|
||||||
|
```powershell
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
5. Start Jellyseerr:
|
||||||
|
```powershell
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
</TabItem>
|
||||||
|
<TabItem value="develop" label="develop">
|
||||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||||
```powershell
|
```powershell
|
||||||
mkdir C:\jellyseerr
|
mkdir C:\jellyseerr
|
||||||
@@ -202,11 +268,10 @@ cd C:\jellyseerr
|
|||||||
2. Clone the Jellyseerr repository and checkout the develop branch:
|
2. Clone the Jellyseerr repository and checkout the develop branch:
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/Fallenbagel/jellyseerr.git .
|
git clone https://github.com/Fallenbagel/jellyseerr.git .
|
||||||
git checkout main
|
git checkout develop # by default, you are on the develop branch so this step is not necessary
|
||||||
```
|
```
|
||||||
3. Install the dependencies:
|
3. Install the dependencies:
|
||||||
```powershell
|
```powershell
|
||||||
npm install -g win-node-env
|
|
||||||
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
|
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
|
||||||
```
|
```
|
||||||
4. Build the project:
|
4. Build the project:
|
||||||
@@ -217,6 +282,8 @@ pnpm build
|
|||||||
```powershell
|
```powershell
|
||||||
pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
You can add the environment variables to a `.env` file in the Jellyseerr directory.
|
You can add the environment variables to a `.env` file in the Jellyseerr directory.
|
||||||
@@ -244,7 +311,6 @@ node dist/index.js
|
|||||||
- Set the trigger to "When the computer starts"
|
- Set the trigger to "When the computer starts"
|
||||||
- Set the action to "Start a program"
|
- Set the action to "Start a program"
|
||||||
- Set the program/script to the path of the `start-jellyseerr.bat` file
|
- Set the program/script to the path of the `start-jellyseerr.bat` file
|
||||||
- Set the "Start in" to the jellyseerr directory.
|
|
||||||
- Click "Finish"
|
- Click "Finish"
|
||||||
|
|
||||||
Now, Jellyseerr will start when the computer boots up in the background.
|
Now, Jellyseerr will start when the computer boots up in the background.
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ sidebar_position: 1
|
|||||||
:::info
|
:::info
|
||||||
This is the recommended method for most users.
|
This is the recommended method for most users.
|
||||||
Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/).
|
Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/).
|
||||||
|
|
||||||
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Unix (Linux, macOS)
|
## Unix (Linux, macOS)
|
||||||
@@ -73,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
|
|||||||
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
|
||||||
|
|
||||||
#### Installation:
|
#### Installation:
|
||||||
Define the `jellyseerr` service in your `compose.yaml` as follows:
|
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
@@ -96,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
|
|||||||
|
|
||||||
Then, start all services defined in the Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
docker compose pull jellyseerr
|
docker-compose pull jellyseerr
|
||||||
```
|
```
|
||||||
Then, restart all services defined in the Compose file:
|
Then, restart all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
:::tip
|
:::tip
|
||||||
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
||||||
@@ -147,16 +145,6 @@ 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>
|
||||||
|
|
||||||
@@ -179,16 +167,6 @@ 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>
|
||||||
@@ -207,6 +185,3 @@ 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.
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ sidebar_position: 3
|
|||||||
|
|
||||||
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
|
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
|
||||||
import Admonition from '@theme/Admonition';
|
import Admonition from '@theme/Admonition';
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
# Nix Package Manager (Advanced)
|
# Nix Package Manager (Advanced)
|
||||||
:::info
|
:::info
|
||||||
@@ -15,55 +13,22 @@ This method is not recommended for most users. It is intended for advanced users
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
export const VersionMismatchWarning = () => {
|
export const VersionMismatchWarning = () => {
|
||||||
let jellyseerrVersion = null;
|
const jellyseerrVersion = JellyseerrVersion();
|
||||||
let nixpkgVersions = null;
|
const nixpkgVersion = NixpkgVersion();
|
||||||
try {
|
|
||||||
jellyseerrVersion = JellyseerrVersion();
|
|
||||||
nixpkgVersions = NixpkgVersion();
|
|
||||||
} catch (err) {
|
|
||||||
return (
|
|
||||||
<Admonition type="error">
|
|
||||||
Failed to load version information. Error: {err.message || JSON.stringify(err)}
|
|
||||||
</Admonition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nixpkgVersions || nixpkgVersions.error) {
|
const isUpToDate = jellyseerrVersion === nixpkgVersion;
|
||||||
return (
|
|
||||||
<Admonition type="error">
|
|
||||||
Failed to fetch Nixpkg versions: {nixpkgVersions?.error || 'Unknown error'}
|
|
||||||
</Admonition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUnstableUpToDate = jellyseerrVersion === nixpkgVersions.unstable;
|
|
||||||
const isStableUpToDate = jellyseerrVersion === nixpkgVersions.stable;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isStableUpToDate ? (
|
{!isUpToDate ? (
|
||||||
<Admonition type="warning">
|
<Admonition type="warning">
|
||||||
The{' '}
|
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>.
|
||||||
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-24.11/pkgs/servers/jellyseerr/default.nix#L14">
|
</Admonition>
|
||||||
upstream Jellyseerr Nix Package (v{nixpkgVersions.stable})
|
) : (
|
||||||
</a>{' '}
|
<Admonition type="success">
|
||||||
is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>,{' '}
|
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is <b>up-to-date</b> with <b>Jellyseerr v{jellyseerrVersion}</b>.
|
||||||
{isUnstableUpToDate ? (
|
</Admonition>
|
||||||
<>
|
)}
|
||||||
consider using the{' '}
|
|
||||||
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix">
|
|
||||||
unstable package
|
|
||||||
</a>{' '}
|
|
||||||
instead.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
you will need to{' '}
|
|
||||||
<a href="#overriding-the-package-derivation">override the package derivation</a>.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Admonition>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -83,8 +48,6 @@ To get up and running with jellyseerr using Nix, you can add the following to yo
|
|||||||
|
|
||||||
If you want more advanced configuration options, you can use the following:
|
If you want more advanced configuration options, you can use the following:
|
||||||
|
|
||||||
<Tabs groupId="nixpkg-methods" queryString>
|
|
||||||
<TabItem value="default" label="Default Configurations">
|
|
||||||
```nix
|
```nix
|
||||||
{ config, pkgs, ... }:
|
{ config, pkgs, ... }:
|
||||||
|
|
||||||
@@ -93,20 +56,53 @@ If you want more advanced configuration options, you can use the following:
|
|||||||
enable = true;
|
enable = true;
|
||||||
port = 5055;
|
port = 5055;
|
||||||
openFirewall = true;
|
openFirewall = true;
|
||||||
package = pkgs.jellyseerr; # Use the unstable package if stable is not up-to-date
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</TabItem>
|
|
||||||
<TabItem value="custom" label="Database Configurations">
|
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
|
||||||
In order to use postgres, you will need to add override the default module of jellyseerr with the following as the current default module is not compatible with postgres:
|
|
||||||
```nix
|
```bash
|
||||||
|
nixos-rebuild switch
|
||||||
|
```
|
||||||
|
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
|
||||||
|
```bash
|
||||||
|
systemctl status jellyseerr
|
||||||
|
```
|
||||||
|
|
||||||
|
:::info
|
||||||
|
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||||
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import CodeBlock from '@theme/CodeBlock';
|
||||||
|
|
||||||
|
## Overriding the package derivation
|
||||||
|
export const VersionMatch = () => {
|
||||||
|
const jellyseerrVersion = JellyseerrVersion();
|
||||||
|
const nixpkgVersion = NixpkgVersion();
|
||||||
|
|
||||||
|
const code = `{ config, pkgs, ... }:
|
||||||
{
|
{
|
||||||
config,
|
nixpkgs.config.packageOverrides = pkgs: {
|
||||||
pkgs,
|
jellyseerr = pkgs.jellyseerr.overrideAttrs (oldAttrs: rec {
|
||||||
lib,
|
version = "${jellyseerrVersion}";
|
||||||
...
|
|
||||||
}:
|
src = pkgs.fetchFromGitHub {
|
||||||
|
rev = "v\${version}";
|
||||||
|
sha256 = pkgs.lib.fakeSha256;
|
||||||
|
};
|
||||||
|
|
||||||
|
offlineCache = pkgs.fetchYarnDeps {
|
||||||
|
sha256 = pkgs.lib.fakeSha256;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const module = `{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
with lib;
|
with lib;
|
||||||
let
|
let
|
||||||
cfg = config.services.jellyseerr;
|
cfg = config.services.jellyseerr;
|
||||||
@@ -117,65 +113,28 @@ in
|
|||||||
disabledModules = [ "services/misc/jellyseerr.nix" ];
|
disabledModules = [ "services/misc/jellyseerr.nix" ];
|
||||||
|
|
||||||
options.services.jellyseerr = {
|
options.services.jellyseerr = {
|
||||||
enable = mkEnableOption ''Jellyseerr, a requests manager for Jellyfin'';
|
enable = mkEnableOption (mdDoc ''Jellyseerr, a requests manager for Jellyfin'');
|
||||||
|
|
||||||
openFirewall = mkOption {
|
openFirewall = mkOption {
|
||||||
type = types.bool;
|
type = types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''Open port in the firewall for the Jellyseerr web interface.'';
|
description = mdDoc ''Open port in the firewall for the Jellyseerr web interface.'';
|
||||||
};
|
};
|
||||||
|
|
||||||
port = mkOption {
|
port = mkOption {
|
||||||
type = types.port;
|
type = types.port;
|
||||||
default = 5055;
|
default = 5055;
|
||||||
description = ''The port which the Jellyseerr web UI should listen to.'';
|
description = mdDoc ''The port which the Jellyseerr web UI should listen to.'';
|
||||||
};
|
};
|
||||||
|
|
||||||
package = mkOption {
|
package = mkOption {
|
||||||
type = types.package;
|
type = types.package;
|
||||||
default = pkgs.jellyseerr;
|
default = pkgs.jellyseerr;
|
||||||
defaultText = literalExpression "pkgs.jellyseerr";
|
defaultText = literalExpression "pkgs.jellyseerr";
|
||||||
description = ''
|
description = lib.mdDoc ''
|
||||||
Jellyseerr package to use.
|
Jellyseerr package to use.
|
||||||
'';
|
'';
|
||||||
};
|
|
||||||
|
|
||||||
databaseConfig = mkOption {
|
|
||||||
type = types.attrsOf types.str;
|
|
||||||
default = {
|
|
||||||
type = "sqlite";
|
|
||||||
configDirectory = "config";
|
|
||||||
logQueries = "false";
|
|
||||||
};
|
};
|
||||||
description = ''
|
|
||||||
Database configuration. For "sqlite", only "type", "configDirectory", and "logQueries" are relevant.
|
|
||||||
For "postgres", include host, port, user, pass, name, and optionally socket.
|
|
||||||
Example:
|
|
||||||
{
|
|
||||||
type = "postgres";
|
|
||||||
socket = "/run/postgresql";
|
|
||||||
user = "jellyseerr";
|
|
||||||
name = "jellyseerr";
|
|
||||||
logQueries = "false";
|
|
||||||
}
|
|
||||||
or
|
|
||||||
{
|
|
||||||
type = "postgres";
|
|
||||||
host = "localhost";
|
|
||||||
port = "5432";
|
|
||||||
user = "dbuser";
|
|
||||||
pass = "password";
|
|
||||||
name = "jellyseerr";
|
|
||||||
logQueries = "false";
|
|
||||||
}
|
|
||||||
or
|
|
||||||
{
|
|
||||||
type = "sqlite";
|
|
||||||
configDirectory = "config";
|
|
||||||
logQueries = "false";
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
@@ -183,29 +142,14 @@ in
|
|||||||
description = "Jellyseerr, a requests manager for Jellyfin";
|
description = "Jellyseerr, a requests manager for Jellyfin";
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
environment =
|
environment.PORT = toString cfg.port;
|
||||||
let
|
|
||||||
dbConfig = cfg.databaseConfig;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
PORT = toString cfg.port;
|
|
||||||
DB_TYPE = toString dbConfig.type;
|
|
||||||
CONFIG_DIRECTORY = toString dbConfig.configDirectory or "";
|
|
||||||
DB_LOG_QUERIES = toString dbConfig.logQueries;
|
|
||||||
DB_HOST = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.host or "" else "";
|
|
||||||
DB_PORT = if dbConfig.type == "postgres" && !(hasAttr "socket" dbConfig) then toString dbConfig.port or "" else "";
|
|
||||||
DB_SOCKET_PATH = if dbConfig.type == "postgres" && hasAttr "socket" dbConfig then toString dbConfig.socket or "" else "";
|
|
||||||
DB_USER = if dbConfig.type == "postgres" then toString dbConfig.user or "" else "";
|
|
||||||
DB_PASS = if dbConfig.type == "postgres" then toString dbConfig.pass or "" else "";
|
|
||||||
DB_NAME = if dbConfig.type == "postgres" then toString dbConfig.name or "" else "";
|
|
||||||
};
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "exec";
|
Type = "exec";
|
||||||
StateDirectory = "jellyseerr";
|
StateDirectory = "jellyseerr";
|
||||||
WorkingDirectory = "${cfg.package}/libexec/jellyseerr";
|
WorkingDirectory = "\${cfg.package}/libexec/jellyseerr/deps/jellyseerr";
|
||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
ExecStart = "${cfg.package}/bin/jellyseerr";
|
ExecStart = "\${cfg.package}/bin/jellyseerr";
|
||||||
BindPaths = [ "/var/lib/jellyseerr/:${cfg.package}/libexec/jellyseerr/config/" ];
|
BindPaths = [ "/var/lib/jellyseerr/:\${cfg.package}/libexec/jellyseerr/deps/jellyseerr/config/" ];
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
@@ -225,47 +169,57 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.port ]; };
|
networking.firewall = mkIf cfg.openFirewall {
|
||||||
};
|
allowedTCPPorts = [ cfg.port ];
|
||||||
}
|
|
||||||
```
|
|
||||||
Then, import the module into your `configuration.nix`:
|
|
||||||
```nix
|
|
||||||
{ config, pkgs, ... }:
|
|
||||||
{
|
|
||||||
imports = [ ./modules/jellyseerr.nix ];
|
|
||||||
|
|
||||||
services.jellyseerr = {
|
|
||||||
enable = true;
|
|
||||||
port = 5055;
|
|
||||||
openFirewall = true;
|
|
||||||
package = pkgs.unstable.jellyseerr; # use the unstable package if stable is not up-to-date
|
|
||||||
databaseConfig = {
|
|
||||||
type = "postgres";
|
|
||||||
host = "localhost"; # or socket: "/run/postgresql"
|
|
||||||
port = "5432"; # if using socket, this is not needed
|
|
||||||
user = "jellyseerr";
|
|
||||||
pass = "jellyseerr";
|
|
||||||
name = "jellyseerr";
|
|
||||||
logQueries = "false";
|
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
}
|
}`;
|
||||||
```
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
|
const configuration = `{ config, pkgs, ... }:
|
||||||
|
{
|
||||||
|
imports = [ ./jellyseerr-module.nix ]
|
||||||
|
|
||||||
```bash
|
services.jellyseerr = {
|
||||||
nixos-rebuild switch
|
enable = true;
|
||||||
```
|
port = 5055;
|
||||||
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
|
openFirewall = true;
|
||||||
```bash
|
package = (pkgs.callPackage (import ../../../pkgs/jellyseerr) { });
|
||||||
systemctl status jellyseerr
|
};
|
||||||
```
|
}`;
|
||||||
|
|
||||||
:::info
|
const isUpToDate = jellyseerrVersion === nixpkgVersion;
|
||||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
|
||||||
:::
|
return (
|
||||||
|
<>
|
||||||
|
{isUpToDate ? (
|
||||||
|
<>
|
||||||
|
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg package version <strong>({nixpkgVersion})</strong> is <strong>up-to-date</strong>.</p>
|
||||||
|
<p>There is no need to override the package derivation.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>The latest version of Jellyseerr <strong>({jellyseerrVersion})</strong> and the Jellyseerr nixpkg version <strong>(v{nixpkgVersion})</strong> is <strong>out-of-date</strong>.
|
||||||
|
If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to override the package derivation.</p>
|
||||||
|
<p>In order to override the package derivation:</p>
|
||||||
|
<ol>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Grab the <a href="https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix">latest nixpkg derivation for Jellyseerr</a></li>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Grab the latest <a href="https://raw.githubusercontent.com/Fallenbagel/jellyseerr/main/package.json">package.json</a> for Jellyseerr</li>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Add it to the same directory as the nixpkg derivation</li>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Update the `src` and `offlineCache` attributes in the nixpkg derivation:</li>
|
||||||
|
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{code}</CodeBlock>
|
||||||
|
<Admonition type="tip" style={{ marginBottom: '1rem' }}>You can replace the <b>sha256</b> with the actual hash that <b>nixos-rebuild</b> outputs when you run the command.</Admonition>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Grab this module and import it in your `configuration.nix`</li>
|
||||||
|
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{module}</CodeBlock>
|
||||||
|
<Admonition type="tip" style={{ marginBottom: '1rem' }}>We are using a custom module because the upstream module does not have a package option.</Admonition>
|
||||||
|
<li style={{ marginBottom: '1rem' }}>Call the new package in your `configuration.nix`</li>
|
||||||
|
<CodeBlock className="language-nix" style={{ marginBottom: '1rem' }}>{configuration}</CodeBlock>
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
<VersionMatch />
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
---
|
|
||||||
title: Troubleshooting
|
|
||||||
---
|
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
## [TMDB] failed to retrieve/fetch XXX
|
|
||||||
|
|
||||||
### Option 1: Change your DNS servers
|
|
||||||
|
|
||||||
This error often comes from your Internet Service Provider (ISP) blocking TMDB API. The ISP may block the DNS resolution to the TMDB API hostname.
|
|
||||||
|
|
||||||
To fix this, you can change your DNS servers to a public DNS service like Google's DNS or Cloudflare's DNS:
|
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
|
||||||
|
|
||||||
Add the following to your `docker run` command to use Google's DNS:
|
|
||||||
```bash
|
|
||||||
--dns=8.8.8.8
|
|
||||||
```
|
|
||||||
or for Cloudflare's DNS:
|
|
||||||
```bash
|
|
||||||
--dns=1.1.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="docker-compose" label="Docker Compose">
|
|
||||||
|
|
||||||
Add the following to your `compose.yaml` to use Google's DNS:
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
```
|
|
||||||
or for Cloudflare's DNS:
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
dns:
|
|
||||||
- 1.1.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="windows" label="Windows">
|
|
||||||
|
|
||||||
1. Open the Control Panel.
|
|
||||||
2. Click on Network and Internet.
|
|
||||||
3. Click on Network and Sharing Center.
|
|
||||||
4. Click on Change adapter settings.
|
|
||||||
5. Right-click the network interface connected to the internet and select Properties.
|
|
||||||
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
|
||||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="linux" label="Linux">
|
|
||||||
|
|
||||||
1. Open a terminal.
|
|
||||||
2. Edit the `/etc/resolv.conf` file with your favorite text editor.
|
|
||||||
3. Add the following line to use Google's DNS:
|
|
||||||
```bash
|
|
||||||
nameserver 8.8.8.8
|
|
||||||
```
|
|
||||||
or for Cloudflare's DNS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nameserver 1.1.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
### Option 2: Force IPV4 resolution first
|
|
||||||
|
|
||||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
|
||||||
|
|
||||||
You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`:
|
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
|
||||||
|
|
||||||
Add the following to your `docker run` command:
|
|
||||||
```bash
|
|
||||||
-e "FORCE_IPV4_FIRST=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="docker-compose" label="Docker Compose">
|
|
||||||
|
|
||||||
Add the following to your `compose.yaml`:
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
environment:
|
|
||||||
- FORCE_IPV4_FIRST=true
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
### Option 3: Use Jellyseerr through a proxy
|
|
||||||
|
|
||||||
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
|
|
||||||
|
|
||||||
In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API.
|
|
||||||
|
|
||||||
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
|
|
||||||
|
|
||||||
### Option 4: Check that your server can reach TMDB API
|
|
||||||
|
|
||||||
Make sure that your server can reach the TMDB API by running the following command:
|
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="docker-compose" label="Docker Compose">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec jellyseerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="linux" label="Linux">
|
|
||||||
|
|
||||||
In a terminal:
|
|
||||||
```bash
|
|
||||||
curl -L https://api.themoviedb.org
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="windows" label="Windows">
|
|
||||||
|
|
||||||
In a PowerShell window:
|
|
||||||
```powershell
|
|
||||||
(Invoke-WebRequest -Uri "https://api.themoviedb.org" -Method Get).Content
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
If you can't get a response, then your server can't reach the TMDB API.
|
|
||||||
This is usually due to a network configuration issue or a firewall blocking the connection.
|
|
||||||
@@ -18,10 +18,6 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
|
|||||||
|
|
||||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
||||||
|
|
||||||
### Notification Role ID (optional)
|
|
||||||
|
|
||||||
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
|
|
||||||
|
|
||||||
### Bot Username (optional)
|
### Bot Username (optional)
|
||||||
|
|
||||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
|
|||||||
|
|
||||||
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
||||||
|
|
||||||
If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key.
|
|
||||||
|
|
||||||
## Application Title
|
## Application Title
|
||||||
|
|
||||||
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!
|
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!
|
||||||
@@ -58,9 +56,9 @@ You should enable this if you are having issues with loading images directly fro
|
|||||||
|
|
||||||
Set the default display language for Jellyseerr. Users can override this setting in their user settings.
|
Set the default display language for Jellyseerr. Users can override this setting in their user settings.
|
||||||
|
|
||||||
## Discover Region, Discover Language & Streaming Region
|
## Discover Region & Discover Language
|
||||||
|
|
||||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings.
|
||||||
|
|
||||||
## Hide Available Media
|
## Hide Available Media
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
|
|||||||
|
|
||||||
### Discover Region & Discover Language
|
### Discover Region & Discover Language
|
||||||
|
|
||||||
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-discover-language--streaming-region) to suit their own preferences.
|
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
|
||||||
|
|
||||||
### Movie Request Limit & Series Request Limit
|
### Movie Request Limit & Series Request Limit
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0"
|
"node": ">=18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,37 +26,25 @@ export const JellyseerrVersion = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NixpkgVersion = () => {
|
export const NixpkgVersion = () => {
|
||||||
const [versions, setVersions] = useState(null);
|
const [version, setVersion] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchVersion = async () => {
|
const fetchVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const unstableUrl =
|
const url =
|
||||||
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix';
|
'https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix';
|
||||||
const stableUrl =
|
const response = await fetch(url);
|
||||||
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-24.11/pkgs/servers/jellyseerr/default.nix';
|
const data = await response.text();
|
||||||
|
|
||||||
const [unstableResponse, stableResponse] = await Promise.all([
|
|
||||||
fetch(unstableUrl),
|
|
||||||
fetch(stableUrl),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const unstableData = await unstableResponse.text();
|
|
||||||
const stableData = await stableResponse.text();
|
|
||||||
|
|
||||||
const versionRegex = /version\s*=\s*"([^"]+)"/;
|
const versionRegex = /version\s*=\s*"([^"]+)"/;
|
||||||
|
const match = data.match(versionRegex);
|
||||||
const unstableMatch = unstableData.match(versionRegex);
|
if (match && match[1]) {
|
||||||
const stableMatch = stableData.match(versionRegex);
|
setVersion(match[1]);
|
||||||
|
} else {
|
||||||
const unstableVersion =
|
setError('0.0.0');
|
||||||
unstableMatch && unstableMatch[1] ? unstableMatch[1] : '0.0.0';
|
}
|
||||||
const stableVersion =
|
|
||||||
stableMatch && stableMatch[1] ? stableMatch[1] : '0.0.0';
|
|
||||||
|
|
||||||
setVersions({ unstable: unstableVersion, stable: stableVersion });
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -75,5 +63,5 @@ export const NixpkgVersion = () => {
|
|||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return versions;
|
return version;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ module.exports = {
|
|||||||
commitTag: process.env.COMMIT_TAG || 'local',
|
commitTag: process.env.COMMIT_TAG || 'local',
|
||||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
||||||
},
|
},
|
||||||
|
publicRuntimeConfig: {
|
||||||
|
// Will be available on both server and client
|
||||||
|
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ hostname: 'gravatar.com' },
|
{ hostname: 'gravatar.com' },
|
||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
{ hostname: 'artworks.thetvdb.com' },
|
{ hostname: '*', protocol: 'https' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ tags:
|
|||||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||||
- name: watchlist
|
- name: watchlist
|
||||||
description: Collection of media to watch later
|
description: Collection of media to watch later
|
||||||
- name: blacklist
|
|
||||||
description: Blacklisted media from discovery page.
|
|
||||||
servers:
|
servers:
|
||||||
- url: '{server}/api/v1'
|
- url: '{server}/api/v1'
|
||||||
variables:
|
variables:
|
||||||
@@ -48,19 +46,6 @@ servers:
|
|||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
Blacklist:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
tmdbId:
|
|
||||||
type: number
|
|
||||||
example: 1
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
media:
|
|
||||||
$ref: '#/components/schemas/MediaInfo'
|
|
||||||
userId:
|
|
||||||
type: number
|
|
||||||
example: 1
|
|
||||||
Watchlist:
|
Watchlist:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -143,12 +128,10 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
locale:
|
locale:
|
||||||
type: string
|
type: string
|
||||||
discoverRegion:
|
region:
|
||||||
type: string
|
type: string
|
||||||
originalLanguage:
|
originalLanguage:
|
||||||
type: string
|
type: string
|
||||||
streamingRegion:
|
|
||||||
type: string
|
|
||||||
MainSettings:
|
MainSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -188,9 +171,6 @@ components:
|
|||||||
defaultPermissions:
|
defaultPermissions:
|
||||||
type: number
|
type: number
|
||||||
example: 32
|
example: 32
|
||||||
enableSpecialEpisodes:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
PlexLibrary:
|
PlexLibrary:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1278,8 +1258,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
webhookRoleId:
|
|
||||||
type: string
|
|
||||||
enableMentions:
|
enableMentions:
|
||||||
type: boolean
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -1341,8 +1319,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
chatId:
|
chatId:
|
||||||
type: string
|
type: string
|
||||||
messageThreadId:
|
|
||||||
type: string
|
|
||||||
sendSilently:
|
sendSilently:
|
||||||
type: boolean
|
type: boolean
|
||||||
PushbulletSettings:
|
PushbulletSettings:
|
||||||
@@ -1826,9 +1802,6 @@ components:
|
|||||||
telegramChatId:
|
telegramChatId:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
telegramMessageThreadId:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
telegramSendSilently:
|
telegramSendSilently:
|
||||||
type: boolean
|
type: boolean
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -1942,11 +1915,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
native_name:
|
native_name:
|
||||||
type: string
|
type: string
|
||||||
OverrideRule:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -2005,9 +1973,6 @@ paths:
|
|||||||
appDataPath:
|
appDataPath:
|
||||||
type: string
|
type: string
|
||||||
example: /app/config
|
example: /app/config
|
||||||
appDataPermissions:
|
|
||||||
type: boolean
|
|
||||||
example: true
|
|
||||||
/settings/main:
|
/settings/main:
|
||||||
get:
|
get:
|
||||||
summary: Get main settings
|
summary: Get main settings
|
||||||
@@ -2810,15 +2775,6 @@ paths:
|
|||||||
imageCount:
|
imageCount:
|
||||||
type: number
|
type: number
|
||||||
example: 123
|
example: 123
|
||||||
avatar:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
size:
|
|
||||||
type: number
|
|
||||||
example: 123456
|
|
||||||
imageCount:
|
|
||||||
type: number
|
|
||||||
example: 123
|
|
||||||
apiCaches:
|
apiCaches:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -3630,8 +3586,6 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
serverType:
|
|
||||||
type: number
|
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- password
|
- password
|
||||||
@@ -3770,11 +3724,6 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum: [created, updated, requests, displayname]
|
enum: [created, updated, requests, displayname]
|
||||||
default: created
|
default: created
|
||||||
- in: query
|
|
||||||
name: q
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: A JSON array of all users
|
description: A JSON array of all users
|
||||||
@@ -3891,7 +3840,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
jellyfinUserIds:
|
jellyfinIds:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
@@ -4091,109 +4040,6 @@ paths:
|
|||||||
restricted:
|
restricted:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
/blacklist:
|
|
||||||
get:
|
|
||||||
summary: Returns blacklisted items
|
|
||||||
description: Returns list of all blacklisted media
|
|
||||||
tags:
|
|
||||||
- settings
|
|
||||||
parameters:
|
|
||||||
- in: query
|
|
||||||
name: take
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
nullable: true
|
|
||||||
example: 25
|
|
||||||
- in: query
|
|
||||||
name: skip
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
nullable: true
|
|
||||||
example: 0
|
|
||||||
- in: query
|
|
||||||
name: search
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
example: dune
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Blacklisted items returned
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
pageInfo:
|
|
||||||
$ref: '#/components/schemas/PageInfo'
|
|
||||||
results:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
createdAt:
|
|
||||||
type: string
|
|
||||||
example: 2024-04-21T01:55:44.000Z
|
|
||||||
id:
|
|
||||||
type: number
|
|
||||||
example: 1
|
|
||||||
mediaType:
|
|
||||||
type: string
|
|
||||||
example: movie
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
example: Dune
|
|
||||||
tmdbId:
|
|
||||||
type: number
|
|
||||||
example: 438631
|
|
||||||
post:
|
|
||||||
summary: Add media to blacklist
|
|
||||||
tags:
|
|
||||||
- blacklist
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Blacklist'
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Item succesfully blacklisted
|
|
||||||
'412':
|
|
||||||
description: Item has already been blacklisted
|
|
||||||
/blacklist/{tmdbId}:
|
|
||||||
get:
|
|
||||||
summary: Get media from blacklist
|
|
||||||
tags:
|
|
||||||
- blacklist
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: tmdbId
|
|
||||||
description: tmdbId ID
|
|
||||||
required: true
|
|
||||||
example: '1'
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Blacklist details in JSON
|
|
||||||
delete:
|
|
||||||
summary: Remove media from blacklist
|
|
||||||
tags:
|
|
||||||
- blacklist
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: tmdbId
|
|
||||||
description: tmdbId ID
|
|
||||||
required: true
|
|
||||||
example: '1'
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Succesfully removed media item
|
|
||||||
/watchlist:
|
/watchlist:
|
||||||
post:
|
post:
|
||||||
summary: Add media to watchlist
|
summary: Add media to watchlist
|
||||||
@@ -5016,11 +4862,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 8|9
|
example: 8|9
|
||||||
- in: query
|
|
||||||
name: status
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
example: 3|4
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5456,13 +5297,6 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum: [added, modified]
|
enum: [added, modified]
|
||||||
default: added
|
default: added
|
||||||
- in: query
|
|
||||||
name: sortDirection
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [asc, desc]
|
|
||||||
nullable: true
|
|
||||||
default: desc
|
|
||||||
- in: query
|
- in: query
|
||||||
name: requestedBy
|
name: requestedBy
|
||||||
schema:
|
schema:
|
||||||
@@ -5513,7 +5347,7 @@ paths:
|
|||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
- type: string
|
- type: string
|
||||||
enum: [all]
|
enum: [all]
|
||||||
is4k:
|
is4k:
|
||||||
@@ -5619,7 +5453,7 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
minimum: 0
|
minimum: 1
|
||||||
is4k:
|
is4k:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
@@ -6983,74 +6817,6 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WatchProviderDetails'
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
/overrideRule:
|
|
||||||
get:
|
|
||||||
summary: Get override rules
|
|
||||||
description: Returns a list of all override rules with their conditions and settings
|
|
||||||
tags:
|
|
||||||
- overriderule
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Override rules returned
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/OverrideRule'
|
|
||||||
post:
|
|
||||||
summary: Create override rule
|
|
||||||
description: Creates a new Override Rule from the request body.
|
|
||||||
tags:
|
|
||||||
- overriderule
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 'Values were successfully created'
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/OverrideRule'
|
|
||||||
/overrideRule/{ruleId}:
|
|
||||||
put:
|
|
||||||
summary: Update override rule
|
|
||||||
description: Updates an Override Rule from the request body.
|
|
||||||
tags:
|
|
||||||
- overriderule
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: ruleId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: 'Values were successfully updated'
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/OverrideRule'
|
|
||||||
delete:
|
|
||||||
summary: Delete override rule by ID
|
|
||||||
description: Deletes the override rule with the provided ruleId.
|
|
||||||
tags:
|
|
||||||
- overriderule
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: ruleId
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Override rule successfully deleted
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/OverrideRule'
|
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -62,14 +62,12 @@
|
|||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.9.1",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.7.0",
|
||||||
"pg": "8.11.0",
|
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.2",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -94,8 +92,7 @@
|
|||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.4",
|
||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.12",
|
||||||
"undici": "^6.20.1",
|
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
"winston-daily-rotate-file": "4.7.1",
|
"winston-daily-rotate-file": "4.7.1",
|
||||||
@@ -122,8 +119,7 @@
|
|||||||
"@types/express": "4.17.17",
|
"@types/express": "4.17.17",
|
||||||
"@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/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 +165,7 @@
|
|||||||
"typescript": "4.9.5"
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^20.0.0",
|
||||||
"pnpm": "^9.0.0"
|
"pnpm": "^9.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
2011
pnpm-lock.yaml
generated
2011
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
|
||||||
import { getSettings } from '@server/lib/settings';
|
|
||||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||||
import rateLimit from '@server/utils/rateLimit';
|
import rateLimit from '@server/utils/rateLimit';
|
||||||
import type NodeCache from 'node-cache';
|
import type NodeCache from 'node-cache';
|
||||||
@@ -34,32 +32,13 @@ class ExternalAPI {
|
|||||||
this.fetch = fetch;
|
this.fetch = fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(baseUrl);
|
this.baseUrl = baseUrl;
|
||||||
|
this.params = params;
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
this.defaultHeaders = {
|
this.defaultHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...((url.username || url.password) && {
|
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${url.username}:${url.password}`
|
|
||||||
).toString('base64')}`,
|
|
||||||
}),
|
|
||||||
...(settings.main.mediaServerType === MediaServerType.EMBY && {
|
|
||||||
'Accept-Encoding': 'gzip',
|
|
||||||
}),
|
|
||||||
...options.headers,
|
...options.headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (url.username || url.password) {
|
|
||||||
url.username = '';
|
|
||||||
url.password = '';
|
|
||||||
baseUrl = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.params = params;
|
|
||||||
this.cache = options.nodeCache;
|
this.cache = options.nodeCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +48,10 @@ class ExternalAPI {
|
|||||||
ttl?: number,
|
ttl?: number,
|
||||||
config?: RequestInit
|
config?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...this.params,
|
||||||
...params,
|
...params,
|
||||||
headers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
@@ -84,20 +60,20 @@ class ExternalAPI {
|
|||||||
const url = this.formatUrl(endpoint, params);
|
const url = this.formatUrl(endpoint, params);
|
||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
...config,
|
...config,
|
||||||
headers,
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,18 +82,15 @@ class ExternalAPI {
|
|||||||
|
|
||||||
protected async post<T>(
|
protected async post<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
ttl?: number,
|
ttl?: number,
|
||||||
config?: RequestInit
|
config?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
config: { ...this.params, ...params },
|
config: { ...this.params, ...params },
|
||||||
headers,
|
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
@@ -127,21 +100,15 @@ class ExternalAPI {
|
|||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
...config,
|
...config,
|
||||||
headers,
|
headers: {
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +122,10 @@ class ExternalAPI {
|
|||||||
ttl?: number,
|
ttl?: number,
|
||||||
config?: RequestInit
|
config?: RequestInit
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
config: { ...this.params, ...params },
|
config: { ...this.params, ...params },
|
||||||
data,
|
data,
|
||||||
headers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
@@ -171,21 +135,15 @@ class ExternalAPI {
|
|||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
...config,
|
...config,
|
||||||
headers,
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,22 +157,12 @@ class ExternalAPI {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = this.formatUrl(endpoint, params);
|
const url = this.formatUrl(endpoint, params);
|
||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
method: 'DELETE',
|
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: {
|
||||||
...this.defaultHeaders,
|
...this.defaultHeaders,
|
||||||
...config?.headers,
|
...config?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -227,11 +175,9 @@ class ExternalAPI {
|
|||||||
config?: RequestInit,
|
config?: RequestInit,
|
||||||
overwriteBaseUrl?: string
|
overwriteBaseUrl?: string
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...this.params,
|
||||||
...params,
|
...params,
|
||||||
headers,
|
|
||||||
});
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
|
||||||
@@ -246,19 +192,11 @@ class ExternalAPI {
|
|||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||||
this.fetch(url, {
|
this.fetch(url, {
|
||||||
...config,
|
...config,
|
||||||
headers,
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${
|
|
||||||
text ? ': ' + text : ''
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
});
|
});
|
||||||
@@ -269,17 +207,11 @@ class ExternalAPI {
|
|||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
...config,
|
...config,
|
||||||
headers,
|
headers: {
|
||||||
|
...this.defaultHeaders,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
@@ -289,14 +221,6 @@ class ExternalAPI {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected removeCache(endpoint: string, options?: Record<string, string>) {
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
|
||||||
...this.params,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
this.cache?.del(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatUrl(
|
private formatUrl(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
@@ -311,44 +235,35 @@ class ExternalAPI {
|
|||||||
...this.params,
|
...this.params,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
return (
|
return `${href}?${searchParams.toString()}`;
|
||||||
href +
|
|
||||||
(searchParams.toString().length
|
|
||||||
? '?' + searchParams.toString()
|
|
||||||
: searchParams.toString())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
if (!options) {
|
if (!params) {
|
||||||
return `${this.baseUrl}${endpoint}`;
|
return `${this.baseUrl}${endpoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
|
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDataFromResponse(response: Response) {
|
private async getDataFromResponse(response: Response) {
|
||||||
const contentType = response.headers.get('Content-Type');
|
const contentType = response.headers.get('Content-Type')?.split(';')[0];
|
||||||
if (contentType?.includes('application/json')) {
|
if (contentType === 'application/json') {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} else if (
|
} else if (
|
||||||
contentType?.includes('application/xml') ||
|
contentType === 'application/xml' ||
|
||||||
contentType?.includes('text/html') ||
|
contentType === 'text/html' ||
|
||||||
contentType?.includes('text/plain')
|
contentType === 'text/plain'
|
||||||
) {
|
) {
|
||||||
return await response.text();
|
return await response.text();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
return await response.blob();
|
||||||
return await response.blob();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class JellyfinAPI extends ExternalAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
|
private authToken?: string;
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
|
private jellyfinHost: string;
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
@@ -112,6 +114,9 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.jellyfinHost = jellyfinHost;
|
||||||
|
this.authToken = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -138,38 +143,39 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(true);
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Failed to authenticate with headers', {
|
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!e.cause.status) {
|
|
||||||
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.cause.status === 401) {
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause.status === 401) {
|
const status = e.response?.status;
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
|
||||||
|
const networkErrorCodes = new Set([
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'EHOSTUNREACH',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ECONNRESET',
|
||||||
|
'EADDRINUSE',
|
||||||
|
'ENETDOWN',
|
||||||
|
'ENETUNREACH',
|
||||||
|
'EPIPE',
|
||||||
|
'ECONNABORTED',
|
||||||
|
'EPROTO',
|
||||||
|
'EHOSTDOWN',
|
||||||
|
'EAI_AGAIN',
|
||||||
|
'ERR_INVALID_URL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (networkErrorCodes.has(e.code) || status === 404) {
|
||||||
|
throw new ApiError(status, ApiErrorCode.InvalidUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
||||||
'Something went wrong while authenticating with the Jellyfin server',
|
|
||||||
{
|
|
||||||
label: 'Jellyfin API',
|
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
ip: ClientIP,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +190,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +203,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the server name from the Jellyfin server',
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,11 +218,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +234,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +258,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting libraries from the Jellyfin server',
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
{
|
{ label: 'Jellyfin API' }
|
||||||
label: 'Jellyfin API',
|
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -310,11 +313,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,11 +334,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,16 +353,16 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.cause?.status === 500) {
|
if (e.response && e.response.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,11 +373,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,25 +398,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createApiToken(appName: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
await this.post(`/Auth/Keys?App=${appName}`);
|
|
||||||
const apiKeys = await this.get<any>(`/Auth/Keys`);
|
|
||||||
return apiKeys.Items.reverse().find(
|
|
||||||
(item: any) => item.AppName === appName
|
|
||||||
).AccessToken;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
'Something went wrong while creating an API key from the Jellyfin server',
|
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class PlexAPI {
|
|||||||
settings.plex.libraries = [];
|
settings.plex.libraries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await settings.save();
|
settings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLibraryContents(
|
public async getLibraryContents(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
|||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
@@ -128,11 +127,6 @@ export interface PlexWatchlistItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexWatchlistCache {
|
|
||||||
etag: string;
|
|
||||||
response: WatchlistResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlexTvAPI extends ExternalAPI {
|
class PlexTvAPI extends ExternalAPI {
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
|
|
||||||
@@ -267,11 +261,6 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
items: PlexWatchlistItem[];
|
items: PlexWatchlistItem[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const watchlistCache = cacheManager.getCache('plexwatchlist');
|
|
||||||
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
|
|
||||||
this.authToken
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
'X-Plex-Container-Start': offset.toString(),
|
'X-Plex-Container-Start': offset.toString(),
|
||||||
'X-Plex-Container-Size': size.toString(),
|
'X-Plex-Container-Size': size.toString(),
|
||||||
@@ -279,62 +268,42 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
const response = await this.fetch(
|
const response = await this.fetch(
|
||||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: this.defaultHeaders,
|
||||||
...this.defaultHeaders,
|
|
||||||
...(cachedWatchlist?.etag
|
|
||||||
? { 'If-None-Match': cachedWatchlist.etag }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as WatchlistResponse;
|
const data = (await response.json()) as WatchlistResponse;
|
||||||
|
|
||||||
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
|
||||||
cachedWatchlist = {
|
|
||||||
etag: response.headers.get('etag') ?? '',
|
|
||||||
response: data,
|
|
||||||
};
|
|
||||||
|
|
||||||
watchlistCache.data.set<PlexWatchlistCache>(
|
|
||||||
this.authToken,
|
|
||||||
cachedWatchlist
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchlistDetails = await Promise.all(
|
const watchlistDetails = await Promise.all(
|
||||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
|
||||||
async (watchlistItem) => {
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
{},
|
||||||
{},
|
undefined,
|
||||||
undefined,
|
{},
|
||||||
{},
|
'https://metadata.provider.plex.tv'
|
||||||
'https://metadata.provider.plex.tv'
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
const tmdbString = metadata.Guid.find((guid) =>
|
const tmdbString = metadata.Guid.find((guid) =>
|
||||||
guid.id.startsWith('tmdb')
|
guid.id.startsWith('tmdb')
|
||||||
);
|
);
|
||||||
const tvdbString = metadata.Guid.find((guid) =>
|
const tvdbString = metadata.Guid.find((guid) =>
|
||||||
guid.id.startsWith('tvdb')
|
guid.id.startsWith('tvdb')
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ratingKey: metadata.ratingKey,
|
ratingKey: metadata.ratingKey,
|
||||||
// This should always be set? But I guess it also cannot be?
|
// This should always be set? But I guess it also cannot be?
|
||||||
// We will filter out the 0's afterwards
|
// We will filter out the 0's afterwards
|
||||||
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||||
tvdbId: tvdbString
|
tvdbId: tvdbString
|
||||||
? Number(tvdbString.id.split('//')[1])
|
? Number(tvdbString.id.split('//')[1])
|
||||||
: undefined,
|
: undefined,
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
type: metadata.type,
|
type: metadata.type,
|
||||||
};
|
};
|
||||||
}
|
})
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||||
@@ -342,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
return {
|
return {
|
||||||
offset,
|
offset,
|
||||||
size,
|
size,
|
||||||
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
|
totalSize: data.MediaContainer.totalSize,
|
||||||
items: filteredList,
|
items: filteredList,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -358,29 +327,6 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pingToken() {
|
|
||||||
try {
|
|
||||||
const data: { pong: unknown } = await this.get(
|
|
||||||
'/api/v2/ping',
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.pong) {
|
|
||||||
throw new Error('No pong response');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to ping token', {
|
|
||||||
label: 'Plex Refresh Token',
|
|
||||||
errorMessage: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlexTvAPI;
|
export default PlexTvAPI;
|
||||||
|
|||||||
@@ -175,11 +175,7 @@ class IMDBRadarrProxy extends ExternalAPI {
|
|||||||
`/movie/imdb/${IMDBid}`
|
`/movie/imdb/${IMDBid}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||||
!data?.length ||
|
|
||||||
data[0].ImdbId !== IMDBid ||
|
|
||||||
!data[0].MovieRatings.Imdb
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movie?.rottenTomatoes) {
|
if (!movie) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
if (!tvshow) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||||
`/queue`,
|
includeEpisode: 'true',
|
||||||
{
|
});
|
||||||
includeEpisode: 'true',
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.records;
|
return data.records;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async refreshMonitoredDownloads(): Promise<void> {
|
|
||||||
await this.runCommand('RefreshMonitoredDownloads', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async runCommand(
|
protected async runCommand(
|
||||||
commandName: string,
|
commandName: string,
|
||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.post(
|
await this.post(`/command`, {
|
||||||
`/command`,
|
name: commandName,
|
||||||
{
|
...options,
|
||||||
name: commandName,
|
});
|
||||||
...options,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,20 +179,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
@@ -230,23 +223,6 @@ 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;
|
||||||
|
|||||||
@@ -257,18 +257,11 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
return createdSeriesData;
|
return createdSeriesData;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to add series');
|
throw new Error('Failed to add series');
|
||||||
}
|
}
|
||||||
@@ -303,10 +296,10 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runCommand('MissingEpisodeSearch', { seriesId });
|
await this.runCommand('SeriesSearch', { seriesId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while executing Sonarr missing episode search.',
|
'Something went wrong while executing Sonarr series search.',
|
||||||
{
|
{
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
@@ -353,30 +346,6 @@ 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;
|
||||||
|
|||||||
@@ -95,16 +95,15 @@ interface DiscoverTvOptions {
|
|||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
private discoverRegion?: string;
|
private region?: string;
|
||||||
private originalLanguage?: string;
|
private originalLanguage?: string;
|
||||||
constructor({
|
constructor({
|
||||||
discoverRegion,
|
region,
|
||||||
originalLanguage,
|
originalLanguage,
|
||||||
}: { discoverRegion?: string; originalLanguage?: string } = {}) {
|
}: { region?: string; originalLanguage?: string } = {}) {
|
||||||
super(
|
super(
|
||||||
'https://api.themoviedb.org/3',
|
'https://api.themoviedb.org/3',
|
||||||
{
|
{
|
||||||
@@ -118,7 +117,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.discoverRegion = discoverRegion;
|
this.region = region;
|
||||||
this.originalLanguage = originalLanguage;
|
this.originalLanguage = originalLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +468,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
include_adult: includeAdult ? 'true' : 'false',
|
||||||
language,
|
language,
|
||||||
region: this.discoverRegion || '',
|
region: this.region || '',
|
||||||
with_original_language:
|
with_original_language:
|
||||||
originalLanguage && originalLanguage !== 'all'
|
originalLanguage && originalLanguage !== 'all'
|
||||||
? originalLanguage
|
? originalLanguage
|
||||||
@@ -524,7 +523,6 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
voteCountLte,
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
withStatus,
|
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -541,7 +539,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
sort_by: sortBy,
|
sort_by: sortBy,
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
language,
|
language,
|
||||||
region: this.discoverRegion || '',
|
region: this.region || '',
|
||||||
// Set our release date values, but check if one is set and not the other,
|
// Set our release date values, but check if one is set and not the other,
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
'first_air_date.gte':
|
'first_air_date.gte':
|
||||||
@@ -572,7 +570,6 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
'vote_count.lte': voteCountLte || '',
|
'vote_count.lte': voteCountLte || '',
|
||||||
with_watch_providers: watchProviders || '',
|
with_watch_providers: watchProviders || '',
|
||||||
watch_region: watchRegion || '',
|
watch_region: watchRegion || '',
|
||||||
with_status: withStatus || '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -594,7 +591,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
language,
|
language,
|
||||||
region: this.discoverRegion || '',
|
region: this.region || '',
|
||||||
originalLanguage: this.originalLanguage || '',
|
originalLanguage: this.originalLanguage || '',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -620,7 +617,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
language,
|
language,
|
||||||
region: this.discoverRegion || '',
|
region: this.region || '',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ export enum ApiErrorCode {
|
|||||||
InvalidUrl = 'INVALID_URL',
|
InvalidUrl = 'INVALID_URL',
|
||||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||||
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',
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ export enum MediaStatus {
|
|||||||
PROCESSING,
|
PROCESSING,
|
||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLACKLISTED,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,3 @@ export enum MediaServerType {
|
|||||||
EMBY,
|
EMBY,
|
||||||
NOT_CONFIGURED,
|
NOT_CONFIGURED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerType {
|
|
||||||
JELLYFIN = 'Jellyfin',
|
|
||||||
EMBY = 'Emby',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,4 @@ export enum UserType {
|
|||||||
PLEX = 1,
|
PLEX = 1,
|
||||||
LOCAL = 2,
|
LOCAL = 2,
|
||||||
JELLYFIN = 3,
|
JELLYFIN = 3,
|
||||||
EMBY = 4,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,7 @@
|
|||||||
import fs from 'fs';
|
import 'reflect-metadata';
|
||||||
import type { TlsOptions } from 'tls';
|
|
||||||
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
const DB_SSL_PREFIX = 'DB_SSL_';
|
|
||||||
|
|
||||||
function boolFromEnv(envVar: string, defaultVal = false) {
|
|
||||||
if (process.env[envVar]) {
|
|
||||||
return process.env[envVar]?.toLowerCase() === 'true';
|
|
||||||
}
|
|
||||||
return defaultVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined {
|
|
||||||
if (process.env[envVar]) {
|
|
||||||
return process.env[envVar];
|
|
||||||
}
|
|
||||||
const filePath = process.env[`${envVar}_FILE`];
|
|
||||||
if (filePath) {
|
|
||||||
return fs.readFileSync(filePath);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSslConfig(): TlsOptions | undefined {
|
|
||||||
if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
rejectUnauthorized: boolFromEnv(
|
|
||||||
`${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`,
|
|
||||||
true
|
|
||||||
),
|
|
||||||
ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`),
|
|
||||||
key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`),
|
|
||||||
cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const devConfig: DataSourceOptions = {
|
const devConfig: DataSourceOptions = {
|
||||||
type: 'sqlite',
|
type: 'sqlite',
|
||||||
database: process.env.CONFIG_DIRECTORY
|
database: process.env.CONFIG_DIRECTORY
|
||||||
@@ -45,10 +9,10 @@ const devConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['server/entity/**/*.ts'],
|
entities: ['server/entity/**/*.ts'],
|
||||||
migrations: ['server/migration/sqlite/**/*.ts'],
|
migrations: ['server/migration/**/*.ts'],
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
subscribers: ['server/subscriber/**/*.ts'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,60 +23,16 @@ const prodConfig: DataSourceOptions = {
|
|||||||
: 'config/db/db.sqlite3',
|
: 'config/db/db.sqlite3',
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
logging: false,
|
||||||
enableWAL: true,
|
enableWAL: true,
|
||||||
entities: ['dist/entity/**/*.js'],
|
entities: ['dist/entity/**/*.js'],
|
||||||
migrations: ['dist/migration/sqlite/**/*.js'],
|
migrations: ['dist/migration/**/*.js'],
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
subscribers: ['dist/subscriber/**/*.js'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const postgresDevConfig: DataSourceOptions = {
|
const dataSource = new DataSource(
|
||||||
type: 'postgres',
|
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
|
||||||
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
|
);
|
||||||
port: process.env.DB_SOCKET_PATH
|
|
||||||
? undefined
|
|
||||||
: parseInt(process.env.DB_PORT ?? '5432'),
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
|
||||||
ssl: buildSslConfig(),
|
|
||||||
synchronize: false,
|
|
||||||
migrationsRun: true,
|
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
|
||||||
entities: ['server/entity/**/*.ts'],
|
|
||||||
migrations: ['server/migration/postgres/**/*.ts'],
|
|
||||||
subscribers: ['server/subscriber/**/*.ts'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const postgresProdConfig: DataSourceOptions = {
|
|
||||||
type: 'postgres',
|
|
||||||
host: process.env.DB_SOCKET_PATH || process.env.DB_HOST,
|
|
||||||
port: process.env.DB_SOCKET_PATH
|
|
||||||
? undefined
|
|
||||||
: parseInt(process.env.DB_PORT ?? '5432'),
|
|
||||||
username: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASS,
|
|
||||||
database: process.env.DB_NAME ?? 'jellyseerr',
|
|
||||||
ssl: buildSslConfig(),
|
|
||||||
synchronize: false,
|
|
||||||
migrationsRun: false,
|
|
||||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
|
||||||
entities: ['dist/entity/**/*.js'],
|
|
||||||
migrations: ['dist/migration/postgres/**/*.js'],
|
|
||||||
subscribers: ['dist/subscriber/**/*.js'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isPgsql = process.env.DB_TYPE === 'postgres';
|
|
||||||
|
|
||||||
function getDataSource(): DataSourceOptions {
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
return isPgsql ? postgresProdConfig : prodConfig;
|
|
||||||
} else {
|
|
||||||
return isPgsql ? postgresDevConfig : devConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataSource = new DataSource(getDataSource());
|
|
||||||
|
|
||||||
export const getRepository = <Entity extends object>(
|
export const getRepository = <Entity extends object>(
|
||||||
target: EntityTarget<Entity>
|
target: EntityTarget<Entity>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { MediaStatus, type MediaType } from '@server/constants/media';
|
|
||||||
import { getRepository } from '@server/datasource';
|
|
||||||
import Media from '@server/entity/Media';
|
|
||||||
import { User } from '@server/entity/User';
|
|
||||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
|
||||||
import {
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
Index,
|
|
||||||
JoinColumn,
|
|
||||||
ManyToOne,
|
|
||||||
OneToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Unique,
|
|
||||||
} from 'typeorm';
|
|
||||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
@Unique(['tmdbId'])
|
|
||||||
export class Blacklist implements BlacklistItem {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
public id: number;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
|
||||||
public mediaType: MediaType;
|
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'varchar' })
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
@Column()
|
|
||||||
@Index()
|
|
||||||
public tmdbId: number;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, (user) => user.id, {
|
|
||||||
eager: true,
|
|
||||||
})
|
|
||||||
user: User;
|
|
||||||
|
|
||||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
@JoinColumn()
|
|
||||||
public media: Media;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
public createdAt: Date;
|
|
||||||
|
|
||||||
constructor(init?: Partial<Blacklist>) {
|
|
||||||
Object.assign(this, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async addToBlacklist({
|
|
||||||
blacklistRequest,
|
|
||||||
}: {
|
|
||||||
blacklistRequest: {
|
|
||||||
mediaType: MediaType;
|
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
|
||||||
tmdbId: ZodNumber['_output'];
|
|
||||||
};
|
|
||||||
}): Promise<void> {
|
|
||||||
const blacklist = new this({
|
|
||||||
...blacklistRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
let media = await mediaRepository.findOne({
|
|
||||||
where: {
|
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const blacklistRepository = getRepository(this);
|
|
||||||
|
|
||||||
await blacklistRepository.save(blacklist);
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
media = new Media({
|
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
|
||||||
status: MediaStatus.BLACKLISTED,
|
|
||||||
status4k: MediaStatus.BLACKLISTED,
|
|
||||||
mediaType: blacklistRequest.mediaType,
|
|
||||||
blacklist: Promise.resolve(blacklist),
|
|
||||||
});
|
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
} else {
|
|
||||||
media.blacklist = Promise.resolve(blacklist);
|
|
||||||
media.status = MediaStatus.BLACKLISTED;
|
|
||||||
media.status4k = MediaStatus.BLACKLISTED;
|
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,12 @@ import SonarrAPI from '@server/api/servarr/sonarr';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { Blacklist } from '@server/entity/Blacklist';
|
|
||||||
import type { User } from '@server/entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import { Watchlist } from '@server/entity/Watchlist';
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
@@ -19,7 +17,6 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
@@ -43,10 +40,6 @@ class Media {
|
|||||||
finalIds = tmdbIds;
|
finalIds = tmdbIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await mediaRepository
|
const media = await mediaRepository
|
||||||
.createQueryBuilder('media')
|
.createQueryBuilder('media')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
@@ -73,7 +66,7 @@ class Media {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { tmdbId: id, mediaType: mediaType },
|
where: { tmdbId: id, mediaType },
|
||||||
relations: { requests: true, issues: true },
|
relations: { requests: true, issues: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,32 +116,16 @@ class Media {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
public issues: Issue[];
|
public issues: Issue[];
|
||||||
|
|
||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
|
||||||
public blacklist: Promise<Blacklist>;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
* The `lastSeasonChange` column stores the date and time when the media was added to the library.
|
|
||||||
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
|
|
||||||
*/
|
|
||||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
|
||||||
public lastSeasonChange: Date;
|
public lastSeasonChange: Date;
|
||||||
|
|
||||||
/**
|
@Column({ type: 'datetime', nullable: true })
|
||||||
* The `mediaAddedAt` column stores the date and time when the media was added to the library.
|
|
||||||
* It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`.
|
|
||||||
* This column is nullable because it can be null when the media is not yet synced to the library.
|
|
||||||
*/
|
|
||||||
@DbAwareColumn({
|
|
||||||
type: 'datetime',
|
|
||||||
default: () => 'CURRENT_TIMESTAMP',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
public mediaAddedAt: Date;
|
public mediaAddedAt: Date;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'int' })
|
@Column({ nullable: true, type: 'int' })
|
||||||
@@ -234,10 +211,9 @@ class Media {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pageName =
|
const pageName =
|
||||||
getSettings().main.mediaServerType == MediaServerType.EMBY
|
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||||
? 'item'
|
|
||||||
: 'details';
|
|
||||||
const { serverId, externalHostname } = getSettings().jellyfin;
|
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||||
|
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
@@ -247,7 +223,7 @@ class Media {
|
|||||||
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
if (this.jellyfinMediaId4k) {
|
if (this.jellyfinMediaId4k) {
|
||||||
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`;
|
this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ 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,
|
||||||
MediaType,
|
MediaType,
|
||||||
} from '@server/constants/media';
|
} from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import OverrideRule from '@server/entity/OverrideRule';
|
|
||||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
@@ -42,7 +40,6 @@ export class RequestPermissionError extends Error {}
|
|||||||
export class QuotaRestrictedError extends Error {}
|
export class QuotaRestrictedError extends Error {}
|
||||||
export class DuplicateMediaRequestError extends Error {}
|
export class DuplicateMediaRequestError extends Error {}
|
||||||
export class NoSeasonsAvailableError extends Error {}
|
export class NoSeasonsAvailableError extends Error {}
|
||||||
export class BlacklistedMediaError extends Error {}
|
|
||||||
|
|
||||||
type MediaRequestOptions = {
|
type MediaRequestOptions = {
|
||||||
isAutoRequest?: boolean;
|
isAutoRequest?: boolean;
|
||||||
@@ -59,7 +56,6 @@ export class MediaRequest {
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
let requestUser = user;
|
let requestUser = user;
|
||||||
|
|
||||||
@@ -147,16 +143,6 @@ export class MediaRequest {
|
|||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (media.status === MediaStatus.BLACKLISTED) {
|
|
||||||
logger.warn('Request for media blocked due to being blacklisted', {
|
|
||||||
tmdbId: tmdbMedia.id,
|
|
||||||
mediaType: requestBody.mediaType,
|
|
||||||
label: 'Media Request',
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new BlacklistedMediaError('This media is blacklisted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||||
media.status = MediaStatus.PENDING;
|
media.status = MediaStatus.PENDING;
|
||||||
}
|
}
|
||||||
@@ -208,134 +194,6 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply overrides if the user is not an admin or has the "advanced request" permission
|
|
||||||
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
|
|
||||||
type: 'or',
|
|
||||||
});
|
|
||||||
|
|
||||||
let rootFolder = requestBody.rootFolder;
|
|
||||||
let profileId = requestBody.profileId;
|
|
||||||
let tags = requestBody.tags;
|
|
||||||
|
|
||||||
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 overrideRules = await overrideRuleRepository.find({
|
|
||||||
where:
|
|
||||||
requestBody.mediaType === MediaType.MOVIE
|
|
||||||
? { radarrServiceId: defaultRadarrId }
|
|
||||||
: { sonarrServiceId: defaultSonarrId },
|
|
||||||
});
|
|
||||||
|
|
||||||
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 (
|
|
||||||
rule.users &&
|
|
||||||
!rule.users
|
|
||||||
.split(',')
|
|
||||||
.some((userId) => Number(userId) === requestUser.id)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rule.genre &&
|
|
||||||
!rule.genre
|
|
||||||
.split(',')
|
|
||||||
.some((genreId) =>
|
|
||||||
tmdbMedia.genres
|
|
||||||
.map((genre) => genre.id)
|
|
||||||
.includes(Number(genreId))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
rule.language &&
|
|
||||||
!rule.language
|
|
||||||
.split('|')
|
|
||||||
.some((languageId) => languageId === tmdbMedia.original_language)
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// hacky way to prioritize rules
|
|
||||||
// TODO: make this better
|
|
||||||
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
|
|
||||||
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
|
|
||||||
|
|
||||||
const aSpecificity = keys.filter((key) => a[key] !== null).length;
|
|
||||||
const bSpecificity = keys.filter((key) => b[key] !== null).length;
|
|
||||||
|
|
||||||
// Take the rule with the most specific condition first
|
|
||||||
return bSpecificity - aSpecificity;
|
|
||||||
})[0];
|
|
||||||
|
|
||||||
if (prioritizedRule) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestBody.mediaType === MediaType.MOVIE) {
|
if (requestBody.mediaType === MediaType.MOVIE) {
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
@@ -374,9 +232,9 @@ export class MediaRequest {
|
|||||||
: undefined,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: profileId,
|
profileId: requestBody.profileId,
|
||||||
rootFolder: rootFolder,
|
rootFolder: requestBody.rootFolder,
|
||||||
tags: tags,
|
tags: requestBody.tags,
|
||||||
isAutoRequest: options.isAutoRequest ?? false,
|
isAutoRequest: options.isAutoRequest ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -386,14 +244,12 @@ export class MediaRequest {
|
|||||||
const tmdbMediaShow = tmdbMedia as Awaited<
|
const tmdbMediaShow = tmdbMedia as Awaited<
|
||||||
ReturnType<typeof tmdb.getTvShow>
|
ReturnType<typeof tmdb.getTvShow>
|
||||||
>;
|
>;
|
||||||
let requestedSeasons =
|
const requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? 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
|
||||||
@@ -479,10 +335,10 @@ export class MediaRequest {
|
|||||||
: undefined,
|
: undefined,
|
||||||
is4k: requestBody.is4k,
|
is4k: requestBody.is4k,
|
||||||
serverId: requestBody.serverId,
|
serverId: requestBody.serverId,
|
||||||
profileId: profileId,
|
profileId: requestBody.profileId,
|
||||||
rootFolder: rootFolder,
|
rootFolder: requestBody.rootFolder,
|
||||||
languageProfileId: requestBody.languageProfileId,
|
languageProfileId: requestBody.languageProfileId,
|
||||||
tags: tags,
|
tags: requestBody.tags,
|
||||||
seasons: finalSeasons.map(
|
seasons: finalSeasons.map(
|
||||||
(sn) =>
|
(sn) =>
|
||||||
new SeasonRequest({
|
new SeasonRequest({
|
||||||
@@ -719,15 +575,10 @@ 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
|
|
||||||
) {
|
) {
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||||
|
mediaRepository.save(media);
|
||||||
await mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.PROCESSING }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -997,7 +848,7 @@ export class MediaRequest {
|
|||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
await requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -1010,14 +861,6 @@ 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',
|
||||||
@@ -1275,23 +1118,18 @@ export class MediaRequest {
|
|||||||
throw new Error('Media data not found');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateFields = {
|
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
sonarrSeries.id;
|
||||||
sonarrSeries.id,
|
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
sonarrSeries.titleSlug;
|
||||||
sonarrSeries.titleSlug,
|
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
|
||||||
[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);
|
||||||
|
|
||||||
await requestRepository.update(
|
this.status = MediaRequestStatus.FAILED;
|
||||||
{ id: this.id },
|
requestRepository.save(this);
|
||||||
{ 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',
|
||||||
@@ -1304,15 +1142,6 @@ 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',
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import {
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
|
||||||
class OverrideRule {
|
|
||||||
@PrimaryGeneratedColumn()
|
|
||||||
public id: number;
|
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: true })
|
|
||||||
public radarrServiceId?: number;
|
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: true })
|
|
||||||
public sonarrServiceId?: number;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public users?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public genre?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public language?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public keywords?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: true })
|
|
||||||
public profileId?: number;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public rootFolder?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public tags?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
|
||||||
public createdAt: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn()
|
|
||||||
public updatedAt: Date;
|
|
||||||
|
|
||||||
constructor(init?: Partial<OverrideRule>) {
|
|
||||||
Object.assign(this, init);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OverrideRule;
|
|
||||||
@@ -23,9 +23,7 @@ class Season {
|
|||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status4k: MediaStatus;
|
public status4k: MediaStatus;
|
||||||
|
|
||||||
@ManyToOne(() => Media, (media) => media.seasons, {
|
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
|
||||||
onDelete: 'CASCADE',
|
|
||||||
})
|
|
||||||
public media: Promise<Media>;
|
public media: Promise<Media>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ export class UserSettings {
|
|||||||
public locale?: string;
|
public locale?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public discoverRegion?: string;
|
public region?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public streamingRegion?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public originalLanguage?: string;
|
public originalLanguage?: string;
|
||||||
@@ -60,9 +57,6 @@ export class UserSettings {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramChatId?: string;
|
public telegramChatId?: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
public telegramMessageThreadId?: string;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public telegramSendSilently?: boolean;
|
public telegramSendSilently?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import { Session } from '@server/entity/Session';
|
import { Session } from '@server/entity/Session';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -19,11 +19,8 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import clearCookies from '@server/middleware/clearcookies';
|
import clearCookies from '@server/middleware/clearcookies';
|
||||||
import routes from '@server/routes';
|
import routes from '@server/routes';
|
||||||
import avatarproxy from '@server/routes/avatarproxy';
|
|
||||||
import imageproxy from '@server/routes/imageproxy';
|
import imageproxy from '@server/routes/imageproxy';
|
||||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
|
||||||
import restartFlag from '@server/utils/restartFlag';
|
import restartFlag from '@server/utils/restartFlag';
|
||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
@@ -53,12 +50,6 @@ const dev = process.env.NODE_ENV !== 'production';
|
|||||||
const app = next({ dev });
|
const app = next({ dev });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
if (!appDataPermissions()) {
|
|
||||||
logger.error(
|
|
||||||
'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
app
|
app
|
||||||
.prepare()
|
.prepare()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -66,24 +57,15 @@ app
|
|||||||
|
|
||||||
// Run migrations in production
|
// Run migrations in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
if (isPgsql) {
|
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
||||||
await dbConnection.runMigrations();
|
await dbConnection.runMigrations();
|
||||||
} else {
|
await dbConnection.query('PRAGMA foreign_keys=ON');
|
||||||
await dbConnection.query('PRAGMA foreign_keys=OFF');
|
|
||||||
await dbConnection.runMigrations();
|
|
||||||
await dbConnection.query('PRAGMA foreign_keys=ON');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = await getSettings().load();
|
const settings = getSettings();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
// Register HTTP proxy
|
|
||||||
if (settings.main.proxy.enabled) {
|
|
||||||
await createCustomProxyAgent(settings.main.proxy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
if (
|
if (
|
||||||
settings.plex.libraries.length > 1 &&
|
settings.plex.libraries.length > 1 &&
|
||||||
@@ -192,7 +174,7 @@ app
|
|||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
cleanupLimit: 2,
|
cleanupLimit: 2,
|
||||||
ttl: 60 * 60 * 24 * 30,
|
ttl: 1000 * 60 * 60 * 24 * 30,
|
||||||
}).connect(sessionRespository) as Store,
|
}).connect(sessionRespository) as Store,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -220,7 +202,6 @@ app
|
|||||||
|
|
||||||
// Do not set cookies so CDNs can cache them
|
// Do not set cookies so CDNs can cache them
|
||||||
server.use('/imageproxy', clearCookies, imageproxy);
|
server.use('/imageproxy', clearCookies, imageproxy);
|
||||||
server.use('/avatarproxy', clearCookies, avatarproxy);
|
|
||||||
|
|
||||||
server.get('*', (req, res) => handle(req, res));
|
server.get('*', (req, res) => handle(req, res));
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { User } from '@server/entity/User';
|
|
||||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
|
||||||
|
|
||||||
export interface BlacklistItem {
|
|
||||||
tmdbId: number;
|
|
||||||
mediaType: 'movie' | 'tv';
|
|
||||||
title?: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
|
||||||
results: BlacklistItem[];
|
|
||||||
}
|
|
||||||
@@ -8,16 +8,3 @@ interface PageInfo {
|
|||||||
export interface PaginatedResponse {
|
export interface PaginatedResponse {
|
||||||
pageInfo: PageInfo;
|
pageInfo: PageInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the keys of an object that are not functions
|
|
||||||
*/
|
|
||||||
type NonFunctionPropertyNames<T> = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
[K in keyof T]: T[K] extends Function ? never : K;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the properties of an object that are not functions
|
|
||||||
*/
|
|
||||||
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface GenreSliderItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WatchlistItem {
|
export interface WatchlistItem {
|
||||||
id: number;
|
|
||||||
ratingKey: string;
|
ratingKey: string;
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import type OverrideRule from '@server/entity/OverrideRule';
|
|
||||||
|
|
||||||
export type OverrideRuleResultsResponse = OverrideRule[];
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { MediaType } from '@server/constants/media';
|
import type { MediaType } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: NonFunctionProperties<MediaRequest>[];
|
results: MediaRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
@@ -14,7 +14,6 @@ export type MediaRequestBody = {
|
|||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
serverId?: number;
|
serverId?: number;
|
||||||
profileId?: number;
|
profileId?: number;
|
||||||
profileName?: string;
|
|
||||||
rootFolder?: string;
|
rootFolder?: string;
|
||||||
languageProfileId?: number;
|
languageProfileId?: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|||||||
@@ -32,12 +32,10 @@ export interface PublicSettingsResponse {
|
|||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
series4kEnabled: boolean;
|
series4kEnabled: boolean;
|
||||||
discoverRegion: string;
|
region: string;
|
||||||
streamingRegion: string;
|
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
|
||||||
cacheImages: boolean;
|
cacheImages: boolean;
|
||||||
vapidPublic: string;
|
vapidPublic: string;
|
||||||
enablePushRegistration: boolean;
|
enablePushRegistration: boolean;
|
||||||
@@ -60,7 +58,7 @@ export interface CacheItem {
|
|||||||
|
|
||||||
export interface CacheResponse {
|
export interface CacheResponse {
|
||||||
apiCaches: CacheItem[];
|
apiCaches: CacheItem[];
|
||||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusResponse {
|
export interface StatusResponse {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ export interface UserSettingsGeneralResponse {
|
|||||||
email?: string;
|
email?: string;
|
||||||
discordId?: string;
|
discordId?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
discoverRegion?: string;
|
region?: string;
|
||||||
streamingRegion?: string;
|
|
||||||
originalLanguage?: string;
|
originalLanguage?: string;
|
||||||
movieQuotaLimit?: number;
|
movieQuotaLimit?: number;
|
||||||
movieQuotaDays?: number;
|
movieQuotaDays?: number;
|
||||||
@@ -34,7 +33,6 @@ export interface UserSettingsNotificationsResponse {
|
|||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
telegramBotUsername?: string;
|
telegramBotUsername?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
telegramMessageThreadId?: string;
|
|
||||||
telegramSendSilently?: boolean;
|
telegramSendSilently?: boolean;
|
||||||
webPushEnabled?: boolean;
|
webPushEnabled?: boolean;
|
||||||
notificationTypes: Partial<NotificationAgentTypes>;
|
notificationTypes: Partial<NotificationAgentTypes>;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { MediaServerType } from '@server/constants/server';
|
|||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
import refreshToken from '@server/lib/refreshToken';
|
|
||||||
import {
|
import {
|
||||||
jellyfinFullScanner,
|
jellyfinFullScanner,
|
||||||
jellyfinRecentScanner,
|
jellyfinRecentScanner,
|
||||||
@@ -14,6 +13,7 @@ import type { JobId } from '@server/lib/settings';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import watchlistSync from '@server/lib/watchlistsync';
|
import watchlistSync from '@server/lib/watchlistsync';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import random from 'lodash/random';
|
||||||
import schedule from 'node-schedule';
|
import schedule from 'node-schedule';
|
||||||
|
|
||||||
interface ScheduledJob {
|
interface ScheduledJob {
|
||||||
@@ -113,20 +113,30 @@ export const startJobs = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watchlist Sync
|
// Watchlist Sync
|
||||||
scheduledJobs.push({
|
const watchlistSyncJob: ScheduledJob = {
|
||||||
id: 'plex-watchlist-sync',
|
id: 'plex-watchlist-sync',
|
||||||
name: 'Plex Watchlist Sync',
|
name: 'Plex Watchlist Sync',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'seconds',
|
interval: 'fixed',
|
||||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
|
||||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
watchlistSync.syncWatchlist();
|
watchlistSync.syncWatchlist();
|
||||||
}),
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
|
||||||
|
// after each run
|
||||||
|
watchlistSyncJob.job.on('run', () => {
|
||||||
|
watchlistSyncJob.job.schedule(
|
||||||
|
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push(watchlistSyncJob);
|
||||||
|
|
||||||
// Run full radarr scan every 24 hours
|
// Run full radarr scan every 24 hours
|
||||||
scheduledJobs.push({
|
scheduledJobs.push({
|
||||||
id: 'radarr-scan',
|
id: 'radarr-scan',
|
||||||
@@ -217,23 +227,6 @@ export const startJobs = (): void => {
|
|||||||
});
|
});
|
||||||
// Clean TMDB image cache
|
// Clean TMDB image cache
|
||||||
ImageProxy.clearCache('tmdb');
|
ImageProxy.clearCache('tmdb');
|
||||||
|
|
||||||
// Clean users avatar image cache
|
|
||||||
ImageProxy.clearCache('avatar');
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduledJobs.push({
|
|
||||||
id: 'plex-refresh-token',
|
|
||||||
name: 'Plex Refresh Token',
|
|
||||||
type: 'process',
|
|
||||||
interval: 'fixed',
|
|
||||||
cronSchedule: jobs['plex-refresh-token'].schedule,
|
|
||||||
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
|
|
||||||
logger.info('Starting scheduled job: Plex Refresh Token', {
|
|
||||||
label: 'Jobs',
|
|
||||||
});
|
|
||||||
refreshToken.run();
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
admin = await userRepository.findOne({
|
admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ class AvailabilitySync {
|
|||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ export type AvailableCacheIds =
|
|||||||
| 'imdb'
|
| 'imdb'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid'
|
| 'plexguid'
|
||||||
| 'plextv'
|
| 'plextv';
|
||||||
| 'plexwatchlist';
|
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -69,7 +68,6 @@ class CacheManager {
|
|||||||
stdTtl: 86400 * 7, // 1 week cache
|
stdTtl: 86400 * 7, // 1 week cache
|
||||||
checkPeriod: 60,
|
checkPeriod: 60,
|
||||||
}),
|
}),
|
||||||
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public getCache(id: AvailableCacheIds): Cache {
|
public getCache(id: AvailableCacheIds): Cache {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface DownloadingItem {
|
|||||||
timeLeft: string;
|
timeLeft: string;
|
||||||
estimatedCompletionTime: Date;
|
estimatedCompletionTime: Date;
|
||||||
title: string;
|
title: string;
|
||||||
downloadId: string;
|
|
||||||
episode?: EpisodeNumberResult;
|
episode?: EpisodeNumberResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ class DownloadTracker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await radarr.refreshMonitoredDownloads();
|
|
||||||
const queueItems = await radarr.getQueue();
|
const queueItems = await radarr.getQueue();
|
||||||
|
|
||||||
this.radarrServers[server.id] = queueItems.map((item) => ({
|
this.radarrServers[server.id] = queueItems.map((item) => ({
|
||||||
@@ -97,7 +95,6 @@ class DownloadTracker {
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
downloadId: item.downloadId,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
@@ -163,7 +160,6 @@ class DownloadTracker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sonarr.refreshMonitoredDownloads();
|
|
||||||
const queueItems = await sonarr.getQueue();
|
const queueItems = await sonarr.getQueue();
|
||||||
|
|
||||||
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
this.sonarrServers[server.id] = queueItems.map((item) => ({
|
||||||
@@ -176,7 +172,6 @@ class DownloadTracker {
|
|||||||
timeLeft: item.timeleft,
|
timeLeft: item.timeleft,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
episode: item.episode,
|
episode: item.episode,
|
||||||
downloadId: item.downloadId,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (queueItems.length > 0) {
|
if (queueItems.length > 0) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
|
|||||||
import rateLimit from '@server/utils/rateLimit';
|
import rateLimit from '@server/utils/rateLimit';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { promises } from 'fs';
|
import { promises } from 'fs';
|
||||||
import mime from 'mime/lite';
|
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
|
|
||||||
type ImageResponse = {
|
type ImageResponse = {
|
||||||
@@ -12,7 +11,7 @@ type ImageResponse = {
|
|||||||
curRevalidate: number;
|
curRevalidate: number;
|
||||||
isStale: boolean;
|
isStale: boolean;
|
||||||
etag: string;
|
etag: string;
|
||||||
extension: string | null;
|
extension: string;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
cacheMiss: boolean;
|
cacheMiss: boolean;
|
||||||
};
|
};
|
||||||
@@ -28,45 +27,29 @@ class ImageProxy {
|
|||||||
let deletedImages = 0;
|
let deletedImages = 0;
|
||||||
const cacheDirectory = path.join(baseCacheDirectory, key);
|
const cacheDirectory = path.join(baseCacheDirectory, key);
|
||||||
|
|
||||||
try {
|
const files = await promises.readdir(cacheDirectory);
|
||||||
const files = await promises.readdir(cacheDirectory);
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filePath = path.join(cacheDirectory, file);
|
const filePath = path.join(cacheDirectory, file);
|
||||||
const stat = await promises.lstat(filePath);
|
const stat = await promises.lstat(filePath);
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
const imageFiles = await promises.readdir(filePath);
|
const imageFiles = await promises.readdir(filePath);
|
||||||
|
|
||||||
for (const imageFile of imageFiles) {
|
for (const imageFile of imageFiles) {
|
||||||
const [, expireAtSt] = imageFile.split('.');
|
const [, expireAtSt] = imageFile.split('.');
|
||||||
const expireAt = Number(expireAtSt);
|
const expireAt = Number(expireAtSt);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now > expireAt) {
|
if (now > expireAt) {
|
||||||
await promises.rm(path.join(filePath), {
|
await promises.rm(path.join(filePath, imageFile));
|
||||||
recursive: true,
|
deletedImages += 1;
|
||||||
});
|
|
||||||
deletedImages += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
logger.error('Directory not found', {
|
|
||||||
label: 'Image Cache',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to read directory', {
|
|
||||||
label: 'Image Cache',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
|
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
|
||||||
label: 'Image Cache',
|
label: 'Image Cache',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,56 +69,39 @@ class ImageProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getDirectorySize(dir: string): Promise<number> {
|
private static async getDirectorySize(dir: string): Promise<number> {
|
||||||
try {
|
const files = await promises.readdir(dir, {
|
||||||
const files = await promises.readdir(dir, {
|
withFileTypes: true,
|
||||||
withFileTypes: true,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const paths = files.map(async (file) => {
|
const paths = files.map(async (file) => {
|
||||||
const path = join(dir, file.name);
|
const path = join(dir, file.name);
|
||||||
|
|
||||||
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
|
||||||
|
|
||||||
if (file.isFile()) {
|
if (file.isFile()) {
|
||||||
const { size } = await promises.stat(path);
|
const { size } = await promises.stat(path);
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (await Promise.all(paths))
|
|
||||||
.flat(Infinity)
|
|
||||||
.reduce((i, size) => i + size, 0);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(paths))
|
||||||
|
.flat(Infinity)
|
||||||
|
.reduce((i, size) => i + size, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getImageCount(dir: string) {
|
private static async getImageCount(dir: string) {
|
||||||
try {
|
const files = await promises.readdir(dir);
|
||||||
const files = await promises.readdir(dir);
|
|
||||||
|
|
||||||
return files.length;
|
return files.length;
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetch: typeof fetch;
|
private fetch: typeof fetch;
|
||||||
private cacheVersion;
|
private cacheVersion;
|
||||||
private key;
|
private key;
|
||||||
private baseUrl;
|
private baseUrl;
|
||||||
private headers: HeadersInit | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -143,7 +109,6 @@ class ImageProxy {
|
|||||||
options: {
|
options: {
|
||||||
cacheVersion?: number;
|
cacheVersion?: number;
|
||||||
rateLimitOptions?: RateLimitOptions;
|
rateLimitOptions?: RateLimitOptions;
|
||||||
headers?: HeadersInit;
|
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
this.cacheVersion = options.cacheVersion ?? 1;
|
this.cacheVersion = options.cacheVersion ?? 1;
|
||||||
@@ -157,13 +122,9 @@ class ImageProxy {
|
|||||||
} else {
|
} else {
|
||||||
this.fetch = fetch;
|
this.fetch = fetch;
|
||||||
}
|
}
|
||||||
this.headers = options.headers || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImage(
|
public async getImage(path: string): Promise<ImageResponse> {
|
||||||
path: string,
|
|
||||||
fallbackPath?: string
|
|
||||||
): Promise<ImageResponse> {
|
|
||||||
const cacheKey = this.getCacheKey(path);
|
const cacheKey = this.getCacheKey(path);
|
||||||
|
|
||||||
const imageResponse = await this.get(cacheKey);
|
const imageResponse = await this.get(cacheKey);
|
||||||
@@ -172,11 +133,7 @@ class ImageProxy {
|
|||||||
const newImage = await this.set(path, cacheKey);
|
const newImage = await this.set(path, cacheKey);
|
||||||
|
|
||||||
if (!newImage) {
|
if (!newImage) {
|
||||||
if (fallbackPath) {
|
throw new Error('Failed to load image');
|
||||||
return await this.getImage(fallbackPath);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to load image');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newImage;
|
return newImage;
|
||||||
@@ -190,27 +147,6 @@ class ImageProxy {
|
|||||||
return imageResponse;
|
return imageResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearCachedImage(path: string) {
|
|
||||||
// find cacheKey
|
|
||||||
const cacheKey = this.getCacheKey(path);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
||||||
const files = await promises.readdir(directory);
|
|
||||||
|
|
||||||
await promises.rm(directory, { recursive: true });
|
|
||||||
|
|
||||||
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
|
|
||||||
label: 'Image Cache',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to clear cached image', {
|
|
||||||
label: 'Image Cache',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
private async get(cacheKey: string): Promise<ImageResponse | null> {
|
||||||
try {
|
try {
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
@@ -251,30 +187,16 @@ class ImageProxy {
|
|||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
const href =
|
const href =
|
||||||
this.baseUrl +
|
this.baseUrl +
|
||||||
(this.baseUrl.length > 0
|
(this.baseUrl.endsWith('/') ? '' : '/') +
|
||||||
? this.baseUrl.endsWith('/')
|
|
||||||
? ''
|
|
||||||
: '/'
|
|
||||||
: '') +
|
|
||||||
(path.startsWith('/') ? path.slice(1) : path);
|
(path.startsWith('/') ? path.slice(1) : path);
|
||||||
const response = await this.fetch(href, {
|
const response = await this.fetch(href);
|
||||||
headers: this.headers || undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const extension = mime.getExtension(
|
const extension = path.split('.').pop() ?? '';
|
||||||
response.headers.get('content-type') ?? ''
|
const maxAge = Number(
|
||||||
);
|
|
||||||
|
|
||||||
let maxAge = Number(
|
|
||||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maxAge) maxAge = 86400;
|
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
@@ -310,7 +232,7 @@ class ImageProxy {
|
|||||||
|
|
||||||
private async writeToCacheDir(
|
private async writeToCacheDir(
|
||||||
dir: string,
|
dir: string,
|
||||||
extension: string | null,
|
extension: string,
|
||||||
maxAge: number,
|
maxAge: number,
|
||||||
expireAt: number,
|
expireAt: number,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
|||||||
@@ -291,11 +291,7 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.options.webhookRoleId) {
|
await fetch(settings.options.webhookUrl, {
|
||||||
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -309,25 +305,15 @@ class DiscordAgent
|
|||||||
content: userMentions.join(' '),
|
content: userMentions.join(' '),
|
||||||
} as DiscordWebhookPayload),
|
} as DiscordWebhookPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Discord notification', {
|
logger.error('Error sending Discord notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -132,32 +132,22 @@ class GotifyAgent
|
|||||||
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Gotify notification', {
|
logger.error('Error sending Gotify notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class LunaSeaAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: settings.options.profileName
|
headers: settings.options.profileName
|
||||||
? {
|
? {
|
||||||
@@ -114,25 +114,15 @@ class LunaSeaAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
body: JSON.stringify(this.buildPayload(type, payload)),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending LunaSea notification', {
|
logger.error('Error sending LunaSea notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -133,23 +133,13 @@ class PushbulletAgent
|
|||||||
channel_tag: settings.options.channelTag,
|
channel_tag: settings.options.channelTag,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -174,7 +164,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -182,24 +172,14 @@ class PushbulletAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -235,7 +215,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -243,24 +223,14 @@ class PushbulletAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class PushoverAgent
|
|||||||
): Promise<Partial<PushoverImagePayload>> {
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(imageUrl);
|
const response = await fetch(imageUrl);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||||
const contentType = (
|
const contentType = (
|
||||||
@@ -67,17 +64,10 @@ class PushoverAgent
|
|||||||
attachment_type: contentType,
|
attachment_type: contentType,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error getting image payload', {
|
logger.error('Error getting image payload', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -210,7 +200,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -222,23 +212,13 @@ class PushoverAgent
|
|||||||
sound: settings.options.sound,
|
sound: settings.options.sound,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -266,7 +246,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -278,24 +258,14 @@ class PushoverAgent
|
|||||||
sound: payload.notifyUser.settings.pushoverSound,
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -332,7 +302,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -343,24 +313,14 @@ class PushoverAgent
|
|||||||
user: user.settings.pushoverUserKey,
|
user: user.settings.pushoverUserKey,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -237,32 +237,22 @@ class SlackAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildEmbed(type, payload)),
|
body: JSON.stringify(this.buildEmbed(type, payload)),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Slack notification', {
|
logger.error('Error sending Slack notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ interface TelegramMessagePayload {
|
|||||||
text: string;
|
text: string;
|
||||||
parse_mode: string;
|
parse_mode: string;
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
message_thread_id: string;
|
|
||||||
disable_notification: boolean;
|
disable_notification: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ interface TelegramPhotoPayload {
|
|||||||
caption: string;
|
caption: string;
|
||||||
parse_mode: string;
|
parse_mode: string;
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
message_thread_id: string;
|
|
||||||
disable_notification: boolean;
|
disable_notification: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +174,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -184,27 +182,16 @@ class TelegramAgent
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
chat_id: settings.options.chatId,
|
chat_id: settings.options.chatId,
|
||||||
message_thread_id: settings.options.messageThreadId,
|
|
||||||
disable_notification: !!settings.options.sendSilently,
|
disable_notification: !!settings.options.sendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -228,7 +215,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -236,30 +223,18 @@ class TelegramAgent
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||||
message_thread_id:
|
|
||||||
payload.notifyUser.settings.telegramMessageThreadId,
|
|
||||||
disable_notification:
|
disable_notification:
|
||||||
!!payload.notifyUser.settings.telegramSendSilently,
|
!!payload.notifyUser.settings.telegramSendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -293,7 +268,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -301,28 +276,17 @@ class TelegramAgent
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...notificationPayload,
|
...notificationPayload,
|
||||||
chat_id: user.settings.telegramChatId,
|
chat_id: user.settings.telegramChatId,
|
||||||
message_thread_id: user.settings.telegramMessageThreadId,
|
|
||||||
disable_notification: !!user.settings?.telegramSendSilently,
|
disable_notification: !!user.settings?.telegramSendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class WebhookAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -187,25 +187,15 @@ class WebhookAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
body: JSON.stringify(this.buildPayload(type, payload)),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending webhook notification', {
|
logger.error('Error sending webhook notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ export enum Permission {
|
|||||||
AUTO_REQUEST_TV = 33554432,
|
AUTO_REQUEST_TV = 33554432,
|
||||||
RECENT_VIEW = 67108864,
|
RECENT_VIEW = 67108864,
|
||||||
WATCHLIST_VIEW = 134217728,
|
WATCHLIST_VIEW = 134217728,
|
||||||
MANAGE_BLACKLIST = 268435456,
|
|
||||||
VIEW_BLACKLIST = 1073741824,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import PlexTvAPI from '@server/api/plextv';
|
|
||||||
import { getRepository } from '@server/datasource';
|
|
||||||
import { User } from '@server/entity/User';
|
|
||||||
import logger from '@server/logger';
|
|
||||||
|
|
||||||
class RefreshToken {
|
|
||||||
public async run() {
|
|
||||||
const userRepository = getRepository(User);
|
|
||||||
|
|
||||||
const users = await userRepository
|
|
||||||
.createQueryBuilder('user')
|
|
||||||
.addSelect('user.plexToken')
|
|
||||||
.where("user.plexToken != ''")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
await this.refreshUserToken(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshUserToken(user: User) {
|
|
||||||
if (!user.plexToken) {
|
|
||||||
logger.warn('Skipping user refresh token for user without plex token', {
|
|
||||||
label: 'Plex Refresh Token',
|
|
||||||
user: user.displayName,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plexTvApi = new PlexTvAPI(user.plexToken);
|
|
||||||
plexTvApi.pingToken();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshToken = new RefreshToken();
|
|
||||||
|
|
||||||
export default refreshToken;
|
|
||||||
@@ -210,27 +210,14 @@ class JellyfinScanner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.ProviderIds.Tmdb) {
|
if (metadata.ProviderIds.Tvdb) {
|
||||||
try {
|
tvShow = await this.tmdb.getShowByTvdbId({
|
||||||
tvShow = await this.tmdb.getTvShow({
|
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||||
tvId: Number(metadata.ProviderIds.Tmdb),
|
});
|
||||||
});
|
} else if (metadata.ProviderIds.Tmdb) {
|
||||||
} catch {
|
tvShow = await this.tmdb.getTvShow({
|
||||||
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
tvId: Number(metadata.ProviderIds.Tmdb),
|
||||||
jellyfinitem,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
|
||||||
try {
|
|
||||||
tvShow = await this.tmdb.getShowByTvdbId({
|
|
||||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
this.log('Unable to find TVDb ID for this title.', 'debug', {
|
|
||||||
jellyfinitem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tvShow) {
|
if (tvShow) {
|
||||||
@@ -504,13 +491,7 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.log(
|
this.log(`failed show: ${metadata.Name}`);
|
||||||
`No information found for the show: ${metadata.Name}`,
|
|
||||||
'debug',
|
|
||||||
{
|
|
||||||
jellyfinitem,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log(
|
this.log(
|
||||||
@@ -586,10 +567,7 @@ class JellyfinScanner {
|
|||||||
public async run(): Promise<void> {
|
public async run(): Promise<void> {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
if (
|
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
|
||||||
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
|
||||||
settings.main.mediaServerType != MediaServerType.EMBY
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +582,12 @@ class JellyfinScanner {
|
|||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -614,7 +597,7 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class PlexScanner
|
|||||||
});
|
});
|
||||||
|
|
||||||
settings.plex.libraries = newLibraries;
|
settings.plex.libraries = newLibraries;
|
||||||
await settings.save();
|
settings.save();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const library of this.libraries) {
|
for (const library of this.libraries) {
|
||||||
@@ -277,11 +277,8 @@ class PlexScanner
|
|||||||
|
|
||||||
const seasons = tvShow.seasons;
|
const seasons = tvShow.seasons;
|
||||||
const processableSeasons: ProcessableSeason[] = [];
|
const processableSeasons: ProcessableSeason[] = [];
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
const filteredSeasons = settings.main.enableSpecialEpisodes
|
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
|
||||||
? seasons
|
|
||||||
: seasons.filter((sn) => sn.season_number !== 0);
|
|
||||||
|
|
||||||
for (const season of filteredSeasons) {
|
for (const season of filteredSeasons) {
|
||||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||||
|
|||||||
@@ -102,12 +102,11 @@ class SonarrScanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tmdbId = tvShow.id;
|
const tmdbId = tvShow.id;
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||||
(sn) =>
|
(sn) =>
|
||||||
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) &&
|
sn.seasonNumber !== 0 &&
|
||||||
(!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true)
|
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const season of filteredSeasons) {
|
for (const season of filteredSeasons) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user