Compare commits

..

1 Commits

Author SHA1 Message Date
Gauthier
acc3599f1b perf: try to improve performance by removing rate limit 2024-11-06 17:00:30 +01:00
379 changed files with 10489 additions and 24702 deletions

View File

@@ -7,7 +7,7 @@
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
"contributorsPerLine": 7,
"projectName": "jellyseerr",
"projectOwner": "fallenbagel",
"projectOwner": "Fallenbagel",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true,
@@ -94,8 +94,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
"profile": "https://github.com/jab416171",
"contributions": [
"doc",
"code"
"doc"
]
},
{
@@ -249,8 +248,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
"profile": "http://www.piribisoft.com",
"contributions": [
"doc",
"code"
"doc"
]
},
{
@@ -277,8 +275,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
"profile": "https://athfan.com",
"contributions": [
"doc",
"code"
"doc"
]
},
{
@@ -296,8 +293,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
"profile": "https://github.com/xeruf",
"contributions": [
"doc",
"code"
"doc"
]
},
{
@@ -342,8 +338,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code",
"maintenance"
"code"
]
},
{
@@ -382,6 +377,33 @@
"code"
]
},
{
"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": "mobihen",
"name": "Nir Israel Hen",
@@ -426,222 +448,6 @@
"contributions": [
"security"
]
},
{
"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"
]
},
{
"login": "GkhnGRBZ",
"name": "GkhnGRBZ",
"avatar_url": "https://avatars.githubusercontent.com/u/127258824?v=4",
"profile": "https://github.com/GkhnGRBZ",
"contributions": [
"code"
]
},
{
"login": "benhaney",
"name": "Ben Haney",
"avatar_url": "https://avatars.githubusercontent.com/u/31331498?v=4",
"profile": "http://benhaney.com",
"contributions": [
"code"
]
},
{
"login": "Wunderharke",
"name": "Wunderharke",
"avatar_url": "https://avatars.githubusercontent.com/u/5105672?v=4",
"profile": "https://github.com/Wunderharke",
"contributions": [
"doc"
]
},
{
"login": "methbkts",
"name": "Metin Bektas",
"avatar_url": "https://avatars.githubusercontent.com/u/30674934?v=4",
"profile": "https://github.com/methbkts",
"contributions": [
"infra"
]
},
{
"login": "andrewkolda",
"name": "andrewkolda",
"avatar_url": "https://avatars.githubusercontent.com/u/158614532?v=4",
"profile": "https://github.com/andrewkolda",
"contributions": [
"design"
]
},
{
"login": "ishanjain28",
"name": "Ishan Jain",
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
"profile": "https://ishanjain.me",
"contributions": [
"code"
]
},
{
"login": "michaelhthomas",
"name": "Michael Thomas",
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
"profile": "http://michaelt.xyz",
"contributions": [
"code"
]
},
{
"login": "RankWeis",
"name": "RankWeis",
"avatar_url": "https://avatars.githubusercontent.com/u/733691?v=4",
"profile": "https://github.com/RankWeis",
"contributions": [
"code"
]
},
{
"login": "jessielw",
"name": "Jessie Wilson",
"avatar_url": "https://avatars.githubusercontent.com/u/48299282?v=4",
"profile": "http://www.linkedin.com/in/jessielwilson",
"contributions": [
"code"
]
},
{
"login": "brotaxt",
"name": "DominicKo",
"avatar_url": "https://avatars.githubusercontent.com/u/25477935?v=4",
"profile": "https://github.com/brotaxt",
"contributions": [
"code"
]
},
{
"login": "corentinnormand",
"name": "Corentin Normand",
"avatar_url": "https://avatars.githubusercontent.com/u/30508927?v=4",
"profile": "https://doctolib.com",
"contributions": [
"code"
]
},
{
"login": "benbeauchamp7",
"name": "Ben Beauchamp",
"avatar_url": "https://avatars.githubusercontent.com/u/43358492?v=4",
"profile": "https://github.com/benbeauchamp7",
"contributions": [
"code"
]
},
{
"login": "vfaergestad",
"name": "vfaergestad",
"avatar_url": "https://avatars.githubusercontent.com/u/49147564?v=4",
"profile": "https://github.com/vfaergestad",
"contributions": [
"code"
]
},
{
"login": "wolffman122",
"name": "wolffman122",
"avatar_url": "https://avatars.githubusercontent.com/u/19178872?v=4",
"profile": "https://github.com/wolffman122",
"contributions": [
"code"
]
},
{
"login": "Schrottfresser",
"name": "Schrottfresser",
"avatar_url": "https://avatars.githubusercontent.com/u/39998368?v=4",
"profile": "https://github.com/Schrottfresser",
"contributions": [
"code"
]
},
{
"login": "DillionLowry",
"name": "Dillion",
"avatar_url": "https://avatars.githubusercontent.com/u/91228469?v=4",
"profile": "https://github.com/DillionLowry",
"contributions": [
"code"
]
},
{
"login": "JamsRepos",
"name": "Jam",
"avatar_url": "https://avatars.githubusercontent.com/u/1347620?v=4",
"profile": "https://github.com/JamsRepos",
"contributions": [
"code"
]
},
{
"login": "joelowrance",
"name": "Joe Lowrance",
"avatar_url": "https://avatars.githubusercontent.com/u/63176?v=4",
"profile": "http://www.joelowrance.com",
"contributions": [
"code"
]
},
{
"login": "0xSysR3ll",
"name": "0xsysr3ll",
"avatar_url": "https://avatars.githubusercontent.com/u/31414959?v=4",
"profile": "https://github.com/0xSysR3ll",
"contributions": [
"code"
]
}
]
}

View File

@@ -55,16 +55,6 @@ body:
- tablet
validations:
required: true
- type: dropdown
id: database
attributes:
options:
- SQLite (default)
- PostgreSQL
label: Database
description: Which database backend are you using?
validations:
required: true
- type: input
id: device
attributes:

View File

@@ -12,8 +12,8 @@ jobs:
test:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22-alpine
runs-on: ubuntu-22.04
container: node:20-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -43,23 +43,15 @@ jobs:
- name: Build
run: pnpm build
build:
build_and_push:
name: Build & Publish Docker Images
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
strategy:
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
outputs:
digest-amd64: ${{ steps.set_outputs.outputs.digest-amd64 }}
digest-arm64: ${{ steps.set_outputs.outputs.digest-arm64 }}
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -78,79 +70,24 @@ jobs:
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
env:
OWNER: ${{ github.repository_owner }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
fallenbagel/jellyseerr
ghcr.io/${{ env.OWNER_LC }}/jellyseerr
tags: |
type=ref,event=branch
type=sha,prefix=,suffix=,format=short
- name: Build and push by digest
id: build
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
platforms: linux/amd64,linux/arm64
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
BUILD_VERSION=develop
BUILD_DATE=${{ github.event.repository.updated_at }}
outputs: |
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
provenance: false
- name: Set outputs
id: set_outputs
run: |
platform="${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}"
echo "digest-${platform}=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT
merge_and_push:
name: Create and Push Multi-arch Manifest
needs: build
runs-on: ubuntu-24.04
steps:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lower case owner name
run: |
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
env:
OWNER: ${{ github.repository_owner }}
- name: Create and push manifest
run: |
docker manifest create fallenbagel/jellyseerr:develop \
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
docker manifest push fallenbagel/jellyseerr:develop
# GHCR manifest
docker manifest create ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop \
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
docker manifest push ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
tags: |
fallenbagel/jellyseerr:develop
ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
discord:
name: Send Discord Notification
needs: merge_and_push
needs: build_and_push
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3

View File

@@ -17,7 +17,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
@@ -36,11 +36,3 @@ jobs:
# Fix test titles in cypress dashboard
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
- name: Upload video files
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-videos
path: |
cypress/videos
cypress/screenshots

View File

@@ -1,135 +0,0 @@
name: Release Charts
on:
push:
branches:
- develop
jobs:
package-helm-chart:
name: Package helm chart
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install helm
uses: azure/setup-helm@v4
- name: Install Oras
uses: oras-project/setup-oras@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Package helm charts
run: |
mkdir -p ./.cr-release-packages
for chart_path in ./charts/*; do
if [ -d "$chart_path" ] && [ -f "$chart_path/Chart.yaml" ]; then
chart_name=$(grep '^name:' "$chart_path/Chart.yaml" | awk '{print $2}')
# get current version
current_version=$(grep '^version:' "$chart_path/Chart.yaml" | awk '{print $2}')
# try to get current release version
set +e
oras discover ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:${current_version}
oras_exit_code=$?
set -e
if [ $oras_exit_code -ne 0 ]; then
helm dependency build "$chart_path"
helm package "$chart_path" --destination ./.cr-release-packages
else
echo "No version change for $chart_name. Skipping."
fi
else
echo "Skipping $chart_name: Not a valid Helm chart"
fi
done
- name: Check if artifacts exist
id: check-artifacts
run: |
if ls .cr-release-packages/* >/dev/null 2>&1; then
echo "has_artifacts=true" >> $GITHUB_OUTPUT
else
echo "has_artifacts=false" >> $GITHUB_OUTPUT
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
if: steps.check-artifacts.outputs.has_artifacts == 'true'
with:
name: artifacts
include-hidden-files: true
path: .cr-release-packages/
publish:
name: Publish to ghcr.io
runs-on: ubuntu-latest
permissions:
packages: write # needed for pushing to github registry
id-token: write # needed for signing the images with GitHub OIDC Token
needs: [package-helm-chart]
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install helm
uses: azure/setup-helm@v4
- name: Install Oras
uses: oras-project/setup-oras@v1
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Downloads artifacts
uses: actions/download-artifact@v4
with:
name: artifacts
path: .cr-release-packages/
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push charts to GHCR
env:
COSIGN_YES: true
run: |
for chart_path in `find .cr-release-packages -name '*.tgz' -print`; do
# push chart to OCI
chart_release_file=$(basename "$chart_path")
chart_name=${chart_release_file%-*}
helm push ${chart_path} oci://ghcr.io/${GITHUB_REPOSITORY@L} |& tee helm-push-output.log
chart_digest=$(awk -F "[, ]+" '/Digest/{print $NF}' < helm-push-output.log)
# sign chart
cosign sign "ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}@${chart_digest}"
# push artifacthub-repo.yml to OCI
oras push \
ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:artifacthub.io \
--config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \
charts/$chart_name/artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml \
|& tee oras-push-output.log
artifacthub_digest=$(grep "Digest:" oras-push-output.log | awk '{print $2}')
# sign artifacthub-repo.yml
cosign sign "ghcr.io/${GITHUB_REPOSITORY@L}/${chart_name}:artifacthub.io@${artifacthub_digest}"
done

View File

@@ -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

View File

@@ -33,7 +33,5 @@ jobs:
push: true
build-args: |
COMMIT_TAG=${{ github.sha }}
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
BUILD_DATE=${{ github.event.repository.updated_at }}
tags: |
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 20
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
@@ -26,12 +26,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GH_TOKEN }}
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:

View File

@@ -4,7 +4,7 @@ on:
pull_request:
branches:
- develop
paths:
path:
- 'docs/**'
- 'gen-docs/**'

View File

@@ -9,6 +9,3 @@ pnpm-lock.yaml
src/assets/
public/
docs/
# helm charts
**/charts

View File

@@ -15,11 +15,5 @@ module.exports = {
rangeEnd: 0, // default: Infinity
},
},
{
files: 'charts/**',
options: {
rangeEnd: 0, // default: Infinity
},
},
],
};

View File

@@ -19,6 +19,5 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
},
"i18n-ally.localesPaths": ["src/i18n/locale"]
}
}

View File

@@ -8,7 +8,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- HTML/Typescript/Javascript editor
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
- [NodeJS](https://nodejs.org/en/download/) (Node 22.x)
- [NodeJS](https://nodejs.org/en/download/) (Node 20.x)
- [Pnpm](https://pnpm.io/cli/install)
- [Git](https://git-scm.com/downloads)
@@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm install
pnpm
pnpm dev
```
@@ -58,27 +58,12 @@ All help is welcome and greatly appreciated! If you would like to contribute to
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
- Should you need to update your fork, you can do so by rebasing from `upstream`:
```bash
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
### Helm Chart
Tools Required:
- [Helm](https://helm.sh/docs/intro/install/)
- [helm-docs](https://github.com/norwoodj/helm-docs)
Steps:
1. Make the necessary changes.
2. Test your changes.
3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/).
4. Run the `helm-docs` command to regenerate the chart's README.
### Contributing Code
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
@@ -112,50 +97,10 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Jellyseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
<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
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.

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine AS BUILD_IMAGE
FROM node:20-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -14,7 +14,7 @@ RUN \
;; \
esac
RUN npm install --global pnpm@9
RUN npm install --global pnpm
COPY package.json pnpm-lock.yaml postinstall-win.js ./
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
@@ -29,32 +29,23 @@ RUN pnpm build
# remove development dependencies
RUN pnpm prune --prod --ignore-scripts
RUN rm -rf src server .next/cache charts gen-docs docs
RUN rm -rf src server .next/cache
RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:22-alpine
FROM node:20-alpine
# OCI Meta information
ARG BUILD_DATE
ARG BUILD_VERSION
LABEL \
org.opencontainers.image.authors="Fallenbagel" \
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
org.opencontainers.image.created=${BUILD_DATE} \
org.opencontainers.image.version=${BUILD_VERSION} \
org.opencontainers.image.title="Jellyseerr" \
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
org.opencontainers.image.licenses="MIT"
# Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
WORKDIR /app
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
RUN npm install -g pnpm@9
RUN npm install -g pnpm
# copy from build image
COPY --from=BUILD_IMAGE /app ./

View File

@@ -1,9 +1,9 @@
FROM node:22-alpine
FROM node:20-alpine
COPY . /app
WORKDIR /app
RUN npm install --global pnpm@9
Run npm install --global pnpm
RUN pnpm install

129
README.md
View File

@@ -11,17 +11,17 @@
<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="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 -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-69-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-48-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
**Jellyseerr** is a free and open source software application for managing requests for your media library.
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
## Current Features
- Full Jellyfin/Emby/Plex integration including authentication with user import & management.
- Support for **PostgreSQL** and **SQLite** databases.
- Supports Movies, Shows and Mixed Libraries.
- Ability to change email addresses for SMTP purposes.
- Full Jellyfin/Emby/Plex integration including authentication with user import & management
- Supports Movies, Shows and Mixed Libraries
- Ability to change email addresses for smtp purposes
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come!
- Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available.
- Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
@@ -29,7 +29,8 @@
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
- Support for watchlisting & blacklisting media.
(Upcoming Features include: Multiple Server Instances, and much more!)
With more features on the way! Check out our [issue tracker](https://github.com/fallenbagel/jellyseerr/issues) to see the features which have already been requested.
@@ -86,93 +87,66 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</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://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/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>
</tr>
<tr>
<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/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="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>
<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>
</tr>
<tr>
<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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.linkedin.com/in/jessielwilson"><img src="https://avatars.githubusercontent.com/u/48299282?v=4?s=100" width="100px;" alt="Jessie Wilson"/><br /><sub><b>Jessie Wilson</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jessielw" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brotaxt"><img src="https://avatars.githubusercontent.com/u/25477935?v=4?s=100" width="100px;" alt="DominicKo"/><br /><sub><b>DominicKo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=brotaxt" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://doctolib.com"><img src="https://avatars.githubusercontent.com/u/30508927?v=4?s=100" width="100px;" alt="Corentin Normand"/><br /><sub><b>Corentin Normand</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=corentinnormand" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benbeauchamp7"><img src="https://avatars.githubusercontent.com/u/43358492?v=4?s=100" width="100px;" alt="Ben Beauchamp"/><br /><sub><b>Ben Beauchamp</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benbeauchamp7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=vfaergestad" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wolffman122"><img src="https://avatars.githubusercontent.com/u/19178872?v=4?s=100" width="100px;" alt="wolffman122"/><br /><sub><b>wolffman122</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=wolffman122" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Schrottfresser"><img src="https://avatars.githubusercontent.com/u/39998368?v=4?s=100" width="100px;" alt="Schrottfresser"/><br /><sub><b>Schrottfresser</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Schrottfresser" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DillionLowry"><img src="https://avatars.githubusercontent.com/u/91228469?v=4?s=100" width="100px;" alt="Dillion"/><br /><sub><b>Dillion</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DillionLowry" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JamsRepos"><img src="https://avatars.githubusercontent.com/u/1347620?v=4?s=100" width="100px;" alt="Jam"/><br /><sub><b>Jam</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JamsRepos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.joelowrance.com"><img src="https://avatars.githubusercontent.com/u/63176?v=4?s=100" width="100px;" alt="Joe Lowrance"/><br /><sub><b>Joe Lowrance</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joelowrance" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xSysR3ll"><img src="https://avatars.githubusercontent.com/u/31414959?v=4?s=100" width="100px;" alt="0xsysr3ll"/><br /><sub><b>0xsysr3ll</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=0xSysR3ll" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@@ -290,7 +264,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
</tr>
@@ -307,7 +281,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Jellyseerr">🪼⌨️</a> <a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
@@ -317,17 +291,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="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/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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=vfaergestad" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -1,25 +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/
# go template
*.gotmpl

View File

@@ -1,13 +0,0 @@
apiVersion: v2
kubeVersion: ">=1.23.0-0"
name: jellyseerr-chart
description: Jellyseerr helm chart for Kubernetes
type: application
version: 2.5.0
appVersion: "2.6.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

View File

@@ -1,70 +0,0 @@
# jellyseerr-chart
![Version: 2.5.0](https://img.shields.io/badge/Version-2.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 2.6.0](https://img.shields.io/badge/AppVersion-2.6.0-informational?style=flat-square)
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 | `{}` | |
| 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 | `"ghcr.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 | `{}` | |
| probes.livenessProbe | object | `{}` | Configure liveness probe |
| probes.readinessProbe | object | `{}` | Configure readiness probe |
| probes.startupProbe | string | `nil` | Configure startup probe |
| 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 | `[]` | |
| volumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. |
| volumes | list | `[]` | Additional volumes on the output Deployment definition. |

View File

@@ -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" . }}

View File

@@ -1 +0,0 @@
repositoryID: c6b3f2dc-444c-4e37-b397-6a5ff563ee8b

View File

@@ -1,5 +0,0 @@
***********************************************************************
Welcome to {{ .Chart.Name }}
Chart version: {{ .Chart.Version }}
App version: {{ .Chart.AppVersion }}
***********************************************************************

View File

@@ -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 }}

View File

@@ -1,123 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyseerr.fullname" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
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
{{- if .Values.probes.livenessProbe.initialDelaySeconds }}
initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }}
{{- end }}
{{- if .Values.probes.livenessProbe.periodSeconds }}
periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }}
{{- end }}
{{- if .Values.probes.livenessProbe.timeoutSeconds }}
timeoutSeconds: {{ .Values.probes.livenessProbe.timeoutSeconds }}
{{- end }}
{{- if .Values.probes.livenessProbe.successThreshold }}
successThreshold: {{ .Values.probes.livenessProbe.successThreshold }}
{{- end }}
{{- if .Values.probes.livenessProbe.failureThreshold }}
failureThreshold: {{ .Values.probes.livenessProbe.failureThreshold }}
{{- end }}
readinessProbe:
httpGet:
path: /
port: http
{{- if .Values.probes.readinessProbe.initialDelaySeconds }}
initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }}
{{- end }}
{{- if .Values.probes.readinessProbe.periodSeconds }}
periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }}
{{- end }}
{{- if .Values.probes.readinessProbe.timeoutSeconds }}
timeoutSeconds: {{ .Values.probes.readinessProbe.timeoutSeconds }}
{{- end }}
{{- if .Values.probes.readinessProbe.successThreshold }}
successThreshold: {{ .Values.probes.readinessProbe.successThreshold }}
{{- end }}
{{- if .Values.probes.readinessProbe.failureThreshold }}
failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold }}
{{- end }}
{{- if .Values.probes.startupProbe }}
startupProbe:
{{- toYaml .Values.probes.startupProbe | nindent 12 }}
{{- end }}
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
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: config
persistentVolumeClaim:
claimName: {{ include "jellyseerr.configPersistenceName" . }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -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 }}

View File

@@ -1,24 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyseerr.configPersistenceName" . }}
labels:
{{- include "jellyseerr.labels" . | nindent 4 }}
{{- with .Values.config.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
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 }}"

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -1,135 +0,0 @@
replicaCount: 1
image:
registry: ghcr.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
# Liveness / Readiness / Startup Probes
probes:
# -- Configure liveness probe
livenessProbe: {}
# initialDelaySeconds: 60
# periodSeconds: 30
# timeoutSeconds: 5
# successThreshold: 1
# failureThreshold: 5
# -- Configure readiness probe
readinessProbe: {}
# initialDelaySeconds: 60
# periodSeconds: 30
# timeoutSeconds: 5
# successThreshold: 1
# failureThreshold: 5
# -- Configure startup probe
startupProbe: null
# tcpSocket:
# port: http
# -- 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
# -- Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# -- Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -4,7 +4,6 @@ export default defineConfig({
projectId: 'xkm1b4',
e2e: {
baseUrl: 'http://localhost:5055',
video: true,
experimentalSessionAndOrigin: true,
},
env: {

View File

@@ -4,7 +4,7 @@
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {
"apiKey": "testkey",
"applicationTitle": "Jellyseerr",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
@@ -16,15 +16,11 @@
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"discoverRegion": "",
"streamingRegion": "",
"region": "",
"originalLanguage": "",
"blacklistedTags": "",
"blacklistedTagsLimit": 50,
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
"enableSpecialEpisodes": false,
"locale": "en"
},
"plex": {
@@ -71,7 +67,7 @@
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Jellyseerr"
"senderName": "Overseerr"
}
},
"discord": {
@@ -79,7 +75,6 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},
@@ -103,7 +98,6 @@
"options": {
"botAPI": "",
"chatId": "",
"messageThreadId": "",
"sendSilently": false
}
},
@@ -139,16 +133,7 @@
"types": 0,
"options": {
"url": "",
"token": "",
"priority": 0
}
},
"ntfy": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"topic": ""
"token": ""
}
}
}

View File

@@ -13,10 +13,10 @@ describe('General Settings', () => {
});
it('modifies setting that requires restart', () => {
cy.visit('/settings/network');
cy.visit('/settings');
cy.get('#trustProxy').click();
cy.get('[data-testid=settings-network-form]').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should(
'contain',
'Server Restart Required'
@@ -26,7 +26,7 @@ describe('General Settings', () => {
cy.get('[data-testid=modal-title]').should('not.exist');
cy.get('[type=checkbox]#trustProxy').click();
cy.get('[data-testid=settings-network-form]').submit();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('[data-testid=modal-title]').should('not.exist');
});
});

View File

@@ -6,6 +6,7 @@ Cypress.Commands.add('login', (email, password) => {
[email, password],
() => {
cy.visit('/login');
cy.contains('Use your Overseerr account').click();
cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password);

View File

@@ -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:

View File

@@ -7,34 +7,30 @@ sidebar_position: 1
Welcome to the Jellyseerr Documentation.
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
## Features
- **Full Jellyfin/Emby/Plex integration**. Login and manage user access with Jellyfin/Emby/Plex.
- **Syncs to your Jellyfin/Emby/Plex library** to show what titles you already have.
- Supports Movies, Shows and Mixed Libraries.
- **Integrates with Sonarr and Radarr**. With more services to come in the future.
- Optionally set **Override rules** for requests to match with your defined conditions.
- **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI.
- **Simple request management UI**. Don't dig through the app to approve recent requests.
- **Mobile-friendly design**, for when you need to approve requests on the go.
- Granular permission system.
- Localization into other languages.
- Support for **PostgreSQL** and **SQLite** databases.
- Support for various notification agents.
- Easily **Watchlist** or **Blacklist** media.
- More features to come!
## Motivation
The primary motivation for starting Jellyseerr was to bring Jellyfin and Emby support to Overseerr. However, over time, **Jellyseerr** has evolved into its own distinct application with unique features. Designed as a one-stop shop for media requests, it offers a simple, easy-to-use experience for managing requests on Jellyfin, Emby, and Plex servers.
The primary motivation for starting this project was to add support for Jellyfin and Emby to Overseerr. As Overseerr is an incredibly performant and easy-to-use application, we wanted to bring that same experience to Jellyfin and Emby users. Thus, **Jellyseerr** was born.
This application is designed to be a **one-stop-shop** for all your media requests. It is designed to be a **simple, easy-to-use** application that allows users to request media to be added to your Jellyfin/Emby/Plex server.
## We need your help!
[Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is an ambitious project where developers/contributors poured a lot of work into, and that builds on top of [Overseerr](https://github.com/sct/overseerr). And we have a lot more to do as well.
[Jellyseerr](https://github.com/Fallenbagel/jellyseerr) is a fork of Overseerr, with a heavy focus on Jellyfin and Emby integration.
[Overseerr](https://github.com/sct/overseerr) is an ambitious project where the original developers/contributors have already poured a lot of work into, and we wanted to build on top of that.
We value your feedback and support in identifying and fixing bugs to make Jellyseerr even better. As an open-source project, we welcome contributions from everyone. While Jellyseerr has diverged from Overseerr and evolved into its own unique application, we still encourage contributions to Overseerr, as it played a crucial role in inspiring what Jellyseerr has become today.
We also have poured a lot of work into this project, and we have a lot more to do as well. We need your valuable feedback and help to find and fix bugs. Also, with Jellyseerr being an open-source project, anyone is welcome to contribute. We also encourage you to contribute to Overseerr as well.
Contribution includes building new features, patching bugs, translating the application, or even just writing documentation.

View File

@@ -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

View File

@@ -95,8 +95,6 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';

View File

@@ -6,15 +6,13 @@ sidebar_position: 2
# Build from Source (Advanced)
:::warning
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 TabItem from '@theme/TabItem';
### Prerequisites
- [Node.js 22.x](https://nodejs.org/en/download/)
- [Node.js 20.x](https://nodejs.org/en/download/)
- [Pnpm 9.x](https://pnpm.io/installation)
- [Git](https://git-scm.com/downloads)
@@ -28,7 +26,7 @@ sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr
```bash
git clone https://github.com/Fallenbagel/jellyseerr.git
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:
```bash
@@ -60,6 +58,9 @@ PORT=5055
## specify on which interface to listen, by default jellyseerr listens on all interfaces
#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)
# FORCE_IPV4_FIRST=true
```
@@ -202,7 +203,7 @@ cd C:\jellyseerr
2. Clone the Jellyseerr repository and checkout the develop branch:
```powershell
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:
```powershell
@@ -255,8 +256,7 @@ To run jellyseerr as a service:
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
2. Install NSSM:
```powershell
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js"
nssm set Jellyseerr AppDirectory "C:\jellyseerr"
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"]
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
```
3. Start the service:

View File

@@ -7,8 +7,6 @@ sidebar_position: 1
:::info
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/).
Refer to [Configuring Databases](/extending-jellyseerr/database-config#postgresql-options) for details on how to configure your database.
:::
## Unix (Linux, macOS)
@@ -39,6 +37,11 @@ docker run -d \
--restart unless-stopped \
fallenbagel/jellyseerr
```
:::tip
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
`-e JELLYFIN_TYPE=emby`
:::
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
@@ -85,6 +88,9 @@ services:
- /path/to/appdata/config:/app/config
restart: unless-stopped
```
:::tip
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
:::
Then, start all services defined in the Compose file:
```bash
@@ -113,7 +119,8 @@ You may alternatively use a third-party mechanism like [dockge](https://github.c
2. Inside the **Community Applications** app store, search for **Jellyseerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
5. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
5. If you want to use emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`. Otherwise, remove the variable.
6. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
## Windows
@@ -138,16 +145,6 @@ Then, create and start the Jellyseerr container:
<TabItem value="docker-cli" label="Docker CLI">
```bash
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
@@ -170,20 +167,16 @@ services:
volumes:
jellyseerr-data:
external: true
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
```
</TabItem>
</Tabs>
:::tip
If you are using a named volume, then you can safely **ignore** the warning about the `/app/config` folder being incorrectly mounted.
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
:::
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
:::info
@@ -192,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.)
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
:::

View File

@@ -1,21 +0,0 @@
---
title: Kubernetes
description: Install Jellyseerr in Kubernetes
sidebar_position: 5
---
# Kubernetes
:::info
This method is not recommended for most users. It is intended for advanced users who are using Kubernetes.
:::
## Installation
```console
helm install jellyseerr oci://ghcr.io/fallenbagel/jellyseerr/jellyseerr-chart
```
Helm values can be found in the Jellyseerr repository under [charts/jellyseerr-chart/README.md](https://github.com/Fallenbagel/jellyseerr/tree/develop/charts/jellyseerr-chart).
Verify the signature with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) (replace [tag], with the TAG you want to verify) :
```console
cosign verify ghcr.io/fallenbagel/jellyseerr/jellyseerr-chart:[tag] --certificate-identity=https://github.com/Fallenbagel/jellyseerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.ac
tions.githubusercontent.com
```

View File

@@ -6,8 +6,6 @@ sidebar_position: 3
import { JellyseerrVersion, NixpkgVersion } from '@site/src/components/JellyseerrVersion';
import Admonition from '@theme/Admonition';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Nix Package Manager (Advanced)
:::info
@@ -15,55 +13,22 @@ This method is not recommended for most users. It is intended for advanced users
:::
export const VersionMismatchWarning = () => {
let jellyseerrVersion = null;
let nixpkgVersions = null;
try {
jellyseerrVersion = JellyseerrVersion();
nixpkgVersions = NixpkgVersion();
} catch (err) {
return (
<Admonition type="error">
Failed to load version information. Error: {err.message || JSON.stringify(err)}
</Admonition>
);
}
const jellyseerrVersion = JellyseerrVersion();
const nixpkgVersion = NixpkgVersion();
if (!nixpkgVersions || nixpkgVersions.error) {
return (
<Admonition type="error">
Failed to fetch Nixpkg versions: {nixpkgVersions?.error || 'Unknown error'}
</Admonition>
);
}
const isUnstableUpToDate = jellyseerrVersion === nixpkgVersions.unstable;
const isStableUpToDate = jellyseerrVersion === nixpkgVersions.stable;
const isUpToDate = jellyseerrVersion === nixpkgVersion;
return (
<>
{!isStableUpToDate ? (
<Admonition type="warning">
The{' '}
<a href="https://github.com/NixOS/nixpkgs/blob/nixos-24.11/pkgs/servers/jellyseerr/default.nix#L14">
upstream Jellyseerr Nix Package (v{nixpkgVersions.stable})
</a>{' '}
is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>,{' '}
{isUnstableUpToDate ? (
<>
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}
<>
{!isUpToDate ? (
<Admonition type="warning">
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-derivation">override the package derivation</a>.
</Admonition>
) : (
<Admonition type="success">
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>.
</Admonition>
)}
</>
);
};
@@ -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:
<Tabs groupId="nixpkg-methods" queryString>
<TabItem value="default" label="Default Configurations">
```nix
{ config, pkgs, ... }:
@@ -93,20 +56,53 @@ If you want more advanced configuration options, you can use the following:
enable = true;
port = 5055;
openFirewall = true;
package = pkgs.jellyseerr; # Use the unstable package if stable is not up-to-date
};
}
```
</TabItem>
<TabItem value="custom" label="Database Configurations">
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
After adding the configuration to your `configuration.nix`, you can run the following command to install jellyseerr:
```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,
pkgs,
lib,
...
}:
nixpkgs.config.packageOverrides = pkgs: {
jellyseerr = pkgs.jellyseerr.overrideAttrs (oldAttrs: rec {
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;
let
cfg = config.services.jellyseerr;
@@ -117,65 +113,28 @@ in
disabledModules = [ "services/misc/jellyseerr.nix" ];
options.services.jellyseerr = {
enable = mkEnableOption ''Jellyseerr, a requests manager for Jellyfin'';
enable = mkEnableOption (mdDoc ''Jellyseerr, a requests manager for Jellyfin'');
openFirewall = mkOption {
type = types.bool;
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 {
type = types.port;
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 {
type = types.package;
default = pkgs.jellyseerr;
defaultText = literalExpression "pkgs.jellyseerr";
description = ''
Jellyseerr package to use.
'';
};
databaseConfig = mkOption {
type = types.attrsOf types.str;
default = {
type = "sqlite";
configDirectory = "config";
logQueries = "false";
type = types.package;
default = pkgs.jellyseerr;
defaultText = literalExpression "pkgs.jellyseerr";
description = lib.mdDoc ''
Jellyseerr package to use.
'';
};
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 {
@@ -183,29 +142,14 @@ in
description = "Jellyseerr, a requests manager for Jellyfin";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment =
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 "";
};
environment.PORT = toString cfg.port;
serviceConfig = {
Type = "exec";
StateDirectory = "jellyseerr";
WorkingDirectory = "${cfg.package}/libexec/jellyseerr";
WorkingDirectory = "\${cfg.package}/libexec/jellyseerr/deps/jellyseerr";
DynamicUser = true;
ExecStart = "${cfg.package}/bin/jellyseerr";
BindPaths = [ "/var/lib/jellyseerr/:${cfg.package}/libexec/jellyseerr/config/" ];
ExecStart = "\${cfg.package}/bin/jellyseerr";
BindPaths = [ "/var/lib/jellyseerr/:\${cfg.package}/libexec/jellyseerr/deps/jellyseerr/config/" ];
Restart = "on-failure";
ProtectHome = true;
ProtectSystem = "strict";
@@ -225,47 +169,57 @@ in
};
};
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";
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
}
}
```
</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
nixos-rebuild switch
```
After rebuild is complete jellyseerr should be running, verify that it is with the following command.
```bash
systemctl status jellyseerr
```
services.jellyseerr = {
enable = true;
port = 5055;
openFirewall = true;
package = (pkgs.callPackage (import ../../../pkgs/jellyseerr) { });
};
}`;
:::info
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
:::
const isUpToDate = jellyseerrVersion === nixpkgVersion;
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 />

View File

@@ -1,171 +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
```
or for Quad9 DNS:
```bash
--dns=9.9.9.9
```
You can try them all and see which one works for your network.
</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
```
or for Quad9's DNS:
```yaml
---
services:
jellyseerr:
dns:
- 9.9.9.9
```
You can try them all and see which one works for your network.
</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 or `9.9.9.9` for Quad9'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
```
or for Quad9's DNS:
```bash
nameserver 9.9.9.9
```
</TabItem>
</Tabs>
### Option 2: 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.
## Account does not have admin privileges
If your admin account no longer has admin privileges, this is typically because your Jellyfin/Emby user ID has changed on the server side.
This can happen if you have a new installation of Jellyfin/Emby or if you have changed the user ID of your admin account.
### Solution: Reset admin access
1. Back up your `settings.json` file (located in your Jellyseerr data directory)
2. Stop the Jellyseerr container/service
3. Delete the `settings.json` file
4. Start Jellyseerr again
5. This will force the setup page to appear
6. Go through the setup process with the same login details
7. You can skip the services setup
8. Once you reach the discover page, stop Jellyseerr
9. Restore your backed-up `settings.json` file
10. Start Jellyseerr again
This process should restore your admin privileges while preserving your settings.
If you still encounter issues, please reach out on our support channels.

View File

@@ -1,93 +0,0 @@
---
title: Backups
description: Understand which data you should back up.
sidebar_position: 4
---
# Which data does Jellyseerr save and where?
## Settings
All configurations from the **Settings** panel in the Jellyseerr web UI are saved, including integrations with Radarr, Sonarr, Jellyfin, Plex, and notification settings.
These settings are stored in the `settings.json` file located in the Jellyseerr data folder.
## User Data
Apart from the settings, all other data—including user accounts, media requests, blacklist etc. are stored in the database (either SQLite or PostgreSQL).
# Backup
### SQLite
If your backup system uses filesystem snapshots (such as Kubernetes with Volsync), you can directly back up the Jellyseerr data folder.
Otherwise, you need to stop the Jellyseerr application and back up the `config` folder.
For advanced users, it's possible to back up the database without stopping the application by using the [SQLite CLI](https://www.sqlite.org/download.html). Run the following command to create a backup:
```bash
sqlite3 db/db.sqlite3 ".backup '/tmp/jellyseerr_db.sqlite3.bak'"
```
Then, copy the `/tmp/jellyseerr_dump.sqlite3.bak` file to your desired backup location.
### PostgreSQL
You can back up the `config` folder and dump the PostgreSQL database without stopping the Jellyseerr application.
Install [postgresql-client](https://www.postgresql.org/download/) and run the following command to create a backup (just replace the placeholders):
:::info
Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below.
-h, --host=HOSTNAME database server host or socket directory
-p, --port=PORT database server port number
:::
```bash
pg_dump -U <database_user> -d <database_name> -f /tmp/jellyseerr_db.sql
```
# Restore
### SQLite
After restoring your `db/db.sqlite3` file and, optionally, the `settings.json` file, the `config` folder structure should look like this:
```
.
├── cache <-- Optional
├── db
│ └── db.sqlite3
├── logs <-- Optional
└── settings.json <-- Optional (required if you want to avoid reconfiguring Jellyseerr)
```
Once the files are restored, start the Jellyseerr application.
### PostgreSQL
Install the [PostgreSQL client](https://www.postgresql.org/download/) and restore the PostgreSQL database using the following command (replace the placeholders accordingly):
:::info
Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below.
-h, --host=HOSTNAME database server host or socket directory
-p, --port=PORT database server port number
:::
```bash
pg_restore -U <database_user> -d <database_name> /tmp/jellyseerr_db.sql
```
Optionally, restore the `settings.json` file. The `config` folder structure should look like this:
```
.
├── cache <-- Optional
├── logs <-- Optional
└── settings.json <-- Optional (required if you want to avoid reconfiguring Jellyseerr)
```
Once the database and files are restored, start the Jellyseerr application.

View File

@@ -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 &rarr; Integrations &rarr; 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)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -58,17 +58,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.
## 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.
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in 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
@@ -78,12 +70,6 @@ Available media will still appear in search results, however, so it is possible
This setting is **disabled** by default.
## Hide Blacklisted Items
When enabled, media that has been blacklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blacklisted when you have the "Manage Blacklist" permission.
This setting is **disabled** by default.
## Allow Partial Series Requests
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.

View File

@@ -14,14 +14,6 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
This setting is **enabled** by default.
## Enable Jellyfin/Emby/Plex Sign-In
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
This setting is **enabled** by default.
## Enable New Jellyfin/Emby/Plex Sign-In
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.

View File

@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
### 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--discover-language) to suit their own preferences.
### Movie Request Limit & Series Request Limit

View File

@@ -11,7 +11,7 @@ const config: Config = {
baseUrl: '/',
trailingSlash: false,
organizationName: 'fallenbagel',
organizationName: 'Fallenbagel',
projectName: 'Jellyseerr',
deploymentBranch: 'gh-pages',
@@ -32,7 +32,7 @@ const config: Config = {
routeBasePath: '/',
path: '../docs',
editUrl:
'https://github.com/fallenbagel/jellyseerr/edit/develop/docs/',
'https://github.com/Fallenbagel/jellyseerr/edit/develop/docs/',
},
blog: false,
pages: false,
@@ -70,7 +70,7 @@ const config: Config = {
},
items: [
{
href: 'https://github.com/fallenbagel/jellyseerr',
href: 'https://github.com/Fallenbagel/jellyseerr',
label: 'GitHub',
position: 'right',
},

View File

@@ -47,6 +47,6 @@
]
},
"engines": {
"node": ">=22.0"
"node": ">=18.0"
}
}

View File

@@ -26,37 +26,25 @@ export const JellyseerrVersion = () => {
};
export const NixpkgVersion = () => {
const [versions, setVersions] = useState(null);
const [version, setVersion] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchVersion = async () => {
try {
const unstableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix';
const stableUrl =
'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-24.11/pkgs/servers/jellyseerr/default.nix';
const [unstableResponse, stableResponse] = await Promise.all([
fetch(unstableUrl),
fetch(stableUrl),
]);
const unstableData = await unstableResponse.text();
const stableData = await stableResponse.text();
const url =
'https://raw.githubusercontent.com/NixOS/nixpkgs/nixos-unstable/pkgs/servers/jellyseerr/default.nix';
const response = await fetch(url);
const data = await response.text();
const versionRegex = /version\s*=\s*"([^"]+)"/;
const unstableMatch = unstableData.match(versionRegex);
const stableMatch = stableData.match(versionRegex);
const unstableVersion =
unstableMatch && unstableMatch[1] ? unstableMatch[1] : '0.0.0';
const stableVersion =
stableMatch && stableMatch[1] ? stableMatch[1] : '0.0.0';
setVersions({ unstable: unstableVersion, stable: stableVersion });
const match = data.match(versionRegex);
if (match && match[1]) {
setVersion(match[1]);
} else {
setError('0.0.0');
}
setLoading(false);
} catch (err) {
setError(err.message);
@@ -75,5 +63,5 @@ export const NixpkgVersion = () => {
return { error };
}
return versions;
return version;
};

View File

@@ -4,13 +4,12 @@
module.exports = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
},
images: {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: 'artworks.thetvdb.com' },
{ hostname: 'plex.tv' },
],
},
webpack(config) {

View File

@@ -1,19 +1,19 @@
openapi: '3.0.2'
info:
title: 'Jellyseerr API'
title: 'Overseerr API'
version: '1.0.0'
description: |
This is the documentation for the Jellyseerr API backend.
This is the documentation for the Overseerr API backend.
Two primary authentication methods are supported:
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Jellyseerr.
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
tags:
- name: public
description: Public API endpoints requiring no authentication.
- name: settings
description: Endpoints related to Jellyseerr's settings and configuration.
description: Endpoints related to Overseerr's settings and configuration.
- name: auth
description: Endpoints related to logging in or out, and the currently authenticated user.
- name: users
@@ -143,12 +143,10 @@ components:
properties:
locale:
type: string
discoverRegion:
region:
type: string
originalLanguage:
type: string
streamingRegion:
type: string
MainSettings:
type: object
properties:
@@ -160,10 +158,16 @@ components:
example: en
applicationTitle:
type: string
example: Jellyseerr
example: Overseerr
applicationUrl:
type: string
example: https://os.example.com
trustProxy:
type: boolean
example: true
csrfProtection:
type: boolean
example: false
hideAvailable:
type: boolean
example: false
@@ -182,18 +186,6 @@ components:
defaultPermissions:
type: number
example: 32
enableSpecialEpisodes:
type: boolean
example: false
NetworkSettings:
type: object
properties:
csrfProtection:
type: boolean
example: false
trustProxy:
type: boolean
example: true
PlexLibrary:
type: object
properties:
@@ -1157,7 +1149,7 @@ components:
status:
type: number
example: 0
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
requests:
type: array
readOnly: true
@@ -1281,8 +1273,6 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -1344,8 +1334,6 @@ components:
type: string
chatId:
type: string
messageThreadId:
type: string
sendSilently:
type: boolean
PushbulletSettings:
@@ -1399,32 +1387,6 @@ components:
type: string
token:
type: string
NtfySettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
url:
type: string
topic:
type: string
authMethodUsernamePassword:
type: boolean
username:
type: string
password:
type: string
authMethodToken:
type: boolean
token:
type: string
LunaSeaSettings:
type: object
properties:
@@ -1458,7 +1420,7 @@ components:
example: no-reply@example.com
senderName:
type: string
example: Jellyseerr
example: Overseerr
smtpHost:
type: string
example: 127.0.0.1
@@ -1855,9 +1817,6 @@ components:
telegramChatId:
type: string
nullable: true
telegramMessageThreadId:
type: string
nullable: true
telegramSendSilently:
type: boolean
nullable: true
@@ -1971,46 +1930,6 @@ components:
type: string
native_name:
type: string
OverrideRule:
type: object
properties:
id:
type: string
Certification:
type: object
properties:
certification:
type: string
example: 'PG-13'
meaning:
type: string
example: 'Some material may be inappropriate for children under 13.'
nullable: true
order:
type: number
example: 3
nullable: true
required:
- certification
CertificationResponse:
type: object
properties:
certifications:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/Certification'
example:
certifications:
US:
- certification: 'G'
meaning: 'All ages admitted'
order: 1
- certification: 'PG'
meaning: 'Some material may not be suitable for children under 10.'
order: 2
securitySchemes:
cookieAuth:
type: apiKey
@@ -2024,8 +1943,8 @@ components:
paths:
/status:
get:
summary: Get Jellyseerr status
description: Returns the current Jellyseerr status in a JSON object.
summary: Get Overseerr status
description: Returns the current Overseerr status in a JSON object.
security: []
tags:
- public
@@ -2103,37 +2022,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/network:
get:
summary: Get network settings
description: Retrieves all network settings in a JSON object.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
post:
summary: Update network settings
description: Updates network settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NetworkSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/NetworkSettings'
/settings/main/regenerate:
post:
summary: Get main settings with newly-generated API key
@@ -3310,52 +3198,6 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/ntfy:
get:
summary: Get ntfy.sh notification settings
description: Returns current ntfy.sh notification settings in a JSON object.
tags:
- settings
responses:
'200':
description: Returned ntfy.sh settings
content:
application/json:
schema:
$ref: '#/components/schemas/NtfySettings'
post:
summary: Update ntfy.sh notification settings
description: Update ntfy.sh notification settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NtfySettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/NtfySettings'
/settings/notifications/ntfy/test:
post:
summary: Test ntfy.sh settings
description: Sends a test notification to the ntfy.sh agent.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NtfySettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/slack:
get:
summary: Get Slack notification settings
@@ -3911,16 +3753,6 @@ paths:
type: string
enum: [created, updated, requests, displayname]
default: created
- in: query
name: q
required: false
schema:
type: string
- in: query
name: includeIds
required: false
schema:
type: string
responses:
'200':
description: A JSON array of all users
@@ -4037,7 +3869,7 @@ paths:
schema:
type: object
properties:
jellyfinUserIds:
jellyfinIds:
type: array
items:
type: string
@@ -4069,8 +3901,6 @@ paths:
type: string
p256dh:
type: string
userAgent:
type: string
required:
- endpoint
- auth
@@ -4078,88 +3908,6 @@ paths:
responses:
'204':
description: Successfully registered push subscription
/user/{userId}/pushSubscriptions:
get:
summary: Get all web push notification settings for a user
description: |
Returns all web push notification settings for a user in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'200':
description: User web push notification settings in JSON
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
p256dh:
type: string
auth:
type: string
userAgent:
type: string
/user/{userId}/pushSubscription/{key}:
get:
summary: Get web push notification settings for a user
description: |
Returns web push notification settings for a user in a JSON object.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- in: path
name: key
required: true
schema:
type: string
responses:
'200':
description: User web push notification settings in JSON
content:
application/json:
schema:
type: object
properties:
endpoint:
type: string
p256dh:
type: string
auth:
type: string
userAgent:
type: string
delete:
summary: Delete user push subscription by key
description: Deletes the user push subscription with the provided key.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
- in: path
name: key
required: true
schema:
type: string
responses:
'204':
description: Successfully removed user push subscription
/user/{userId}:
get:
summary: Get user by ID
@@ -4346,12 +4094,6 @@ paths:
type: string
nullable: true
example: dune
- in: query
name: filter
schema:
type: string
enum: [all, manual, blacklistedTags]
default: manual
responses:
'200':
description: Blacklisted items returned
@@ -4400,21 +4142,6 @@ paths:
'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:
@@ -4619,104 +4346,6 @@ paths:
responses:
'204':
description: User password updated
/user/{userId}/settings/linked-accounts/plex:
post:
summary: Link the provided Plex account to the current user
description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
authToken:
type: string
required:
- authToken
responses:
'204':
description: Linking account succeeded
'403':
description: Invalid credentials
'422':
description: Account already linked to a user
delete:
summary: Remove the linked Plex account for a user
description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'204':
description: Unlinking account succeeded
'400':
description: Unlink request invalid
'404':
description: User does not exist
/user/{userId}/settings/linked-accounts/jellyfin:
post:
summary: Link the provided Jellyfin account to the current user
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
password:
type: string
example: 'supersecret'
responses:
'204':
description: Linking account succeeded
'403':
description: Invalid credentials
'422':
description: Account already linked to a user
delete:
summary: Remove the linked Jellyfin account for a user
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'204':
description: Unlinking account succeeded
'400':
description: Unlink request invalid
'404':
description: User does not exist
/user/{userId}/settings/notifications:
get:
summary: Get notification settings for a user
@@ -5061,37 +4690,6 @@ paths:
schema:
type: string
example: 8|9
- in: query
name: certification
schema:
type: string
example: PG-13
description: Exact certification to filter by (used when certificationMode is 'exact')
- in: query
name: certificationGte
schema:
type: string
example: G
description: Minimum certification to filter by (used when certificationMode is 'range')
- in: query
name: certificationLte
schema:
type: string
example: PG-13
description: Maximum certification to filter by (used when certificationMode is 'range')
- in: query
name: certificationCountry
schema:
type: string
example: US
description: Country code for the certification system (e.g., US, GB, CA)
- in: query
name: certificationMode
schema:
type: string
enum: [exact, range]
example: exact
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
responses:
'200':
description: Results
@@ -5386,37 +4984,6 @@ paths:
schema:
type: string
example: 3|4
- in: query
name: certification
schema:
type: string
example: TV-14
description: Exact certification to filter by (used when certificationMode is 'exact')
- in: query
name: certificationGte
schema:
type: string
example: TV-PG
description: Minimum certification to filter by (used when certificationMode is 'range')
- in: query
name: certificationLte
schema:
type: string
example: TV-MA
description: Maximum certification to filter by (used when certificationMode is 'range')
- in: query
name: certificationCountry
schema:
type: string
example: US
description: Country code for the certification system (e.g., US, GB, CA)
- in: query
name: certificationMode
schema:
type: string
enum: [exact, range]
example: exact
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
responses:
'200':
description: Results
@@ -5845,8 +5412,6 @@ paths:
processing,
unavailable,
failed,
deleted,
completed,
]
- in: query
name: sort
@@ -5854,26 +5419,12 @@ paths:
type: string
enum: [added, modified]
default: added
- in: query
name: sortDirection
schema:
type: string
enum: [asc, desc]
nullable: true
default: desc
- in: query
name: requestedBy
schema:
type: number
nullable: true
example: 1
- in: query
name: mediaType
schema:
type: string
enum: [movie, tv, all]
nullable: true
default: all
responses:
'200':
description: Requests returned
@@ -5918,7 +5469,7 @@ paths:
- type: array
items:
type: number
minimum: 0
minimum: 1
- type: string
enum: [all]
is4k:
@@ -6024,7 +5575,7 @@ paths:
type: array
items:
type: number
minimum: 0
minimum: 1
is4k:
type: boolean
example: false
@@ -6600,16 +6151,7 @@ paths:
schema:
type: string
nullable: true
enum:
[
all,
available,
partial,
allavailable,
processing,
pending,
deleted,
]
enum: [all, available, partial, allavailable, processing, pending]
- in: query
name: sort
schema:
@@ -6685,7 +6227,7 @@ paths:
example: available
schema:
type: string
enum: [available, partial, processing, pending, unknown, deleted]
enum: [available, partial, processing, pending, unknown]
requestBody:
content:
application/json:
@@ -7397,132 +6939,6 @@ paths:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/certifications/movie:
get:
summary: Get movie certifications
description: Returns list of movie certifications from TMDB.
tags:
- other
security:
- cookieAuth: []
- apiKey: []
responses:
'200':
description: Movie certifications returned
content:
application/json:
schema:
$ref: '#/components/schemas/CertificationResponse'
'500':
description: Unable to retrieve movie certifications
content:
application/json:
schema:
type: object
properties:
status:
type: number
example: 500
message:
type: string
example: Unable to retrieve movie certifications.
/certifications/tv:
get:
summary: Get TV certifications
description: Returns list of TV show certifications from TMDB.
tags:
- other
security:
- cookieAuth: []
- apiKey: []
responses:
'200':
description: TV certifications returned
content:
application/json:
schema:
$ref: '#/components/schemas/CertificationResponse'
'500':
description: Unable to retrieve TV certifications
content:
application/json:
schema:
type: object
properties:
status:
type: number
example: 500
message:
type: string
example: Unable to retrieve TV certifications.
/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:
- cookieAuth: []
- apiKey: []

View File

@@ -5,7 +5,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "node postinstall-win.js",
"dev": "nodemon -e ts --watch server --watch jellyseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
"build": "pnpm build:next && pnpm build:server",
@@ -32,7 +32,6 @@
},
"license": "MIT",
"dependencies": {
"@dr.pogodin/csurf": "^1.14.1",
"@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10",
@@ -43,23 +42,20 @@
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"@types/wink-jaro-distance": "^2.0.2",
"@types/ua-parser-js": "^0.7.36",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.7",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5",
"cronstrue": "2.23.0",
"csurf": "1.11.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"email-templates": "12.0.1",
"email-templates": "9.0.0",
"email-validator": "2.0.4",
"express": "4.21.2",
"express": "4.18.2",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
@@ -67,15 +63,14 @@
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.25",
"next": "^14.2.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
"nodemailer": "6.10.0",
"openpgp": "5.11.2",
"pg": "8.11.0",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"plex-api": "5.3.2",
"pug": "3.0.3",
"pug": "3.0.2",
"react": "^18.3.1",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
@@ -89,28 +84,24 @@
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.9",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.7.1",
"semver": "7.3.8",
"sharp": "^0.33.4",
"sqlite3": "5.1.7",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.2.5",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.12",
"undici": "^7.3.0",
"ua-parser-js": "^1.0.35",
"typeorm": "0.3.11",
"undici": "^6.20.1",
"web-push": "3.5.0",
"wink-jaro-distance": "^2.0.0",
"winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "3.24.2"
"zod": "3.20.6"
},
"devDependencies": {
"@commitlint/cli": "17.4.4",
@@ -120,8 +111,8 @@
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/typography": "0.5.9",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
@@ -131,7 +122,7 @@
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "22.10.5",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
@@ -150,7 +141,7 @@
"commitizen": "4.3.0",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "14.1.0",
"cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.35.0",
"eslint-config-next": "^14.2.4",
@@ -163,8 +154,8 @@
"eslint-plugin-react-hooks": "4.6.0",
"husky": "8.0.3",
"lint-staged": "13.1.2",
"nodemon": "3.1.9",
"postcss": "8.4.31",
"nodemon": "2.0.20",
"postcss": "8.4.21",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
@@ -177,7 +168,7 @@
"typescript": "4.9.5"
},
"engines": {
"node": "^22.0.0",
"node": "^20.0.0",
"pnpm": "^9.0.0"
},
"overrides": {
@@ -243,8 +234,7 @@
"COMMIT_TAG": "$GIT_SHA"
},
"imageNames": [
"fallenbagel/jellyseerr",
"ghcr.io/fallenbagel/jellyseerr"
"fallenbagel/jellyseerr"
],
"platforms": [
"linux/amd64",

4137
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@@ -3,7 +3,7 @@
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 4;
const OFFLINE_VERSION = 3;
const CACHE_NAME = 'offline';
// Customize this with a different URL if needed.
const OFFLINE_URL = '/offline.html';
@@ -107,25 +107,6 @@ self.addEventListener('push', (event) => {
);
}
// Set the badge with the amount of pending requests
// Only update the badge if the payload confirms they are the admin
if (
(payload.notificationType === 'MEDIA_APPROVED' ||
payload.notificationType === 'MEDIA_DECLINED') &&
payload.isAdmin
) {
if ('setAppBadge' in navigator) {
navigator.setAppBadge(payload.pendingRequestsCount);
}
return;
}
if (payload.notificationType === 'MEDIA_PENDING') {
if ('setAppBadge' in navigator) {
navigator.setAppBadge(payload.pendingRequestsCount);
}
}
event.waitUntil(self.registration.showNotification(payload.subject, options));
});

View File

@@ -1,7 +1,8 @@
import logger from '@server/logger';
import axios from 'axios';
import fs, { promises as fsp } from 'fs';
import path from 'path';
import fs, { promises as fsp } from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import xml2js from 'xml2js';
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
@@ -161,14 +162,18 @@ class AnimeListMapping {
label: 'Anime-List Sync',
});
try {
const response = await axios.get(MAPPING_URL, {
responseType: 'stream',
});
const response = await fetch(MAPPING_URL);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
await new Promise<void>((resolve, reject) => {
const writer = fs.createWriteStream(LOCAL_PATH);
writer.on('finish', resolve);
writer.on('error', reject);
response.data.pipe(writer);
if (!response.body) return reject();
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
writer
);
});
} catch (e) {
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);

View File

@@ -1,6 +1,5 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type { RateLimitOptions } from '@server/utils/rateLimit';
// import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
@@ -12,99 +11,219 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
rateLimit?: RateLimitOptions;
}
class ExternalAPI {
protected axios: AxiosInstance;
protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
params: Record<string, string> = {},
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
// if (options.rateLimit) {
// this.fetch = rateLimit(fetch, options.rateLimit);
// } else {
// this.fetch = fetch;
// }
this.fetch = fetch;
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {
maxRequests: options.rateLimit.maxRequests,
maxRPS: options.rateLimit.maxRPS,
});
const url = new URL(baseUrl);
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...((url.username || url.password) && {
Authorization: `Basic ${Buffer.from(
`${url.username}:${url.password}`
).toString('base64')}`,
}),
...options.headers,
};
if (url.username || url.password) {
url.username = '';
url.password = '';
baseUrl = url.toString();
}
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...config?.params,
headers: config?.headers,
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
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);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return response.data;
return data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
...(data ? { data } : {}),
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
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);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return response.data;
return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...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);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async delete<T>(
endpoint: string,
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
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);
return data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
params?: Record<string, string>,
ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...config?.params,
headers: config?.headers,
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
@@ -116,38 +235,112 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).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);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
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);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return response.data;
return data;
}
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...options,
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
this.cache?.del(cacheKey);
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}
private serializeCacheKey(
endpoint: string,
options?: Record<string, unknown>
params?: Record<string, unknown>
) {
if (!options) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease {
url: string;
@@ -67,16 +67,12 @@ class GithubAPI extends ExternalAPI {
'https://api.github.com',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data,
}
);
}
public async getJellyseerrReleases({
public async getOverseerrReleases({
take = 20,
}: {
take?: number;
@@ -85,23 +81,21 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GitHubRelease[]>(
'/repos/fallenbagel/jellyseerr/releases',
{
params: {
per_page: take,
},
per_page: take.toString(),
}
);
return data;
} catch (e) {
logger.warn(
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];
}
}
public async getJellyseerrCommits({
public async getOverseerrCommits({
take = 20,
branch = 'develop',
}: {
@@ -112,17 +106,15 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GithubCommit[]>(
'/repos/fallenbagel/jellyseerr/commits',
{
params: {
per_page: take,
branch,
},
per_page: take.toString(),
branch,
}
);
return data;
} catch (e) {
logger.warn(
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
{ label: 'GitHub API', errorMessage: e.message }
);
return [];

View File

@@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { ApiError } from '@server/types/error';
import { getAppVersion } from '@server/utils/appVersion';
@@ -22,23 +20,6 @@ export interface JellyfinUserResponse {
PrimaryImageTag?: string;
}
export interface JellyfinDevice {
Id: string;
Name: string;
LastUserName: string;
AppName: string;
AppVersion: string;
LastUserId: string;
DateLastActivity: string;
Capabilities: Record<string, unknown>;
}
export interface JellyfinDevicesResponse {
Items: JellyfinDevice[];
TotalRecordCount: number;
StartIndex: number;
}
export interface JellyfinLoginResponse {
User: JellyfinUserResponse;
AccessToken: string;
@@ -111,34 +92,15 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string;
}
export interface JellyfinItemsReponse {
Items: JellyfinLibraryItemExtended[];
TotalRecordCount: number;
StartIndex: number;
}
class JellyfinAPI extends ExternalAPI {
private userId?: string;
private mediaServerType: MediaServerType;
constructor(
jellyfinHost: string,
authToken?: string | null,
deviceId?: string | null
) {
const settings = getSettings();
const safeDeviceId =
deviceId && deviceId.length > 0
? deviceId
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
'base64'
);
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
}
super(
@@ -147,13 +109,9 @@ class JellyfinAPI extends ExternalAPI {
{
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
this.mediaServerType = settings.main.mediaServerType;
}
public async login(
@@ -162,7 +120,7 @@ class JellyfinAPI extends ExternalAPI {
ClientIP?: string
): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => {
const headers =
const headers: { [key: string]: string } =
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
@@ -171,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
Username,
Pw: Password,
},
{},
undefined,
{ headers }
);
};
@@ -178,38 +138,39 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug('Failed to authenticate with headers', {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
label: 'Jellyfin API',
error: e.response?.statusText,
ip: ClientIP,
});
if (!e.response?.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
if (e.response?.status === 401) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
const status = e.cause?.status;
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(
`Something went wrong while authenticating with the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.response?.status,
ip: ClientIP,
}
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
}
}
@@ -224,7 +185,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -238,10 +199,10 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
}
}
@@ -253,10 +214,10 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -269,10 +230,10 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -293,10 +254,7 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{
label: 'Jellyfin API',
error: e.response?.status,
}
{ label: 'Jellyfin API' }
);
return [];
@@ -333,7 +291,16 @@ class JellyfinAPI extends ExternalAPI {
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try {
const libraryItemsResponse = await this.get<any>(
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
`/Users/${this.userId}/Items`,
{
SortBy: 'SortName',
SortOrder: 'Ascending',
IncludeItemTypes: 'Series,Movie,Others',
Recursive: 'true',
StartIndex: '0',
ParentId: id,
collapseBoxSetItems: 'false',
}
);
return libraryItemsResponse.Items.filter(
@@ -342,35 +309,31 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e?.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try {
const endpoint =
this.mediaServerType === MediaServerType.JELLYFIN
? `/Items/Latest`
: `/Users/${this.userId}/Items/Latest`;
const itemResponse = await this.get<any>(
`${endpoint}?Limit=12&ParentId=${id}${
this.mediaServerType === MediaServerType.JELLYFIN
? `&userId=${this.userId ?? 'Me'}`
: ''
}`
`/Users/${this.userId}/Items/Latest`,
{
Limit: '12',
ParentId: id,
}
);
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -378,26 +341,23 @@ class JellyfinAPI extends ExternalAPI {
id: string
): Promise<JellyfinLibraryItemExtended | undefined> {
try {
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
params: {
ids: id,
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
},
});
const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/${id}`
);
return itemResponse.Items?.[0];
return itemResponse;
} catch (e) {
if (availabilitySync.running) {
if (e.response?.status === 500) {
if (e.cause?.status === 500) {
return undefined;
}
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -409,10 +369,10 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -422,7 +382,10 @@ class JellyfinAPI extends ExternalAPI {
): Promise<JellyfinLibraryItem[]> {
try {
const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
`/Shows/${seriesID}/Episodes`,
{
seasonId: seasonID,
}
);
return episodeResponse.Items.filter(
@@ -431,10 +394,10 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
@@ -448,7 +411,7 @@ class JellyfinAPI extends ExternalAPI {
} catch (e) {
logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -92,7 +92,7 @@ class PlexAPI {
plexSettings,
timeout,
}: {
plexToken?: string | null;
plexToken?: string;
plexSettings?: PlexSettings;
timeout?: number;
}) {
@@ -107,7 +107,7 @@ class PlexAPI {
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken ?? undefined,
token: plexToken,
authenticator: {
authenticate: (
_plexApi,
@@ -124,9 +124,9 @@ class PlexAPI {
// },
options: {
identifier: settings.clientId,
product: 'Jellyseerr',
deviceName: 'Jellyseerr',
platform: 'Jellyseerr',
product: 'Overseerr',
deviceName: 'Overseerr',
platform: 'Overseerr',
},
});
}

View File

@@ -1,10 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse {
user: PlexUser;
@@ -128,11 +127,6 @@ export interface PlexWatchlistItem {
title: string;
}
export interface PlexWatchlistCache {
etag: string;
response: WatchlistResponse;
}
class PlexTvAPI extends ExternalAPI {
private authToken: string;
@@ -143,8 +137,6 @@ class PlexTvAPI extends ExternalAPI {
{
headers: {
'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('plextv').data,
}
@@ -155,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const devicesResp = await this.get('/api/resources', {
includeHttps: '1',
});
const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse
devicesResp as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
@@ -211,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> {
try {
const account = await this.axios.get<PlexAccountResponse>(
const account = await this.get<PlexAccountResponse>(
'/users/account.json'
);
return account.data.user;
return account.user;
} catch (e) {
logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -255,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
}
public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const data = await this.get('/api/users');
const parsedXml = (await xml2js.parseStringPromise(
response.data
data as string
)) as UsersResponse;
return parsedXml;
}
@@ -276,71 +261,49 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[];
}> {
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
this.authToken
);
const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
headers: {
'If-None-Match': cachedWatchlist?.etag,
},
baseURL: 'https://metadata.provider.plex.tv',
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
headers: this.defaultHeaders,
}
);
// 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.etag,
response: response.data,
};
watchlistCache.data.set<PlexWatchlistCache>(
this.authToken,
cachedWatchlist
);
}
const data = (await response.json()) as WatchlistResponse;
const watchlistDetails = await Promise.all(
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{
baseURL: 'https://metadata.provider.plex.tv',
}
);
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
})
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -348,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
return {
offset,
size,
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
totalSize: data.MediaContainer.totalSize,
items: filteredList,
};
} catch (e) {
@@ -364,24 +327,6 @@ class PlexTvAPI extends ExternalAPI {
};
}
}
public async pingToken() {
try {
const response = await this.axios.get('/api/v2/ping', {
headers: {
'X-Plex-Client-Identifier': randomUUID(),
},
});
if (!response?.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;

View File

@@ -1,4 +1,4 @@
import ExternalAPI from './externalapi';
import ExternalAPI from '@server/api/externalapi';
interface PushoverSoundsResponse {
sounds: {
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
class PushoverAPI extends ExternalAPI {
constructor() {
super(
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
super('https://api.pushover.net/1');
}
public async getSounds(appToken: string): Promise<PushoverSound[]> {
try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: {
token: appToken,
},
token: appToken,
});
return mapSounds(data.sounds);

View File

@@ -155,13 +155,13 @@ export interface IMDBRating {
*/
class IMDBRadarrProxy extends ExternalAPI {
constructor() {
super('https://api.radarr.video/v1', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
super(
'https://api.radarr.video/v1',
{},
{
nodeCache: cacheManager.getCache('imdb').data,
}
);
}
/**

View File

@@ -1,7 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import jaro from 'wink-jaro-distance';
interface RTAlgoliaSearchResponse {
results: {
@@ -16,7 +15,7 @@ interface RTAlgoliaHit {
tmsId: string;
type: string;
title: string;
titles?: string[];
titles: string[];
description: string;
releaseYear: number;
rating: string;
@@ -25,9 +24,9 @@ interface RTAlgoliaHit {
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka?: string[];
aka: string[];
posterImageUrl: string;
rottenTomatoes?: {
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
@@ -48,47 +47,6 @@ export interface RTRating {
url: string;
}
// Tunables
const INEXACT_TITLE_FACTOR = 0.25;
const ALTERNATE_TITLE_FACTOR = 0.8;
const PER_YEAR_PENALTY = 0.4;
const MINIMUM_SCORE = 0.175;
// Normalization for title comparisons.
// Lowercase and strip non-alphanumeric (unicode-aware).
const norm = (s: string): string =>
s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '');
// Title similarity. 1 if exact, quarter-jaro otherwise.
const similarity = (a: string, b: string): number =>
a === b ? 1 : jaro(a, b).similarity * INEXACT_TITLE_FACTOR;
// Gets the best similarity score between the searched title and all alternate
// titles of the search result. Non-main titles are penalized.
const t_score = ({ title, titles, aka }: RTAlgoliaHit, s: string): number => {
const f = (t: string, i: number) =>
similarity(norm(t), norm(s)) * (i ? ALTERNATE_TITLE_FACTOR : 1);
return Math.max(...[title].concat(aka || [], titles || []).map(f));
};
// Year difference to score: 0 -> 1.0, 1 -> 0.6, 2 -> 0.2, 3+ -> 0.0
const y_score = (r: RTAlgoliaHit, y?: number): number =>
y ? Math.max(0, 1 - Math.abs(r.releaseYear - y) * PER_YEAR_PENALTY) : 1;
// Cut score in half if result has no ratings.
const extra_score = (r: RTAlgoliaHit): number => (r.rottenTomatoes ? 1 : 0.5);
// Score search result as product of all subscores
const score = (r: RTAlgoliaHit, name: string, year?: number): number =>
t_score(r, name) * y_score(r, year) * extra_score(r);
// Score each search result and return the highest scoring result, if any
const best = (rs: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit =>
rs
.map((r) => ({ score: score(r, name, year), result: r }))
.filter(({ score }) => score > MINIMUM_SCORE)
.sort(({ score: a }, { score: b }) => b - a)[0]?.result;
/**
* This is a best-effort API. The Rotten Tomatoes API is technically
* private and getting access costs money/requires approval.
@@ -105,15 +63,12 @@ class RottenTomatoes extends ExternalAPI {
super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,
@@ -135,21 +90,47 @@ class RottenTomatoes extends ExternalAPI {
year: number
): Promise<RTRating | null> {
try {
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"movie"');
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name.replace(/\bthe\b ?/gi, ''),
params: `filters=${filters}&hitsPerPage=20`,
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
const movie = best(contentResults?.hits || [], name, year);
if (!movie?.rottenTomatoes) return null;
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
if (!movie) {
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie) {
return null;
}
return {
title: movie.title,
@@ -177,21 +158,33 @@ class RottenTomatoes extends ExternalAPI {
year?: number
): Promise<RTRating | null> {
try {
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"tv"');
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: `filters=${filters}&hitsPerPage=20`,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
const tvshow = best(contentResults?.hits || [], name, year);
if (!tvshow?.rottenTomatoes) return null;
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year
);
}
if (!tvshow || !tvshow.rottenTomatoes) {
return null;
}
return {
title: tvshow.title,

View File

@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getSystemStatus = async (): Promise<SystemStatus> => {
try {
const response = await this.axios.get<SystemStatus>('/system/status');
const data = await this.get<SystemStatus>('/system/status');
return response.data;
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
@@ -157,16 +157,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
params: {
includeEpisode: true,
},
}
includeEpisode: 'true',
},
0
);
return response.data.records;
return data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
@@ -176,9 +175,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getTags = async (): Promise<Tag[]> => {
try {
const response = await this.axios.get<Tag[]>(`/tag`);
const data = await this.get<Tag[]>(`/tag`);
return response.data;
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
@@ -188,11 +187,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try {
const response = await this.axios.post<Tag>(`/tag`, {
const data = await this.post<Tag>(`/tag`, {
label,
});
return response.data;
return data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
}
@@ -207,10 +206,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
options: Record<string, unknown>
): Promise<void> {
try {
await this.axios.post(`/command`, {
name: commandName,
...options,
});
await this.post(
`/command`,
{
name: commandName,
...options,
},
{},
0
);
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

@@ -28,39 +28,6 @@ export interface RadarrMovie {
qualityProfileId: number;
added: string;
hasFile: boolean;
tags: number[];
movieFile?: {
id: number;
movieId: number;
relativePath?: string;
path?: string;
size: number;
dateAdded: string;
sceneName?: string;
releaseGroup?: string;
edition?: string;
indexerFlags?: number;
mediaInfo: {
id: number;
audioBitrate: number;
audioChannels: number;
audioCodec?: string;
audioLanguages?: string;
audioStreamCount: number;
videoBitDepth: number;
videoBitrate: number;
videoCodec?: string;
videoFps: number;
videoDynamicRange?: string;
videoDynamicRangeType?: string;
resolution?: string;
runTime?: string;
scanType?: string;
subtitles?: string;
};
originalFilePath?: string;
qualityCutoffNotMet: boolean;
};
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
@@ -70,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovies = async (): Promise<RadarrMovie[]> => {
try {
const response = await this.axios.get<RadarrMovie[]>('/movie');
const data = await this.get<RadarrMovie[]>('/movie');
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
}
@@ -80,9 +47,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
try {
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
const data = await this.get<RadarrMovie>(`/movie/${id}`);
return response.data;
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
}
@@ -90,17 +57,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
term: `tmdb:${id}`,
});
if (!response.data[0]) {
if (!data[0]) {
throw new Error('Movie not found');
}
return response.data[0];
return data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDB ID', {
label: 'Radarr API',
@@ -130,7 +95,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
// movie exists in Radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
const data = await this.put<RadarrMovie>(`/movie`, {
...movie,
title: options.title,
qualityProfileId: options.qualityProfileId,
@@ -139,7 +104,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
tags: Array.from(new Set([...movie.tags, ...options.tags])),
tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
@@ -147,25 +112,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
},
});
if (response.data.monitored) {
if (data.monitored) {
logger.info(
'Found existing title in Radarr and set it to monitored.',
{
label: 'Radarr',
movieId: response.data.id,
movieTitle: response.data.title,
movieId: data.id,
movieTitle: data.title,
}
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: response.data,
movie: data,
});
if (options.searchNow) {
this.searchMovie(response.data.id);
this.searchMovie(data.id);
}
return response.data;
return data;
} else {
logger.error('Failed to update existing movie in Radarr.', {
label: 'Radarr',
@@ -183,7 +148,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
return movie;
}
const response = await this.axios.post<RadarrMovie>(`/movie`, {
const data = await this.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
@@ -199,11 +164,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
},
});
if (response.data.id) {
if (data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', {
label: 'Radarr',
movie: response.data,
movie: data,
});
} else {
logger.error('Failed to add movie to Radarr', {
@@ -212,15 +177,22 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
});
throw new Error('Failed to add movie to Radarr');
}
return response.data;
return data;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
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.',
{
label: 'Radarr',
errorMessage: e.message,
options,
response: e?.response?.data,
response: errorData,
}
);
throw new Error('Failed to add movie to Radarr');
@@ -249,34 +221,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public removeMovie = async (movieId: number): Promise<void> => {
try {
const { id, title } = await this.getMovieByTmdbId(movieId);
await this.axios.delete(`/movie/${id}`, {
params: {
deleteFiles: true,
addImportExclusion: false,
},
await this.delete(`/movie/${id}`, {
deleteFiles: 'true',
addImportExclusion: 'false',
});
logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) {
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;

View File

@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeries(): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series');
const data = await this.get<SonarrSeries[]>('/series');
return response.data;
return data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
}
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
const data = await this.get<SonarrSeries>(`/series/${id}`);
return response.data;
return data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
@@ -137,17 +137,15 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
const data = await this.get<SonarrSeries[]>('/series/lookup', {
term: title,
});
if (!response.data[0]) {
if (!data[0]) {
throw new Error('No series found');
}
return response.data;
return data;
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
@@ -160,17 +158,15 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: `tvdb:${id}`,
},
const data = await this.get<SonarrSeries[]>('/series/lookup', {
term: `tvdb:${id}`,
});
if (!response.data[0]) {
if (!data[0]) {
throw new Error('Series not found');
}
return response.data[0];
return data[0];
} catch (e) {
logger.error('Error retrieving series by tvdb ID', {
label: 'Sonarr API',
@@ -188,32 +184,30 @@ class SonarrAPI extends ServarrBase<{
// If the series already exists, we will simply just update it
if (series.id) {
series.monitored = options.monitored ?? series.monitored;
series.tags = options.tags
? Array.from(new Set([...series.tags, ...options.tags]))
: series.tags;
series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesResponse = await this.axios.put<SonarrSeries>(
const newSeriesData = await this.put<SonarrSeries>(
'/series',
series
series as any
);
if (newSeriesResponse.data.id) {
if (newSeriesData.id) {
logger.info('Updated existing series in Sonarr.', {
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
seriesTitle: newSeriesResponse.data.title,
seriesId: newSeriesData.id,
seriesTitle: newSeriesData.title,
});
logger.debug('Sonarr update details', {
label: 'Sonarr',
series: newSeriesResponse.data,
movie: newSeriesData,
});
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
this.searchSeries(newSeriesData.id);
}
return newSeriesResponse.data;
return newSeriesData;
} else {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
@@ -223,38 +217,35 @@ class SonarrAPI extends ServarrBase<{
}
}
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
'/series',
{
tvdbId: options.tvdbid,
title: options.title,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
// We force all seasons to false if its the first request
monitored: false,
}))
),
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
},
} as Partial<SonarrSeries>
);
const createdSeriesData = await this.post<SonarrSeries>('/series', {
tvdbId: options.tvdbid,
title: options.title,
qualityProfileId: options.profileId,
languageProfileId: options.languageProfileId,
seasons: this.buildSeasonList(
options.seasons,
series.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
// We force all seasons to false if its the first request
monitored: false,
}))
),
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
seriesType: options.seriesType,
addOptions: {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: options.searchNow,
},
} as Partial<SonarrSeries>);
if (createdSeriesResponse.data.id) {
if (createdSeriesData.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', {
label: 'Sonarr',
series: createdSeriesResponse.data,
movie: createdSeriesData,
});
} else {
logger.error('Failed to add movie to Sonarr', {
@@ -264,13 +255,20 @@ class SonarrAPI extends ServarrBase<{
throw new Error('Failed to add series to Sonarr');
}
return createdSeriesResponse.data;
return createdSeriesData;
} 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.', {
label: 'Sonarr API',
errorMessage: e.message,
options,
response: e?.response?.data,
response: errorData,
});
throw new Error('Failed to add series');
}
@@ -342,44 +340,19 @@ class SonarrAPI extends ServarrBase<{
return newSeasons;
}
public removeSerie = async (serieId: number): Promise<void> => {
try {
const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.axios.delete(`/series/${id}`, {
params: {
deleteFiles: true,
addImportExclusion: false,
},
await this.delete(`/series/${id}`, {
deleteFiles: 'true',
addImportExclusion: 'false',
});
logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) {
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;

View File

@@ -1,8 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash';
export interface TautulliHistoryRecord {
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
};
}
class TautulliAPI {
private axios: AxiosInstance;
class TautulliAPI extends ExternalAPI {
constructor(settings: TautulliSettings) {
this.axios = axios.create({
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
super(
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey },
});
{
apikey: settings.apiKey || '',
}
);
}
public async getInfo(): Promise<TautulliInfo> {
try {
return (
await this.axios.get<TautulliInfoResponse>('/api/v2', {
params: { cmd: 'get_tautulli_info' },
await this.get<TautulliInfoResponse>('/api/v2', {
cmd: 'get_tautulli_info',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error('Something went wrong fetching Tautulli server info', {
label: 'Tautulli API',
@@ -148,14 +147,12 @@ class TautulliAPI {
): Promise<TautulliWatchStats[]> {
try {
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: 1,
},
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_item_watch_time_stats',
rating_key: ratingKey,
grouping: '1',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch stats from Tautulli',
@@ -176,14 +173,12 @@ class TautulliAPI {
): Promise<TautulliWatchUser[]> {
try {
return (
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
params: {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: 1,
},
await this.get<TautulliWatchUsersResponse>('/api/v2', {
cmd: 'get_item_user_stats',
rating_key: ratingKey,
grouping: '1',
})
).data.response.data;
).response.data;
} catch (e) {
logger.error(
'Something went wrong fetching media watch users from Tautulli',
@@ -206,15 +201,13 @@ class TautulliAPI {
}
return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
params: {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId,
query_days: 0,
grouping: 1,
},
await this.get<TautulliWatchStatsResponse>('/api/v2', {
cmd: 'get_user_watch_time_stats',
user_id: user.plexId.toString(),
query_days: '0',
grouping: '1',
})
).data.response.data[0];
).response.data[0];
} catch (e) {
logger.error(
'Something went wrong fetching user watch stats from Tautulli',
@@ -245,19 +238,17 @@ class TautulliAPI {
while (results.length < 20) {
const tautulliData = (
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
params: {
cmd: 'get_history',
grouping: 1,
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId,
media_type: 'movie,episode',
length: take,
start,
},
await this.get<TautulliHistoryResponse>('/api/v2', {
cmd: 'get_history',
grouping: '1',
order_column: 'date',
order_dir: 'desc',
user_id: user.plexId.toString(),
media_type: 'movie,episode',
length: take.toString(),
start: start.toString(),
})
).data.response.data.data;
).response.data.data;
if (!tautulliData.length) {
return results;

View File

@@ -1,6 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import { sortBy } from 'lodash';
import type {
TmdbCollection,
@@ -38,36 +37,23 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export const SortOptionsIterable = [
'popularity.desc',
'popularity.asc',
'release_date.desc',
'release_date.asc',
'revenue.desc',
'revenue.asc',
'primary_release_date.desc',
'primary_release_date.asc',
'original_title.asc',
'original_title.desc',
'vote_average.desc',
'vote_average.asc',
'vote_count.desc',
'vote_count.asc',
'first_air_date.desc',
'first_air_date.asc',
] as const;
export type SortOptions = (typeof SortOptionsIterable)[number];
export interface TmdbCertificationResponse {
certifications: {
[country: string]: {
certification: string;
meaning?: string;
order?: number;
}[];
};
}
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
interface DiscoverMovieOptions {
page?: number;
@@ -88,10 +74,6 @@ interface DiscoverMovieOptions {
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
certification?: string;
certificationGte?: string;
certificationLte?: string;
certificationCountry?: string;
}
interface DiscoverTvOptions {
@@ -114,35 +96,29 @@ interface DiscoverTvOptions {
watchRegion?: string;
watchProviders?: string;
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
certification?: string;
certificationGte?: string;
certificationLte?: string;
certificationCountry?: string;
}
class TheMovieDb extends ExternalAPI {
private locale: string;
private discoverRegion?: string;
private region?: string;
private originalLanguage?: string;
constructor({
discoverRegion,
region,
originalLanguage,
}: { discoverRegion?: string; originalLanguage?: string } = {}) {
}: { region?: string; originalLanguage?: string } = {}) {
super(
'https://api.themoviedb.org/3',
{
api_key: '431a8708161bcd1f1fbe7536137e61ed',
api_key: 'db55323b8d3e4154498498a75642b381',
},
{
nodeCache: cacheManager.getCache('tmdb').data,
rateLimit: {
maxRequests: 20,
maxRPS: 50,
id: 'tmdb',
},
}
);
this.locale = getSettings().main?.locale || 'en';
this.discoverRegion = discoverRegion;
this.region = region;
this.originalLanguage = originalLanguage;
}
@@ -150,11 +126,14 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = this.locale,
language = 'en',
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query, page, include_adult: includeAdult, language },
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
});
return data;
@@ -172,18 +151,16 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = this.locale,
language = 'en',
year,
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: {
query,
page,
include_adult: includeAdult,
language,
primary_release_year: year,
},
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
primary_release_year: year?.toString() || '',
});
return data;
@@ -201,18 +178,16 @@ class TheMovieDb extends ExternalAPI {
query,
page = 1,
includeAdult = false,
language = this.locale,
language = 'en',
year,
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
params: {
query,
page,
include_adult: includeAdult,
language,
first_air_date_year: year,
},
query,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
first_air_date_year: year?.toString() || '',
});
return data;
@@ -228,14 +203,14 @@ class TheMovieDb extends ExternalAPI {
public getPerson = async ({
personId,
language = this.locale,
language = 'en',
}: {
personId: number;
language?: string;
}): Promise<TmdbPersonDetails> => {
try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: { language },
language,
});
return data;
@@ -246,7 +221,7 @@ class TheMovieDb extends ExternalAPI {
public getPersonCombinedCredits = async ({
personId,
language = this.locale,
language = 'en',
}: {
personId: number;
language?: string;
@@ -255,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`,
{
params: { language },
language,
}
);
@@ -269,7 +244,7 @@ class TheMovieDb extends ExternalAPI {
public getMovie = async ({
movieId,
language = this.locale,
language = 'en',
}: {
movieId: number;
language?: string;
@@ -278,12 +253,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
include_video_language: language + ', en',
},
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
43200
);
@@ -296,7 +268,7 @@ class TheMovieDb extends ExternalAPI {
public getTvShow = async ({
tvId,
language = this.locale,
language = 'en',
}: {
tvId: number;
language?: string;
@@ -305,12 +277,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`,
{
params: {
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
include_video_language: language + ', en',
},
language,
append_to_response:
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
43200
);
@@ -334,10 +303,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`,
{
params: {
language,
append_to_response: 'external_ids',
},
language: language || '',
append_to_response: 'external_ids',
}
);
@@ -350,7 +317,7 @@ class TheMovieDb extends ExternalAPI {
public async getMovieRecommendations({
movieId,
page = 1,
language = this.locale,
language = 'en',
}: {
movieId: number;
page?: number;
@@ -360,10 +327,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`,
{
params: {
page,
language,
},
page: page.toString(),
language,
}
);
@@ -376,7 +341,7 @@ class TheMovieDb extends ExternalAPI {
public async getMovieSimilar({
movieId,
page = 1,
language = this.locale,
language = 'en',
}: {
movieId: number;
page?: number;
@@ -386,10 +351,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`,
{
params: {
page,
language,
},
page: page.toString(),
language,
}
);
@@ -402,7 +365,7 @@ class TheMovieDb extends ExternalAPI {
public async getMoviesByKeyword({
keywordId,
page = 1,
language = this.locale,
language = 'en',
}: {
keywordId: number;
page?: number;
@@ -412,10 +375,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`,
{
params: {
page,
language,
},
page: page.toString(),
language,
}
);
@@ -428,7 +389,7 @@ class TheMovieDb extends ExternalAPI {
public async getTvRecommendations({
tvId,
page = 1,
language = this.locale,
language = 'en',
}: {
tvId: number;
page?: number;
@@ -438,10 +399,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`,
{
params: {
page,
language,
},
page: page.toString(),
language,
}
);
@@ -456,7 +415,7 @@ class TheMovieDb extends ExternalAPI {
public async getTvSimilar({
tvId,
page = 1,
language = this.locale,
language = 'en',
}: {
tvId: number;
page?: number;
@@ -464,10 +423,8 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> {
try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: {
page,
language,
},
page: page.toString(),
language,
});
return data;
@@ -480,7 +437,7 @@ class TheMovieDb extends ExternalAPI {
sortBy = 'popularity.desc',
page = 1,
includeAdult = false,
language = this.locale,
language = 'en',
primaryReleaseDateGte,
primaryReleaseDateLte,
originalLanguage,
@@ -495,10 +452,6 @@ class TheMovieDb extends ExternalAPI {
voteCountLte,
watchProviders,
watchRegion,
certification,
certificationGte,
certificationLte,
certificationCountry,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
@@ -512,44 +465,38 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
page,
include_adult: includeAdult,
language,
region: this.discoverRegion || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
// 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!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte,
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
certification: certification,
'certification.gte': certificationGte,
'certification.lte': certificationLte,
certification_country: certificationCountry,
},
sort_by: sortBy,
page: page.toString(),
include_adult: includeAdult ? 'true' : 'false',
language,
region: this.region || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
// 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!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte || '',
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte || '',
with_genres: genre || '',
with_companies: studio || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
watch_region: watchRegion || '',
with_watch_providers: watchProviders || '',
});
return data;
@@ -561,7 +508,7 @@ class TheMovieDb extends ExternalAPI {
public getDiscoverTv = async ({
sortBy = 'popularity.desc',
page = 1,
language = this.locale,
language = 'en',
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
@@ -578,10 +525,6 @@ class TheMovieDb extends ExternalAPI {
watchProviders,
watchRegion,
withStatus,
certification,
certificationGte,
certificationLte,
certificationCountry,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
@@ -595,45 +538,41 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
region: this.discoverRegion || '',
// 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!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte,
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
with_status: withStatus,
certification: certification,
'certification.gte': certificationGte,
'certification.lte': certificationLte,
certification_country: certificationCountry,
},
sort_by: sortBy,
page: page.toString(),
language,
region: this.region || '',
// 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!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte || '',
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte || '',
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? ''
: this.originalLanguage || '',
include_null_first_air_dates: includeEmptyReleaseDate
? 'true'
: 'false',
with_genres: genre || '',
with_networks: network?.toString() || '',
with_keywords: keywords || '',
'with_runtime.gte': withRuntimeGte || '',
'with_runtime.lte': withRuntimeLte || '',
'vote_average.gte': voteAverageGte || '',
'vote_average.lte': voteAverageLte || '',
'vote_count.gte': voteCountGte || '',
'vote_count.lte': voteCountLte || '',
with_watch_providers: watchProviders || '',
watch_region: watchRegion || '',
with_status: withStatus || '',
});
return data;
@@ -644,7 +583,7 @@ class TheMovieDb extends ExternalAPI {
public getUpcomingMovies = async ({
page = 1,
language = this.locale,
language = 'en',
}: {
page: number;
language: string;
@@ -653,12 +592,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming',
{
params: {
page,
language,
region: this.discoverRegion,
originalLanguage: this.originalLanguage,
},
page: page.toString(),
language,
region: this.region || '',
originalLanguage: this.originalLanguage || '',
}
);
@@ -671,7 +608,7 @@ class TheMovieDb extends ExternalAPI {
public getAllTrending = async ({
page = 1,
timeWindow = 'day',
language = this.locale,
language = 'en',
}: {
page?: number;
timeWindow?: 'day' | 'week';
@@ -681,11 +618,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`,
{
params: {
page,
language,
region: this.discoverRegion,
},
page: page.toString(),
language,
region: this.region || '',
}
);
@@ -706,9 +641,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`,
{
params: {
page,
},
page: page.toString(),
}
);
@@ -729,9 +662,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`,
{
params: {
page,
},
page: page.toString(),
}
);
@@ -744,7 +675,7 @@ class TheMovieDb extends ExternalAPI {
public async getByExternalId({
externalId,
type,
language = this.locale,
language = 'en',
}:
| {
externalId: string;
@@ -760,10 +691,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`,
{
params: {
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
},
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
language,
}
);
@@ -775,7 +704,7 @@ class TheMovieDb extends ExternalAPI {
public async getMediaByImdbId({
imdbId,
language = this.locale,
language = 'en',
}: {
imdbId: string;
language?: string;
@@ -814,7 +743,7 @@ class TheMovieDb extends ExternalAPI {
public async getShowByTvdbId({
tvdbId,
language = this.locale,
language = 'en',
}: {
tvdbId: number;
language?: string;
@@ -844,7 +773,7 @@ class TheMovieDb extends ExternalAPI {
public async getCollection({
collectionId,
language = this.locale,
language = 'en',
}: {
collectionId: number;
language?: string;
@@ -853,9 +782,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
language,
}
);
@@ -920,7 +847,7 @@ class TheMovieDb extends ExternalAPI {
}
public async getMovieGenres({
language = this.locale,
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
@@ -928,9 +855,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language,
},
language,
},
86400 // 24 hours
);
@@ -942,9 +867,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list',
{
params: {
language: 'en',
},
language: 'en',
},
86400 // 24 hours
);
@@ -971,7 +894,7 @@ class TheMovieDb extends ExternalAPI {
}
public async getTvGenres({
language = this.locale,
language = 'en',
}: {
language?: string;
} = {}): Promise<TmdbGenre[]> {
@@ -979,9 +902,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language,
},
language,
},
86400 // 24 hours
);
@@ -993,9 +914,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list',
{
params: {
language: 'en',
},
language: 'en',
},
86400 // 24 hours
);
@@ -1021,35 +940,6 @@ class TheMovieDb extends ExternalAPI {
}
}
public getMovieCertifications =
async (): Promise<TmdbCertificationResponse> => {
try {
const data = await this.get<TmdbCertificationResponse>(
'/certification/movie/list',
{},
604800 // 7 days
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
}
};
public getTvCertifications = async (): Promise<TmdbCertificationResponse> => {
try {
const data = await this.get<TmdbCertificationResponse>(
'/certification/tv/list',
{},
604800 // 7 days
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
}
};
public async getKeywordDetails({
keywordId,
}: {
@@ -1079,10 +969,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
params: {
query,
page,
},
query,
page: page.toString(),
},
86400 // 24 hours
);
@@ -1104,10 +992,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
params: {
query,
page,
},
query,
page: page.toString(),
},
86400 // 24 hours
);
@@ -1127,9 +1013,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ?? this.originalLanguage,
},
language: language ? this.originalLanguage || '' : '',
},
86400 // 24 hours
);
@@ -1153,10 +1037,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
86400 // 24 hours
);
@@ -1180,10 +1062,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
language: language ? this.originalLanguage || '' : '',
watch_region: watchRegion,
},
86400 // 24 hours
);

View File

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

View File

@@ -3,7 +3,6 @@ export enum MediaRequestStatus {
APPROVED,
DECLINED,
FAILED,
COMPLETED,
}
export enum MediaType {
@@ -18,5 +17,4 @@ export enum MediaStatus {
PARTIALLY_AVAILABLE,
AVAILABLE,
BLACKLISTED,
DELETED,
}

View File

@@ -1,43 +1,7 @@
import fs from 'fs';
import type { TlsOptions } from 'tls';
import 'reflect-metadata';
import type { DataSourceOptions, EntityTarget, Repository } 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 = {
type: 'sqlite',
database: process.env.CONFIG_DIRECTORY
@@ -45,10 +9,10 @@ const devConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
logging: false,
enableWAL: true,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/sqlite/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
subscribers: ['server/subscriber/**/*.ts'],
};
@@ -59,60 +23,16 @@ const prodConfig: DataSourceOptions = {
: 'config/db/db.sqlite3',
synchronize: false,
migrationsRun: false,
logging: boolFromEnv('DB_LOG_QUERIES'),
logging: false,
enableWAL: true,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/sqlite/**/*.js'],
migrations: ['dist/migration/**/*.js'],
subscribers: ['dist/subscriber/**/*.js'],
};
const postgresDevConfig: 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: 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());
const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity extends object>(
target: EntityTarget<Entity>

View File

@@ -1,12 +1,11 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
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 { DbAwareColumn } from '@server/utils/DbColumnHelper';
import type { EntityManager } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
@@ -36,7 +35,7 @@ export class Blacklist implements BlacklistItem {
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user?: User;
user: User;
@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
@@ -44,42 +43,34 @@ export class Blacklist implements BlacklistItem {
@JoinColumn()
public media: Media;
@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@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'];
blacklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
const blacklist = new this({
...blacklistRequest,
});
const mediaRepository = em.getRepository(Media);
const mediaRepository = getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});
const blacklistRepository = em.getRepository(this);
const blacklistRepository = getRepository(this);
await blacklistRepository.save(blacklist);
@@ -89,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist),
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = Promise.resolve(blacklist);
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -2,8 +2,13 @@ import type { DiscoverSliderType } from '@server/constants/discover';
import { defaultSliders } from '@server/constants/discover';
import { getRepository } from '@server/datasource';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class DiscoverSlider {
@@ -50,14 +55,10 @@ class DiscoverSlider {
@Column({ nullable: true })
public data?: string;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<DiscoverSlider>) {

View File

@@ -1,13 +1,13 @@
import type { IssueType } from '@server/constants/issue';
import { IssueStatus } from '@server/constants/issue';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
AfterLoad,
Column,
CreateDateColumn,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import IssueComment from './IssueComment';
import Media from './Media';
@@ -55,21 +55,12 @@ class Issue {
})
public comments: IssueComment[];
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
@AfterLoad()
sortComments() {
this.comments?.sort((a, b) => a.id - b.id);
}
constructor(init?: Partial<Issue>) {
Object.assign(this, init);
}

View File

@@ -1,5 +1,11 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { User } from './User';
@@ -22,14 +28,10 @@ class IssueComment {
@Column({ type: 'text' })
public message: string;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<IssueComment>) {

View File

@@ -10,16 +10,17 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { getHostname } from '@server/utils/getHostname';
import {
AfterLoad,
Column,
CreateDateColumn,
Entity,
Index,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
@@ -41,10 +42,6 @@ class Media {
finalIds = tmdbIds;
}
if (finalIds.length === 0) {
return [];
}
const media = await mediaRepository
.createQueryBuilder('media')
.leftJoinAndSelect(
@@ -106,9 +103,7 @@ class Media {
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@OneToMany(() => MediaRequest, (request) => request.media, {
cascade: ['insert', 'remove'],
})
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
public requests: MediaRequest[];
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
@@ -123,36 +118,21 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
/**
* 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' })
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date;
/**
* 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,
})
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true, type: 'int' })

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } 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;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
public updatedAt: Date;
constructor(init?: Partial<OverrideRule>) {
Object.assign(this, init);
}
}
export default OverrideRule;

View File

@@ -1,6 +1,12 @@
import { MediaStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Media from './Media';
@Entity()
@@ -17,19 +23,13 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, {
onDelete: 'CASCADE',
})
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
public media: Promise<Media>;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<Season>) {

View File

@@ -1,6 +1,14 @@
import { MediaRequestStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
@Entity()
@@ -19,19 +27,27 @@ class SeasonRequest {
})
public request: MediaRequest;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

View File

@@ -9,7 +9,6 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { AfterDate } from '@server/utils/dateHelpers';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import path from 'path';
@@ -17,12 +16,14 @@ import { default as generatePassword } from 'secure-random-password';
import {
AfterLoad,
Column,
CreateDateColumn,
Entity,
Not,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
@@ -55,11 +56,11 @@ export class User {
})
public email: string;
@Column({ type: 'varchar', nullable: true })
public plexUsername?: string | null;
@Column({ nullable: true })
public plexUsername?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinUsername?: string | null;
@Column({ nullable: true })
public jellyfinUsername?: string;
@Column({ nullable: true })
public username?: string;
@@ -76,20 +77,20 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ type: 'integer', nullable: true, select: true })
public plexId?: number | null;
@Column({ nullable: true, select: true })
public plexId?: number;
@Column({ type: 'varchar', nullable: true })
public jellyfinUserId?: string | null;
@Column({ nullable: true })
public jellyfinUserId?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public jellyfinDeviceId?: string | null;
@Column({ nullable: true })
public jellyfinDeviceId?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public jellyfinAuthToken?: string | null;
@Column({ nullable: true })
public jellyfinAuthToken?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public plexToken?: string | null;
@Column({ nullable: true })
public plexToken?: string;
@Column({ type: 'integer', default: 0 })
public permissions = 0;
@@ -97,12 +98,6 @@ export class User {
@Column()
public avatar: string;
@Column({ type: 'varchar', nullable: true })
public avatarETag?: string | null;
@Column({ type: 'varchar', nullable: true })
public avatarVersion?: string | null;
@RelationCount((user: User) => user.requests)
public requestCount: number;
@@ -137,14 +132,10 @@ export class User {
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
public createdIssues: Issue[];
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
public warnings: string[] = [];

View File

@@ -1,4 +1,3 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './User';
@@ -19,19 +18,9 @@ export class UserPushSubscription {
@Column()
public p256dh: string;
@Column()
@Column({ unique: true })
public auth: string;
@Column({ nullable: true })
public userAgent: string;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: true,
})
public createdAt: Date;
constructor(init?: Partial<UserPushSubscription>) {
Object.assign(this, init);
}

View File

@@ -31,10 +31,7 @@ export class UserSettings {
public locale?: string;
@Column({ nullable: true })
public discoverRegion?: string;
@Column({ nullable: true })
public streamingRegion?: string;
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
@@ -60,9 +57,6 @@ export class UserSettings {
@Column({ nullable: true })
public telegramChatId?: string;
@Column({ nullable: true })
public telegramMessageThreadId?: string;
@Column({ nullable: true })
public telegramSendSilently?: boolean;

View File

@@ -5,14 +5,15 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@@ -55,14 +56,10 @@ export class Watchlist implements WatchlistItem {
})
public media: Media;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
@CreateDateColumn()
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<Watchlist>) {

View File

@@ -1,6 +1,5 @@
import csurf from '@dr.pogodin/csurf';
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 { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
@@ -10,7 +9,6 @@ import DiscordAgent from '@server/lib/notifications/agents/discord';
import EmailAgent from '@server/lib/notifications/agents/email';
import GotifyAgent from '@server/lib/notifications/agents/gotify';
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
import PushoverAgent from '@server/lib/notifications/agents/pushover';
import SlackAgent from '@server/lib/notifications/agents/slack';
@@ -28,27 +26,29 @@ import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
import axios from 'axios';
import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import http from 'http';
import https from 'https';
import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
import path from 'path';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
axios.defaults.httpAgent = new http.Agent({ family: 4 });
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
if (process.env.forceIpv4First === 'true') {
dns.setDefaultResultOrder('ipv4first');
net.setDefaultAutoSelectFamily(false);
}
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
logger.info(`Starting Overseerr version ${getAppVersion()}`);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
@@ -66,22 +66,18 @@ app
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
if (isPgsql) {
await dbConnection.runMigrations();
} else {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
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
const settings = await getSettings().load();
restartFlag.initializeSettings(settings);
restartFlag.initializeSettings(settings.main);
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(settings.network.proxy);
if (settings.main.proxy.enabled) {
await createCustomProxyAgent(settings.main.proxy);
}
// Migrate library types
@@ -110,7 +106,6 @@ app
new DiscordAgent(),
new EmailAgent(),
new GotifyAgent(),
new NtfyAgent(),
new LunaSeaAgent(),
new PushbulletAgent(),
new PushoverAgent(),
@@ -137,7 +132,7 @@ app
await DiscoverSlider.bootstrapSliders();
const server = express();
if (settings.network.trustProxy) {
if (settings.main.trustProxy) {
server.enable('trust proxy');
}
server.use(cookieParser());
@@ -158,7 +153,7 @@ app
next();
}
});
if (settings.network.csrfProtection) {
if (settings.main.csrfProtection) {
server.use(
csurf({
cookie: {
@@ -188,7 +183,7 @@ app
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
},
store: new TypeormStore({

View File

@@ -6,8 +6,7 @@ export interface BlacklistItem {
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user?: User;
blacklistedTags?: string;
user: User;
}
export interface BlacklistResultsResponse extends PaginatedResponse {

View File

@@ -5,7 +5,6 @@ export interface GenreSliderItem {
}
export interface WatchlistItem {
id: number;
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';

View File

@@ -1,3 +0,0 @@
import type OverrideRule from '@server/entity/OverrideRule';
export type OverrideRuleResultsResponse = OverrideRule[];

View File

@@ -3,10 +3,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties, PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse {
results: (NonFunctionProperties<MediaRequest> & {
profileName?: string;
canRemove?: boolean;
})[];
results: NonFunctionProperties<MediaRequest>[];
}
export type MediaRequestBody = {

View File

@@ -29,24 +29,19 @@ export interface PublicSettingsResponse {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideBlacklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
discoverRegion: string;
streamingRegion: string;
region: string;
originalLanguage: string;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
cacheImages: boolean;
vapidPublic: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
youtubeUrl: string;
}
export interface CacheItem {

View File

@@ -5,8 +5,7 @@ export interface UserSettingsGeneralResponse {
email?: string;
discordId?: string;
locale?: string;
discoverRegion?: string;
streamingRegion?: string;
region?: string;
originalLanguage?: string;
movieQuotaLimit?: number;
movieQuotaDays?: number;
@@ -34,7 +33,6 @@ export interface UserSettingsNotificationsResponse {
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: string;
telegramMessageThreadId?: string;
telegramSendSilently?: boolean;
webPushEnabled?: boolean;
notificationTypes: Partial<NotificationAgentTypes>;

View File

@@ -1,184 +0,0 @@
import type { SortOptions } from '@server/api/themoviedb';
import { SortOptionsIterable } from '@server/api/themoviedb';
import type {
TmdbSearchMovieResponse,
TmdbSearchTvResponse,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;
public async run() {
this.running = true;
try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
label: 'Jobs',
});
} else {
throw err;
}
} finally {
this.reset();
}
}
public status(): StatusBase {
return {
running: this.running,
progress: this.progress,
total: this.total,
};
}
public cancel() {
this.running = false;
this.progress = 0;
this.total = 0;
}
private reset() {
this.cancel();
}
private async createBlacklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
if (blacklistedTags.length === 0) {
return;
}
// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
for (const type of [MediaType.MOVIE, MediaType.TV]) {
const getDiscover =
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
for (let query = 0; query < queryMax; query++) {
const page: number = fixedSortMode
? query + 1
: (query % pageLimit) + 1;
const sortBy: SortOptions | undefined = fixedSortMode
? undefined
: SortOptionsIterable[query % SortOptionsIterable.length];
if (!this.running) {
throw new AbortTransaction();
}
const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
}
}
}
}
private async processResults(
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
keywordId: string,
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);
for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
where: { tmdbId: entry.id },
});
if (blacklistEntry) {
// Don't mark manual blacklists with tags
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
{
blacklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
},
},
em
);
}
}
}
private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blacklistedTags IS NOT NULL`)
.getMany();
// Batch removes so the query doesn't get too large
for (let i = 0; i < mediaToRemove.length; i += 500) {
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
}
}
}
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
export default blacklistedTagsProcessor;

View File

@@ -1,9 +1,7 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import refreshToken from '@server/lib/refreshToken';
import {
jellyfinFullScanner,
jellyfinRecentScanner,
@@ -15,6 +13,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -22,7 +21,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -71,35 +70,6 @@ export const startJobs = (): void => {
running: () => plexFullScanner.status().running,
cancelFn: () => plexFullScanner.cancel(),
});
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();
}),
});
// Watchlist Sync
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'seconds',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
@@ -142,6 +112,31 @@ export const startJobs = (): void => {
});
}
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
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
scheduledJobs.push({
id: 'radarr-scan',
@@ -238,21 +233,5 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
type: 'process',
interval: 'days',
cronSchedule: jobs['process-blacklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blacklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

Some files were not shown because too many files have changed in this diff Show More