Compare commits

..

2 Commits

Author SHA1 Message Date
fallenbagel
4933748f2b refactor: cleans up and removes unncessary console.log statement 2023-11-19 10:28:14 +05:00
fallenbagel
2da404953b fix(middleware): enhanced user privacy on profile pages
Addresses a security vulnerability where the `/users/[:id]` route was accessible to users without
the necessary permissions. Adds middleware that protects that route so that only authenticated users
with the MANAGE_USERS and VIEW_WATCHLIST permissions can access other user's profile pages as
intended.

fix #569
2023-11-19 10:21:57 +05:00
292 changed files with 17083 additions and 34884 deletions

View File

@@ -277,132 +277,6 @@
"contributions": [ "contributions": [
"doc" "doc"
] ]
},
{
"login": "mdll23",
"name": "Michael Dallinger",
"avatar_url": "https://avatars.githubusercontent.com/u/142844478?v=4",
"profile": "https://github.com/mdll23",
"contributions": [
"translation"
]
},
{
"login": "xeruf",
"name": "Janek",
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
"profile": "https://github.com/xeruf",
"contributions": [
"doc"
]
},
{
"login": "aleksasiriski",
"name": "Aleksa Siriški",
"avatar_url": "https://avatars.githubusercontent.com/u/31509435?v=4",
"profile": "https://aleksasiriski.dev",
"contributions": [
"infra"
]
},
{
"login": "Danish-H",
"name": "Danish Humair",
"avatar_url": "https://avatars.githubusercontent.com/u/121830048?v=4",
"profile": "http://danishhumair.com",
"contributions": [
"code"
]
},
{
"login": "trackmastersteve",
"name": "Stephen Harris",
"avatar_url": "https://avatars.githubusercontent.com/u/16858514?v=4",
"profile": "https://arm0.red",
"contributions": [
"doc"
]
},
{
"login": "joshuaboniface",
"name": "Joshua M. Boniface",
"avatar_url": "https://avatars.githubusercontent.com/u/4031396?v=4",
"profile": "https://www.boniface.me",
"contributions": [
"code"
]
},
{
"login": "gauthier-th",
"name": "Gauthier",
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
"profile": "https://gauthierth.fr/",
"contributions": [
"code"
]
},
{
"login": "Kara-Zor-El",
"name": "Kara",
"avatar_url": "https://avatars.githubusercontent.com/u/69772087?v=4",
"profile": "https://github.com/Kara-Zor-El",
"contributions": [
"infra"
]
},
{
"login": "JoaquinOlivero",
"name": "Joaquin Olivero",
"avatar_url": "https://avatars.githubusercontent.com/u/66050823?v=4",
"profile": "https://joaquinolivero.com",
"contributions": [
"code"
]
},
{
"login": "Bretterteig",
"name": "Julian Behr",
"avatar_url": "https://avatars.githubusercontent.com/u/47298401?v=4",
"profile": "https://github.com/Bretterteig",
"contributions": [
"translation"
]
},
{
"login": "ThowZzy",
"name": "ThowZzy",
"avatar_url": "https://avatars.githubusercontent.com/u/61882536?v=4",
"profile": "https://github.com/ThowZzy",
"contributions": [
"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"
]
} }
] ]
} }

View File

@@ -5,7 +5,9 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
'plugin:jsx-a11y/recommended', 'plugin:jsx-a11y/recommended',
'plugin:@next/next/recommended', 'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:react/jsx-runtime',
'prettier', 'prettier',
], ],
parserOptions: { parserOptions: {

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
buy_me_a_coffee: fallen.bagel github: [Fallenbagel]

View File

@@ -13,35 +13,20 @@ jobs:
name: Lint & Test Build name: Lint & Test Build
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: node:20-alpine container: node:18.18-alpine
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
env: env:
HUSKY: 0 HUSKY: 0
run: pnpm install run: yarn
- name: Lint - name: Lint
run: pnpm lint run: yarn lint
- name: Formatting - name: Formatting
run: pnpm format:check run: yarn format:check
- name: Build - name: Build
run: pnpm build run: yarn build
build_and_push: build_and_push:
name: Build & Publish Docker Images name: Build & Publish Docker Images
@@ -49,18 +34,18 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -71,11 +56,11 @@ jobs:
env: env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
build-args: | build-args: |
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2

View File

@@ -1,26 +0,0 @@
name: Merge Conflict Labeler
on:
push:
branches:
- develop
pull_request_target:
branches:
- develop
types: [synchronize]
jobs:
label:
name: Labeling
runs-on: ubuntu-latest
if: ${{ github.repository == 'Fallenbagel/jellyseerr' }}
permissions:
contents: read
pull-requests: write
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@v3
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: '${{ secrets.GITHUB_TOKEN }}'

View File

@@ -13,20 +13,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v6 uses: cypress-io/github-action@v4
with: with:
build: pnpm cypress:build build: yarn cypress:build
start: pnpm start start: yarn start
wait-on: 'http://localhost:5055' wait-on: 'http://localhost:5055'
record: true record: true
env: env:

View File

@@ -11,25 +11,25 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Get the version - name: Get the version
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
push: true push: true
build-args: | build-args: |
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}

View File

@@ -10,39 +10,24 @@ jobs:
HUSKY: 0 HUSKY: 0
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 20 node-version: 16
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Pnpm Setup
uses: pnpm/action-setup@v4
with:
version: 9
- name: Get pnpm store directory
shell: sh
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install run: yarn
- name: Release - name: Release
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -50,59 +35,60 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: npx semantic-release run: npx semantic-release
# build-snap: build-snap:
# name: Build Snap Package (${{ matrix.architecture }}) name: Build Snap Package (${{ matrix.architecture }})
# needs: semantic-release needs: semantic-release
# runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
# strategy: strategy:
# fail-fast: false fail-fast: false
# matrix: matrix:
# architecture: architecture:
# - amd64 - amd64
# - arm64 - arm64
# steps: - armhf
# - name: Checkout Code steps:
# uses: actions/checkout@v4 - name: Checkout Code
# with: uses: actions/checkout@v3
# fetch-depth: 0 with:
# - name: Switch to main branch fetch-depth: 0
# run: git checkout main - name: Switch to main branch
# - name: Pull latest changes run: git checkout main
# run: git pull - name: Pull latest changes
# - name: Prepare run: git pull
# id: prepare - name: Prepare
# run: | id: prepare
# git fetch --prune --tags run: |
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then git fetch --prune --tags
# echo "RELEASE=stable" >> $GITHUB_OUTPUT if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
# else echo "RELEASE=stable" >> $GITHUB_OUTPUT
# echo "RELEASE=edge" >> $GITHUB_OUTPUT else
# fi echo "RELEASE=edge" >> $GITHUB_OUTPUT
# - name: Set Up QEMU fi
# uses: docker/setup-qemu-action@v3 - name: Set Up QEMU
# with: uses: docker/setup-qemu-action@v1
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde with:
# - name: Build Snap Package image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
# uses: diddlesnaps/snapcraft-multiarch-action@v1 - name: Build Snap Package
# id: build uses: diddlesnaps/snapcraft-multiarch-action@v1
# with: id: build
# architecture: ${{ matrix.architecture }} with:
# - name: Upload Snap Package architecture: ${{ matrix.architecture }}
# uses: actions/upload-artifact@v4 - name: Upload Snap Package
# with: uses: actions/upload-artifact@v2
# name: jellyseerr-snap-package-${{ matrix.architecture }} with:
# path: ${{ steps.build.outputs.snap }} name: jellyseerr-snap-package-${{ matrix.architecture }}
# - name: Review Snap Package path: ${{ steps.build.outputs.snap }}
# uses: diddlesnaps/snapcraft-review-tools-action@v1 - name: Review Snap Package
# with: uses: diddlesnaps/snapcraft-review-tools-action@v1
# snap: ${{ steps.build.outputs.snap }} with:
# - name: Publish Snap Package snap: ${{ steps.build.outputs.snap }}
# uses: snapcore/action-publish@v1 - name: Publish Snap Package
# env: uses: snapcore/action-publish@v1
# SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }} env:
# with: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
# snap: ${{ steps.build.outputs.snap }} with:
# release: ${{ steps.prepare.outputs.RELEASE }} snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
discord: discord:
name: Send Discord Notification name: Send Discord Notification

View File

@@ -1,13 +1,9 @@
name: Publish Snap name: Publish Snap
# turn off edge snap builds temporarily and make it manual on:
push:
# on: branches:
# push: - develop
# branches:
# - develop
on: workflow_dispatch
jobs: jobs:
jobs: jobs:
@@ -16,7 +12,7 @@ jobs:
if: "!contains(github.event.head_commit.message, '[skip ci]')" if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps: steps:
- name: Cancel Previous Runs - name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.12.1 uses: styfle/cancel-workflow-action@0.10.0
with: with:
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
@@ -30,9 +26,10 @@ jobs:
architecture: architecture:
- amd64 - amd64
- arm64 - arm64
- armhf
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Prepare - name: Prepare
id: prepare id: prepare
run: | run: |
@@ -43,7 +40,7 @@ jobs:
echo "RELEASE=edge" >> $GITHUB_OUTPUT echo "RELEASE=edge" >> $GITHUB_OUTPUT
fi fi
- name: Set Up QEMU - name: Set Up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v2
- name: Configure Git - name: Configure Git
run: git config --add safe.directory /data/parts/jellyseerr/src run: git config --add safe.directory /data/parts/jellyseerr/src
- name: Build Snap Package - name: Build Snap Package
@@ -52,7 +49,7 @@ jobs:
with: with:
architecture: ${{ matrix.architecture }} architecture: ${{ matrix.architecture }}
- name: Upload Snap Package - name: Upload Snap Package
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: jellyseerr-snap-package-${{ matrix.architecture }} name: jellyseerr-snap-package-${{ matrix.architecture }}
path: ${{ steps.build.outputs.snap }} path: ${{ steps.build.outputs.snap }}

View File

@@ -6,9 +6,9 @@ on:
jobs: jobs:
support: support:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
steps: steps:
- uses: dessant/support-requests@v4 - uses: dessant/support-requests@v2
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
support-label: 'support' support-label: 'support'

1
.npmrc
View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,4 +1,4 @@
# Contributing to Jellyseerr # Contributing to Overseerr
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started... All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
@@ -17,7 +17,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device: 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/jellyseerr.git git clone https://github.com/YOUR_USERNAME/overseerr.git
cd overseerr/ cd overseerr/
``` ```
@@ -97,9 +97,9 @@ When adding new UI text, please try to adhere to the following guidelines:
## Translation ## Translation
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). We use [Weblate](https://hosted.weblate.org/engage/overseerr/) 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> <a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
## Attribution ## Attribution

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS BUILD_IMAGE FROM node:18.18-alpine AS BUILD_IMAGE
WORKDIR /app WORKDIR /app
@@ -10,24 +10,22 @@ RUN \
'linux/arm64' | 'linux/arm/v7') \ 'linux/arm64' | 'linux/arm/v7') \
apk update && \ apk update && \
apk add --no-cache python3 make g++ gcc libc6-compat bash && \ apk add --no-cache python3 make g++ gcc libc6-compat bash && \
npm install --global node-gyp \ yarn global add node-gyp \
;; \ ;; \
esac esac
Run npm install --global pnpm COPY package.json yarn.lock ./
RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000
COPY package.json pnpm-lock.yaml ./
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
COPY . ./ COPY . ./
ARG COMMIT_TAG ARG COMMIT_TAG
ENV COMMIT_TAG=${COMMIT_TAG} ENV COMMIT_TAG=${COMMIT_TAG}
RUN pnpm build RUN yarn build
# remove development dependencies # remove development dependencies
RUN pnpm prune --prod --ignore-scripts RUN yarn install --production --ignore-scripts --prefer-offline
RUN rm -rf src server .next/cache RUN rm -rf src server .next/cache
@@ -36,7 +34,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:20-alpine FROM node:18.18-alpine
# Metadata for Github Package Registry # Metadata for Github Package Registry
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr" LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
@@ -49,6 +47,6 @@ RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
COPY --from=BUILD_IMAGE /app ./ COPY --from=BUILD_IMAGE /app ./
ENTRYPOINT [ "/sbin/tini", "--" ] ENTRYPOINT [ "/sbin/tini", "--" ]
CMD [ "pnpm", "start" ] CMD [ "yarn", "start" ]
EXPOSE 5055 EXPOSE 5055

View File

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

View File

@@ -2,28 +2,23 @@
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;"> <img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
</p> </p>
<p align="center"> <p align="center">
<img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/release.yml/badge.svg" alt="Jellyseerr Release" /> <a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
<img src="https://github.com/Fallenbagel/jellyseerr/actions/workflows/ci.yml/badge.svg" alt="Jellyseerr CI">
</p>
<p align="center">
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a> <a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-29-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. **Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!
It is a fork of [Overseerr](https://github.com/sct/overseerr) built to bring support for [Jellyfin](https://github.com/jellyfin/jellyfin) & [Emby](https://github.com/MediaBrowser/Emby) media servers!
_The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_ _The original Overseerr team have been busy and Jellyfin/Emby support aren't on their roadmap, so we started this project as we wanted to bring the Overseerr experience to the Jellyfin/Emby Community!_
## Current Features ## Current Features
- Full Jellyfin/Emby/Plex integration including authentication with user import & management - Full Jellyfin/Emby/Plex integration. Authenticate and manage user access with Jellyfin/Emby/Plex!
- Supports Movies, Shows and Mixed Libraries - Supports Movies, Shows, Mixed Libraries!
- Ability to change email addresses for smtp purposes - Ability to change email addresses for smtp purposes
- Ability to import all jellyfin/emby users
- Easy integration with your existing services. Currently, Jellyseerr supports Sonarr and Radarr. More to come! - 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. - 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. - Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface.
@@ -40,11 +35,11 @@ With more features on the way! Check out our [issue tracker](https://github.com/
#### Pre-requisite (Important) #### Pre-requisite (Important)
_*On Jellyfin/Emby, ensure the `Settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_ _*On Jellyfin/Emby, ensure the `settings > Home > Automatically group content from the following folders into views such as 'Movies', 'Music' and 'TV'` is turned off*_
### Launching Jellyseerr using Docker (Recommended) ### Launching Jellyseerr using Docker (Recommended)
Check out our docker hub for instructions on how to install and run Jellyseerr: Check out our dockerhub for instructions on how to install and run Jellyseerr:
https://hub.docker.com/r/fallenbagel/jellyseerr https://hub.docker.com/r/fallenbagel/jellyseerr
### Building from source (ADVANCED): ### Building from source (ADVANCED):
@@ -53,8 +48,8 @@ https://hub.docker.com/r/fallenbagel/jellyseerr
Pre-requisites: Pre-requisites:
- Nodejs [v20](https://nodejs.org/en/download) - Nodejs [v18](https://nodejs.org/download/release/v18.18.2)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install)
- Download/git clone the source code from the github (Either develop branch or main for stable) - Download/git clone the source code from the github (Either develop branch or main for stable)
```cmd ```cmd
@@ -64,17 +59,16 @@ yarn install --frozen-lockfile --network-timeout 1000000
yarn run build yarn run build
yarn start yarn start
``` ```
(you can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background)
(You can use task scheduler to run a bat script with `@echo off` and `yarn start` to run jellyseerr in the background) _to set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
_To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` in the root directory of jellyseerr_
#### Linux #### Linux
**Pre-requisites:** **Pre-requisites:**
- Nodejs [v20](https://nodejs.org/en/download) - Nodejs [v18](https://nodejs.org/en/download/package-manager)
- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on Debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`) - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (on debian based distros, the package manager provided `yarn` is different and is a package called cmdlet. You can remove that using `apt-remove cmdlet` then install yarn using `npm install -g yarn`)
- Git - Git
**Steps:** **Steps:**
@@ -85,7 +79,7 @@ _To set env variables such as `JELLYFIN_TYPE=emby` create a file called `.env` i
cd /opt cd /opt
``` ```
2. Then execute the following commands to clone and checkout to the stable version 2. Then clone the follow commands to clone and checkout to the stable version
```bash ```bash
git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr git clone https://github.com/Fallenbagel/jellyseerr.git && cd jellyseerr
@@ -104,9 +98,9 @@ yarn run build
5. If you want to run jellyseerr as a _Systemd-service:_ 5. If you want to run jellyseerr as a _Systemd-service:_
- assuming jellyseerr was cloned to `/opt/` - assuming jellyseerr was cloned to `/opt/`
- first create the environment file at `/etc/jellyseerr/jellyseerr.conf` - first create the environmentfile at `/etc/jellyseerr/jellyseerr.conf`
Environment file: Environmentfile:
``` ```
# Jellyseerr's default port is 5055, if you want to use both, change this. # Jellyseerr's default port is 5055, if you want to use both, change this.
@@ -142,7 +136,6 @@ ExecStart=/usr/bin/node dist/index.js
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
### Packages: ### Packages:
Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr) Archlinux: [AUR](https://aur.archlinux.org/packages/jellyseerr)
@@ -224,19 +217,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</tr> </tr>
<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></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></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>
</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></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://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>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -378,9 +358,6 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
</tr> </tr>
<tr> <tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td> <td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=j0srisk" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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>
</tr> </tr>
</tbody> </tbody>
</table> </table>

25
babel.config.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'next/babel',
{
'preset-env': {
useBuiltIns: 'entry',
corejs: '3',
},
},
],
],
plugins: [
[
'react-intl-auto',
{
removePrefix: 'src/',
},
],
],
};
};

View File

@@ -19,7 +19,6 @@
"region": "", "region": "",
"originalLanguage": "", "originalLanguage": "",
"trustProxy": false, "trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true, "partialRequestsEnabled": true,
"locale": "en" "locale": "en"
}, },
@@ -38,17 +37,6 @@
], ],
"machineId": "test" "machineId": "test"
}, },
"jellyfin": {
"name": "",
"ip": "",
"port": 8096,
"useSsl": false,
"urlBase": "",
"externalHostname": "",
"jellyfinForgotPasswordUrl": "",
"libraries": [],
"serverId": ""
},
"tautulli": {}, "tautulli": {},
"radarr": [], "radarr": [],
"sonarr": [], "sonarr": [],
@@ -151,26 +139,11 @@
"sonarr-scan": { "sonarr-scan": {
"schedule": "0 30 4 * * *" "schedule": "0 30 4 * * *"
}, },
"plex-watchlist-sync": {
"schedule": "0 */10 * * * *"
},
"availability-sync": {
"schedule": "0 0 5 * * *"
},
"download-sync": { "download-sync": {
"schedule": "0 * * * * *" "schedule": "0 * * * * *"
}, },
"download-sync-reset": { "download-sync-reset": {
"schedule": "0 0 1 * * *" "schedule": "0 0 1 * * *"
},
"jellyfin-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"jellyfin-full-scan": {
"schedule": "0 0 3 * * *"
},
"image-cache-cleanup": {
"schedule": "0 0 5 * * *"
} }
} }
} }

View File

@@ -2,6 +2,6 @@ import './commands';
before(() => { before(() => {
if (Cypress.env('SEED_DATABASE')) { if (Cypress.env('SEED_DATABASE')) {
cy.exec('pnpm cypress:prepare'); cy.exec('yarn cypress:prepare');
} }
}); });

View File

@@ -10,11 +10,7 @@ module.exports = {
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE, JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
}, },
images: { images: {
remotePatterns: [ domains: ['image.tmdb.org'],
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
],
}, },
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({

View File

@@ -368,9 +368,6 @@ components:
externalHostname: externalHostname:
type: string type: string
example: 'http://my.jellyfin.host' example: 'http://my.jellyfin.host'
jellyfinForgotPasswordUrl:
type: string
example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html'
adminUser: adminUser:
type: string type: string
example: 'admin' example: 'admin'
@@ -2092,13 +2089,6 @@ paths:
application/json: application/json:
schema: schema:
type: array type: array
items:
type: object
properties:
username:
type: string
userId:
type: integer
/settings/jellyfin/sync: /settings/jellyfin/sync:
get: get:
summary: Get status of full Jellyfin library sync summary: Get status of full Jellyfin library sync
@@ -3402,12 +3392,6 @@ paths:
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags: tags:
- settings - settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
requestBody: requestBody:
required: true required: true
content: content:
@@ -3737,7 +3721,7 @@ paths:
results: results:
type: array type: array
items: items:
$ref: '#/components/schemas/User' $ref: '#/components/schemas/User'
post: post:
summary: Create new user summary: Create new user
description: | description: |

View File

@@ -3,27 +3,26 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm",
"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", "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: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:next": "next build",
"build": "pnpm build:next && pnpm build:server", "build": "yarn build:next && yarn build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
"start": "NODE_ENV=production node dist/index.js", "start": "NODE_ENV=production node dist/index.js",
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts", "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", "migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --loglevel warn --write --cache .", "format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .", "format:check": "prettier --check --cache .",
"typecheck": "pnpm typecheck:server && pnpm typecheck:client", "typecheck": "yarn typecheck:server && yarn typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit", "typecheck:server": "tsc --project server/tsconfig.json --noEmit",
"typecheck:client": "tsc --noEmit", "typecheck:client": "tsc --noEmit",
"prepare": "husky install", "prepare": "husky install",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
"cypress:build": "pnpm build && pnpm cypress:prepare" "cypress:build": "yarn build && yarn cypress:prepare"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -35,7 +34,6 @@
"@formatjs/intl-locale": "3.1.1", "@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10", "@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-utils": "3.8.4", "@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12", "@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16", "@heroicons/react": "2.0.16",
"@supercharge/request-ip": "1.2.0", "@supercharge/request-ip": "1.2.0",
@@ -46,7 +44,6 @@
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"cacheable-lookup": "^7.0.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
@@ -61,10 +58,11 @@
"express-openapi-validator": "4.13.8", "express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",
"express-session": "1.17.3", "express-session": "1.17.3",
"formik": "^2.4.6", "formik": "2.2.9",
"gravatar-url": "3.1.0", "gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21", "lodash": "4.17.21",
"next": "^14.2.4", "next": "12.3.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "9.3.1",
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
@@ -72,13 +70,13 @@
"openpgp": "5.7.0", "openpgp": "5.7.0",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.2", "pug": "3.0.2",
"react": "^18.3.1", "react": "18.2.0",
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-animate-height": "2.1.2", "react-animate-height": "2.1.2",
"react-aria": "3.23.0", "react-aria": "3.23.0",
"react-dom": "^18.3.1", "react-dom": "18.2.0",
"react-intersection-observer": "9.4.3", "react-intersection-observer": "9.4.3",
"react-intl": "^6.6.8", "react-intl": "6.2.10",
"react-markdown": "8.0.5", "react-markdown": "8.0.5",
"react-popper-tooltip": "4.4.2", "react-popper-tooltip": "4.4.2",
"react-select": "5.7.0", "react-select": "5.7.0",
@@ -90,10 +88,9 @@
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3", "secure-random-password": "0.2.3",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "^0.33.4",
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.2.5", "swr": "2.0.4",
"typeorm": "0.3.12", "typeorm": "0.3.12",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
@@ -104,6 +101,7 @@
"zod": "3.20.6" "zod": "3.20.6"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4", "@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4", "@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2", "@semantic-release/changelog": "6.0.2",
@@ -124,8 +122,8 @@
"@types/node": "17.0.36", "@types/node": "17.0.36",
"@types/node-schedule": "2.1.0", "@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7", "@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3", "@types/react": "18.0.28",
"@types/react-dom": "^18.3.0", "@types/react-dom": "18.0.11",
"@types/react-transition-group": "4.4.5", "@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1", "@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13", "@types/semver": "7.3.13",
@@ -137,13 +135,15 @@
"@typescript-eslint/eslint-plugin": "5.54.0", "@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0", "@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13", "autoprefixer": "10.4.13",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.3.0", "commitizen": "4.3.0",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0", "cy-mobile-commands": "0.3.0",
"cypress": "12.7.0", "cypress": "12.7.0",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
"eslint": "8.35.0", "eslint": "8.35.0",
"eslint-config-next": "^14.2.4", "eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0", "eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0", "eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-jsx-a11y": "6.7.1",
@@ -151,6 +151,7 @@
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.1.2", "lint-staged": "13.1.2",
"nodemon": "2.0.20", "nodemon": "2.0.20",
@@ -166,12 +167,10 @@
"tsconfig-paths": "4.1.2", "tsconfig-paths": "4.1.2",
"typescript": "4.9.5" "typescript": "4.9.5"
}, },
"engines": { "resolutions": {
"node": "^20.0.0",
"pnpm": "^9.0.0"
},
"overrides": {
"sqlite3/node-gyp": "8.4.1", "sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6" "@types/express-session": "1.17.6"
}, },
"config": { "config": {
@@ -237,7 +236,8 @@
], ],
"platforms": [ "platforms": [
"linux/amd64", "linux/amd64",
"linux/arm64" "linux/arm64",
"linux/arm/v7"
] ]
}, },
"@semantic-release/github" "@semantic-release/github"

26267
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1,22 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import availabilitySync from '@server/lib/availabilitySync';
import logger from '@server/logger'; import logger from '@server/logger';
import { ApiError } from '@server/types/error'; import type { AxiosInstance } from 'axios';
import { getAppVersion } from '@server/utils/appVersion'; import axios from 'axios';
export interface JellyfinUserResponse { export interface JellyfinUserResponse {
Name: string; Name: string;
ServerId: string; ServerId: string;
ServerName: string; ServerName: string;
Id: string; Id: string;
Configuration: {
GroupedFolders: string[];
};
Policy: {
IsAdministrator: boolean;
};
PrimaryImageTag?: string; PrimaryImageTag?: string;
} }
@@ -29,13 +20,6 @@ export interface JellyfinUserListResponse {
users: JellyfinUserResponse[]; users: JellyfinUserResponse[];
} }
interface JellyfinMediaFolder {
Name: string;
Id: string;
Type: string;
CollectionType: string;
}
export interface JellyfinLibrary { export interface JellyfinLibrary {
type: 'show' | 'movie'; type: 'show' | 'movie';
key: string; key: string;
@@ -92,90 +76,48 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
DateCreated?: string; DateCreated?: string;
} }
class JellyfinAPI extends ExternalAPI { class JellyfinAPI {
private authToken?: string; private authToken?: string;
private userId?: string; private userId?: string;
private jellyfinHost: string; private jellyfinHost: string;
private axios: AxiosInstance;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
}
super(
jellyfinHost,
{},
{
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
this.jellyfinHost = jellyfinHost; this.jellyfinHost = jellyfinHost;
this.authToken = authToken; this.authToken = authToken;
let authHeaderVal = '';
if (this.authToken) {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0", Token="${authToken}"`;
} else {
authHeaderVal = `MediaBrowser Client="Overseerr", Device="Axios", DeviceId="${deviceId}", Version="10.8.0"`;
}
this.axios = axios.create({
baseURL: this.jellyfinHost,
headers: {
'X-Emby-Authorization': authHeaderVal,
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
} }
public async login( public async login(
Username?: string, Username?: string,
Password?: string, Password?: string
ClientIP?: string
): Promise<JellyfinLoginResponse> { ): Promise<JellyfinLoginResponse> {
const authenticate = async (useHeaders: boolean) => { try {
const headers = const account = await this.axios.post<JellyfinLoginResponse>(
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
return this.post<JellyfinLoginResponse>(
'/Users/AuthenticateByName', '/Users/AuthenticateByName',
{ {
Username, Username: Username,
Pw: Password, Pw: Password,
}, }
{ headers }
); );
}; return account.data;
try {
return await authenticate(true);
} catch (e) { } catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, { throw new Error('Unauthorized');
label: 'Jellyfin API',
ip: ClientIP,
});
}
try {
return await authenticate(false);
} catch (e) {
const status = e.response?.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);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
} }
} }
@@ -184,106 +126,69 @@ class JellyfinAPI extends ExternalAPI {
return; return;
} }
public async getSystemInfo(): Promise<any> {
try {
const systemInfoResponse = await this.get<any>('/System/Info');
return systemInfoResponse;
} catch (e) {
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
}
}
public async getServerName(): Promise<string> { public async getServerName(): Promise<string> {
try { try {
const serverResponse = await this.get<JellyfinUserResponse>( const account = await this.axios.get<JellyfinUserResponse>(
'/System/Info/Public' "/System/Info/Public'}"
); );
return account.data.ServerName;
return serverResponse.ServerName;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, `Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('girl idk');
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
} }
} }
public async getUsers(): Promise<JellyfinUserListResponse> { public async getUsers(): Promise<JellyfinUserListResponse> {
try { try {
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`); const account = await this.axios.get(`/Users`);
return { users: account.data };
return { users: userReponse };
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getUser(): Promise<JellyfinUserResponse> { public async getUser(): Promise<JellyfinUserResponse> {
try { try {
const userReponse = await this.get<JellyfinUserResponse>( const account = await this.axios.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}` `/Users/${this.userId ?? 'Me'}`
); );
return userReponse; return account.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`, `Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getLibraries(): Promise<JellyfinLibrary[]> { public async getLibraries(): Promise<JellyfinLibrary[]> {
try { try {
const mediaFolderResponse = await this.get<any>(`/Library/MediaFolders`); // TODO: Try to fix automatic grouping without fucking up LDAP users
// const libraries = await this.axios.get<any>('/Library/VirtualFolders');
return this.mapLibraries(mediaFolderResponse.Items); const account = await this.axios.get<any>(
} catch (mediaFoldersResponseError) { `/Users/${this.userId ?? 'Me'}/Views`
// fallback to user views to get libraries );
// this only and maybe/depending on factors affects LDAP users
try {
const mediaFolderResponse = await this.get<any>(
`/Users/${this.userId ?? 'Me'}/Views`
);
return this.mapLibraries(mediaFolderResponse.Items); const response: JellyfinLibrary[] = account.data.Items.filter(
} catch (e) { (Item: any) => {
logger.error( return (
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, Item.Type === 'CollectionFolder' &&
{ label: 'Jellyfin API' } Item.CollectionType !== 'music' &&
); Item.CollectionType !== 'books' &&
Item.CollectionType !== 'musicvideos' &&
return []; Item.CollectionType !== 'homevideos'
} );
} }
} ).map((Item: any) => {
private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] {
const excludedTypes = [
'music',
'books',
'musicvideos',
'homevideos',
'boxsets',
];
return mediaFolders
.filter((Item: JellyfinMediaFolder) => {
return (
Item.Type === 'CollectionFolder' &&
!excludedTypes.includes(Item.CollectionType)
);
})
.map((Item: JellyfinMediaFolder) => {
return <JellyfinLibrary>{ return <JellyfinLibrary>{
key: Item.Id, key: Item.Id,
title: Item.Name, title: Item.Name,
@@ -291,15 +196,24 @@ class JellyfinAPI extends ExternalAPI {
agent: 'jellyfin', agent: 'jellyfin',
}; };
}); });
return response;
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
}
} }
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> { public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const libraryItemsResponse = await this.get<any>( const contents = await this.axios.get<any>(
`/Users/${this.userId}/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}`
); );
return libraryItemsResponse.Items.filter( return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -307,64 +221,55 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> { public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
try { try {
const itemResponse = await this.get<any>( const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}` `/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
); );
return itemResponse; return contents.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
public async getItemData( public async getItemData(id: string): Promise<JellyfinLibraryItemExtended> {
id: string
): Promise<JellyfinLibraryItemExtended | undefined> {
try { try {
const itemResponse = await this.get<any>( const contents = await this.axios.get<any>(
`/Users/${this.userId}/Items/${id}` `/Users/${this.userId}/Items/${id}`
); );
return itemResponse; return contents.data;
} catch (e) { } catch (e) {
if (availabilitySync.running) {
if (e.response && e.response.status === 500) {
return undefined;
}
}
logger.error( logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`, `Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); throw new Error('Invalid auth token');
} }
} }
public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> { public async getSeasons(seriesID: string): Promise<JellyfinLibraryItem[]> {
try { try {
const seasonResponse = await this.get<any>(`/Shows/${seriesID}/Seasons`); const contents = await this.axios.get<any>(`/Shows/${seriesID}/Seasons`);
return seasonResponse.Items; return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
);
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
@@ -373,11 +278,11 @@ class JellyfinAPI extends ExternalAPI {
seasonID: string seasonID: string
): Promise<JellyfinLibraryItem[]> { ): Promise<JellyfinLibraryItem[]> {
try { try {
const episodeResponse = await this.get<any>( const contents = await this.axios.get<any>(
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}` `/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
); );
return episodeResponse.Items.filter( return contents.data.Items.filter(
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
); );
} catch (e) { } catch (e) {
@@ -385,8 +290,7 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );
throw new Error('Invalid auth token');
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
} }
} }
} }

View File

@@ -1,9 +0,0 @@
export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
NotAdmin = 'NOT_ADMIN',
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unknown = 'UNKNOWN',
}

View File

@@ -9,7 +9,6 @@ import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
import { import {
AfterLoad, AfterLoad,
Column, Column,
@@ -152,11 +151,11 @@ class Media {
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null; public ratingKey4k?: string | null;
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true })
public jellyfinMediaId?: string | null; public jellyfinMediaId?: string;
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true })
public jellyfinMediaId4k?: string | null; public jellyfinMediaId4k?: string;
public serviceUrl?: string; public serviceUrl?: string;
public serviceUrl4k?: string; public serviceUrl4k?: string;
@@ -212,12 +211,15 @@ class Media {
} else { } else {
const pageName = const pageName =
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details'; process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
const { serverId, externalHostname } = getSettings().jellyfin; const { serverId, hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: getHostname(); : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (this.jellyfinMediaId) { if (this.jellyfinMediaId) {
this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`;

View File

@@ -23,25 +23,19 @@ import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import type CacheableLookupType from 'cacheable-lookup';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csurf from 'csurf'; import csurf from 'csurf';
import { lookup } from 'dns';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import express from 'express'; import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator'; import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session'; import type { Store } from 'express-session';
import session from 'express-session'; import session from 'express-session';
import next from 'next'; import next from 'next';
import http from 'node:http';
import https from 'node:https';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs'; import YAML from 'yamljs';
const _importDynamic = new Function('modulePath', 'return import(modulePath)');
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`); logger.info(`Starting Overseerr version ${getAppVersion()}`);
@@ -52,25 +46,6 @@ const handle = app.getRequestHandler();
app app
.prepare() .prepare()
.then(async () => { .then(async () => {
const CacheableLookup = (await _importDynamic('cacheable-lookup'))
.default as typeof CacheableLookupType;
const cacheable = new CacheableLookup();
const originalLookup = cacheable.lookup;
// if hostname is localhost use dns.lookup instead of cacheable-lookup
cacheable.lookup = (...args: any) => {
const [hostname] = args;
if (hostname === 'localhost') {
lookup(...(args as Parameters<typeof lookup>));
} else {
originalLookup(...(args as Parameters<typeof originalLookup>));
}
};
cacheable.install(http.globalAgent);
cacheable.install(https.globalAgent);
const dbConnection = await dataSource.initialize(); const dbConnection = await dataSource.initialize();
// Run migrations in production // Run migrations in production
@@ -146,7 +121,7 @@ app
try { try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) { if (descriptor?.writable === true) {
(req as any).ip = getClientIp(req) ?? ''; req.ip = getClientIp(req) ?? '';
} }
} catch (e) { } catch (e) {
logger.error('Failed to attach the ip to the request', { logger.error('Failed to attach the ip to the request', {

View File

@@ -24,7 +24,6 @@ export interface PublicSettingsResponse {
jellyfinHost?: string; jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
jellyfinForgotPasswordUrl?: string;
initialized: boolean; initialized: boolean;
applicationTitle: string; applicationTitle: string;
applicationUrl: string; applicationUrl: string;

View File

@@ -12,7 +12,6 @@ import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock'; import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto'; import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
@@ -27,7 +26,7 @@ interface SyncStatus {
libraries: Library[]; libraries: Library[];
} }
class JellyfinScanner { class JobJellyfinSync {
private sessionId: string; private sessionId: string;
private tmdb: TheMovieDb; private tmdb: TheMovieDb;
private jfClient: JellyfinAPI; private jfClient: JellyfinAPI;
@@ -63,7 +62,7 @@ class JellyfinScanner {
const metadata = await this.jfClient.getItemData(jellyfinitem.Id); const metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media(); const newMedia = new Media();
if (!metadata?.Id) { if (!metadata.Id) {
logger.debug('No Id metadata for this title. Skipping', { logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync', label: 'Plex Sync',
ratingKey: jellyfinitem.Id, ratingKey: jellyfinitem.Id,
@@ -84,17 +83,13 @@ class JellyfinScanner {
} }
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter( return MediaSource.MediaStreams.some((MediaStream) => {
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) > 2000; return (MediaStream.Width ?? 0) > 2000;
}); });
}); });
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => { const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter( return MediaSource.MediaStreams.some((MediaStream) => {
(MediaStream) => MediaStream.Type === 'Video'
).some((MediaStream) => {
return (MediaStream.Width ?? 0) <= 2000; return (MediaStream.Width ?? 0) <= 2000;
}); });
}); });
@@ -173,9 +168,9 @@ class JellyfinScanner {
newMedia.jellyfinMediaId = newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k) hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id ? metadata.Id
: null; : undefined;
newMedia.jellyfinMediaId4k = newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : null; has4k && this.enable4kMovie ? metadata.Id : undefined;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`); this.log(`Saved ${metadata.Name}`);
} }
@@ -202,14 +197,6 @@ class JellyfinScanner {
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id; jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
const metadata = await this.jfClient.getItemData(Id); const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', {
label: 'Plex Sync',
ratingKey: jellyfinitem.Id,
});
return;
}
if (metadata.ProviderIds.Tvdb) { if (metadata.ProviderIds.Tvdb) {
tvShow = await this.tmdb.getShowByTvdbId({ tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: Number(metadata.ProviderIds.Tvdb), tvdbId: Number(metadata.ProviderIds.Tvdb),
@@ -288,7 +275,7 @@ class JellyfinScanner {
episode.Id episode.Id
); );
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => { ExtendedEpisodeData.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.some((MediaStream) => { return MediaSource.MediaStreams.some((MediaStream) => {
if (MediaStream.Type === 'Video') { if (MediaStream.Type === 'Video') {
if ((MediaStream.Width ?? 0) >= 2000) { if ((MediaStream.Width ?? 0) >= 2000) {
@@ -466,9 +453,8 @@ class JellyfinScanner {
tmdbId: tvShow.id, tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id, tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''), mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: isAllStandardSeasons ? Id : null, jellyfinMediaId: Id,
jellyfinMediaId4k: jellyfinMediaId4k: Id,
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons status: isAllStandardSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: newSeasons.some( : newSeasons.some(
@@ -595,10 +581,8 @@ class JellyfinScanner {
return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
} }
const hostname = getHostname();
this.jfClient = new JellyfinAPI( this.jfClient = new JellyfinAPI(
hostname, settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken, admin.jellyfinAuthToken,
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );
@@ -691,7 +675,7 @@ class JellyfinScanner {
} }
} }
export const jellyfinFullScanner = new JellyfinScanner(); export const jobJellyfinFullSync = new JobJellyfinSync();
export const jellyfinRecentScanner = new JellyfinScanner({ export const jobJellyfinRecentSync = new JobJellyfinSync({
isRecentOnly: true, isRecentOnly: true,
}); });

View File

@@ -1,11 +1,6 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import {
jellyfinFullScanner,
jellyfinRecentScanner,
} from '@server/lib/scanners/jellyfin';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr'; import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -15,6 +10,7 @@ import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger'; import logger from '@server/logger';
import random from 'lodash/random'; import random from 'lodash/random';
import schedule from 'node-schedule'; import schedule from 'node-schedule';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
interface ScheduledJob { interface ScheduledJob {
id: JobId; id: JobId;
@@ -77,38 +73,38 @@ export const startJobs = (): void => {
// Run recently added jellyfin sync every 5 minutes // Run recently added jellyfin sync every 5 minutes
scheduledJobs.push({ scheduledJobs.push({
id: 'jellyfin-recently-added-scan', id: 'jellyfin-recently-added-scan',
name: 'Jellyfin Recently Added Scan', name: 'Jellyfin Recently Added Sync',
type: 'process', type: 'process',
interval: 'minutes', interval: 'minutes',
cronSchedule: jobs['jellyfin-recently-added-scan'].schedule, cronSchedule: jobs['jellyfin-recently-added-scan'].schedule,
job: schedule.scheduleJob( job: schedule.scheduleJob(
jobs['jellyfin-recently-added-scan'].schedule, jobs['jellyfin-recently-added-scan'].schedule,
() => { () => {
logger.info('Starting scheduled job: Jellyfin Recently Added Scan', { logger.info('Starting scheduled job: Jellyfin Recently Added Sync', {
label: 'Jobs', label: 'Jobs',
}); });
jellyfinRecentScanner.run(); jobJellyfinRecentSync.run();
} }
), ),
running: () => jellyfinRecentScanner.status().running, running: () => jobJellyfinRecentSync.status().running,
cancelFn: () => jellyfinRecentScanner.cancel(), cancelFn: () => jobJellyfinRecentSync.cancel(),
}); });
// Run full jellyfin sync every 24 hours // Run full jellyfin sync every 24 hours
scheduledJobs.push({ scheduledJobs.push({
id: 'jellyfin-full-scan', id: 'jellyfin-full-scan',
name: 'Jellyfin Full Library Scan', name: 'Jellyfin Full Library Sync',
type: 'process', type: 'process',
interval: 'hours', interval: 'hours',
cronSchedule: jobs['jellyfin-full-scan'].schedule, cronSchedule: jobs['jellyfin-full-scan'].schedule,
job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => { job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Jellyfin Full Scan', { logger.info('Starting scheduled job: Jellyfin Full Sync', {
label: 'Jobs', label: 'Jobs',
}); });
jellyfinFullScanner.run(); jobJellyfinFullSync.run();
}), }),
running: () => jellyfinFullScanner.status().running, running: () => jobJellyfinFullSync.status().running,
cancelFn: () => jellyfinFullScanner.cancel(), cancelFn: () => jobJellyfinFullSync.cancel(),
}); });
} }
@@ -168,7 +164,7 @@ export const startJobs = (): void => {
}); });
// Checks if media is still available in plex/sonarr/radarr libs // Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({ /* scheduledJobs.push({
id: 'availability-sync', id: 'availability-sync',
name: 'Media Availability Sync', name: 'Media Availability Sync',
type: 'process', type: 'process',
@@ -183,6 +179,7 @@ export const startJobs = (): void => {
running: () => availabilitySync.running, running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(), cancelFn: () => availabilitySync.cancel(),
}); });
*/
// Run download sync every minute // Run download sync every minute
scheduledJobs.push({ scheduledJobs.push({

View File

@@ -1,12 +1,9 @@
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import type { PlexMetadata } from '@server/api/plexapi'; import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest'; import MediaRequest from '@server/entity/MediaRequest';
@@ -16,26 +13,19 @@ import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { getHostname } from '@server/utils/getHostname';
class AvailabilitySync { class AvailabilitySync {
public running = false; public running = false;
private plexClient: PlexAPI; private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]>; private plexSeasonsCache: Record<string, PlexMetadata[]>;
private jellyfinClient: JellyfinAPI;
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
private sonarrSeasonsCache: Record<string, SonarrSeason[]>; private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
private radarrServers: RadarrSettings[]; private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[]; private sonarrServers: SonarrSettings[];
async run() { async run() {
const settings = getSettings(); const settings = getSettings();
const mediaServerType = getSettings().main.mediaServerType;
this.running = true; this.running = true;
this.plexSeasonsCache = {}; this.plexSeasonsCache = {};
this.jellyfinSeasonsCache = {};
this.sonarrSeasonsCache = {}; this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
@@ -47,53 +37,13 @@ class AvailabilitySync {
const pageSize = 50; const pageSize = 50;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
// If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID if (admin) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
let admin = null;
if (mediaServerType === MediaServerType.PLEX) {
admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
admin = await userRepository.findOne({
where: { id: 1 },
select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' },
});
}
if (mediaServerType === MediaServerType.PLEX) {
if (admin && admin.plexToken) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
} else {
logger.error('Plex admin is not configured.');
}
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
getHostname(),
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);
this.jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
} else {
logger.error('Jellyfin admin is not configured.');
}
} else { } else {
logger.error('An admin is not configured.'); logger.error('An admin is not configured.');
} }
@@ -110,84 +60,41 @@ class AvailabilitySync {
let movieExists = false; let movieExists = false;
let movieExists4k = false; let movieExists4k = false;
// if (mediaServerType === MediaServerType.PLEX) { const { existsInPlex } = await this.mediaExistsInPlex(media, false);
// await this.mediaExistsInPlex(media, false); const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
// } else if ( media,
// mediaServerType === MediaServerType.JELLYFIN || true
// mediaServerType === MediaServerType.EMBY );
// ) {
// await this.mediaExistsInJellyfin(media, false);
// }
const existsInRadarr = await this.mediaExistsInRadarr(media, false); const existsInRadarr = await this.mediaExistsInRadarr(media, false);
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
// plex if (existsInPlex || existsInRadarr) {
if (mediaServerType === MediaServerType.PLEX) { movieExists = true;
const { existsInPlex } = await this.mediaExistsInPlex(media, false); logger.info(
const { existsInPlex: existsInPlex4k } = `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
await this.mediaExistsInPlex(media, true); {
label: 'AvailabilitySync',
if (existsInPlex || existsInRadarr) { }
movieExists = true; );
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
//jellyfin if (existsInPlex4k || existsInRadarr4k) {
if ( movieExists4k = true;
mediaServerType === MediaServerType.JELLYFIN || logger.info(
mediaServerType === MediaServerType.EMBY `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
) { {
const { existsInJellyfin } = await this.mediaExistsInJellyfin( label: 'AvailabilitySync',
media, }
false
); );
const { existsInJellyfin: existsInJellyfin4k } =
await this.mediaExistsInJellyfin(media, true);
if (existsInJellyfin || existsInRadarr) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInJellyfin4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
if (!movieExists && media.status === MediaStatus.AVAILABLE) { if (!movieExists && media.status === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, false, mediaServerType); await this.mediaUpdater(media, false);
} }
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, true, mediaServerType); await this.mediaUpdater(media, true);
} }
} }
@@ -197,8 +104,6 @@ class AvailabilitySync {
let showExists = false; let showExists = false;
let showExists4k = false; let showExists4k = false;
//plex
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
await this.mediaExistsInPlex(media, false); await this.mediaExistsInPlex(media, false);
const { const {
@@ -206,16 +111,6 @@ class AvailabilitySync {
seasonsMap: plexSeasonsMap4k = new Map(), seasonsMap: plexSeasonsMap4k = new Map(),
} = await this.mediaExistsInPlex(media, true); } = await this.mediaExistsInPlex(media, true);
//jellyfin
const {
existsInJellyfin,
seasonsMap: jellyfinSeasonsMap = new Map(),
} = await this.mediaExistsInJellyfin(media, false);
const {
existsInJellyfin: existsInJellyfin4k,
seasonsMap: jellyfinSeasonsMap4k = new Map(),
} = await this.mediaExistsInJellyfin(media, true);
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
await this.mediaExistsInSonarr(media, false); await this.mediaExistsInSonarr(media, false);
const { const {
@@ -223,60 +118,24 @@ class AvailabilitySync {
seasonsMap: sonarrSeasonsMap4k, seasonsMap: sonarrSeasonsMap4k,
} = await this.mediaExistsInSonarr(media, true); } = await this.mediaExistsInSonarr(media, true);
//plex if (existsInPlex || existsInSonarr) {
if (mediaServerType === MediaServerType.PLEX) { showExists = true;
if (existsInPlex || existsInSonarr) { logger.info(
showExists = true; `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
logger.info( {
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, label: 'AvailabilitySync',
{ }
label: 'AvailabilitySync', );
}
);
}
} }
if (mediaServerType === MediaServerType.PLEX) { if (existsInPlex4k || existsInSonarr4k) {
if (existsInPlex4k || existsInSonarr4k) { showExists4k = true;
showExists4k = true; logger.info(
logger.info( `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, {
{ label: 'AvailabilitySync',
label: 'AvailabilitySync', }
} );
);
}
}
//jellyfin
if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (existsInJellyfin || existsInSonarr) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
}
if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
if (existsInJellyfin4k || existsInSonarr4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
} }
// Here we will create a final map that will cross compare // Here we will create a final map that will cross compare
@@ -296,45 +155,11 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false) filteredSeasonsMap.set(season.seasonNumber, false)
); );
// non-4k const finalSeasons = new Map([
const finalSeasons: Map<number, boolean> = new Map(); ...filteredSeasonsMap,
...plexSeasonsMap,
if (mediaServerType === MediaServerType.PLEX) { ...sonarrSeasonsMap,
plexSeasonsMap.forEach((value, key) => { ]);
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map(); const filteredSeasonsMap4k: Map<number, boolean> = new Map();
@@ -348,64 +173,18 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false) filteredSeasonsMap4k.set(season.seasonNumber, false)
); );
// 4k const finalSeasons4k = new Map([
const finalSeasons4k: Map<number, boolean> = new Map(); ...filteredSeasonsMap4k,
...plexSeasonsMap4k,
if (mediaServerType === MediaServerType.PLEX) { ...sonarrSeasonsMap4k,
plexSeasonsMap4k.forEach((value, key) => { ]);
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
}
// TODO: Figure out how to run seasonUpdater for each season
if ([...finalSeasons.values()].includes(false)) { if ([...finalSeasons.values()].includes(false)) {
await this.seasonUpdater( await this.seasonUpdater(media, finalSeasons, false);
media,
finalSeasons,
false,
mediaServerType
);
} }
if ([...finalSeasons4k.values()].includes(false)) { if ([...finalSeasons4k.values()].includes(false)) {
await this.seasonUpdater( await this.seasonUpdater(media, finalSeasons4k, true);
media,
finalSeasons4k,
true,
mediaServerType
);
} }
if ( if (
@@ -413,7 +192,7 @@ class AvailabilitySync {
(media.status === MediaStatus.AVAILABLE || (media.status === MediaStatus.AVAILABLE ||
media.status === MediaStatus.PARTIALLY_AVAILABLE) media.status === MediaStatus.PARTIALLY_AVAILABLE)
) { ) {
await this.mediaUpdater(media, false, mediaServerType); await this.mediaUpdater(media, false);
} }
if ( if (
@@ -421,7 +200,7 @@ class AvailabilitySync {
(media.status4k === MediaStatus.AVAILABLE || (media.status4k === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE) media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
) { ) {
await this.mediaUpdater(media, true, mediaServerType); await this.mediaUpdater(media, true);
} }
} }
} }
@@ -493,11 +272,7 @@ class AvailabilitySync {
return mediaStatus; return mediaStatus;
} }
private async mediaUpdater( private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
media: Media,
is4k: boolean,
mediaServerType: MediaServerType
): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
@@ -545,32 +320,17 @@ class AvailabilitySync {
mediaStatus === MediaStatus.PROCESSING mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null; : null;
if (mediaServerType === MediaServerType.PLEX) { media[is4k ? 'ratingKey4k' : 'ratingKey'] =
media[is4k ? 'ratingKey4k' : 'ratingKey'] = mediaStatus === MediaStatus.PROCESSING
mediaStatus === MediaStatus.PROCESSING ? media[is4k ? 'ratingKey4k' : 'ratingKey']
? media[is4k ? 'ratingKey4k' : 'ratingKey'] : null;
: null;
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
: null;
}
logger.info( logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${ `The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show' media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${ } [TMDB ID ${media.tmdbId}] was not found in any ${
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
} and ${ } and Plex instance. Status will be changed to unknown.`,
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
} instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
@@ -598,8 +358,7 @@ class AvailabilitySync {
private async seasonUpdater( private async seasonUpdater(
media: Media, media: Media,
seasons: Map<number, boolean>, seasons: Map<number, boolean>,
is4k: boolean, is4k: boolean
mediaServerType: MediaServerType
): Promise<void> { ): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const seasonRequestRepository = getRepository(SeasonRequest); const seasonRequestRepository = getRepository(SeasonRequest);
@@ -611,8 +370,6 @@ class AvailabilitySync {
); );
const seasonKeys = [...seasonsPendingRemoval.keys()]; const seasonKeys = [...seasonsPendingRemoval.keys()];
// let isSeasonRemoved = false;
try { try {
// Need to check and see if there are any related season // Need to check and see if there are any related season
// requests. If they are, we will need to delete them. // requests. If they are, we will need to delete them.
@@ -663,13 +420,7 @@ class AvailabilitySync {
media.tmdbId media.tmdbId
}] was not found in any ${ }] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and ${ } and Plex instance. Status will be changed to unknown.`,
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
} instance. Status will be changed to unknown.`,
{ label: 'AvailabilitySync' } { label: 'AvailabilitySync' }
); );
} catch (ex) { } catch (ex) {
@@ -853,7 +604,6 @@ class AvailabilitySync {
return seasonExists; return seasonExists;
} }
// Plex
private async mediaExistsInPlex( private async mediaExistsInPlex(
media: Media, media: Media,
is4k: boolean is4k: boolean
@@ -969,123 +719,6 @@ class AvailabilitySync {
return seasonExistsInPlex; return seasonExistsInPlex;
} }
// Jellyfin
private async mediaExistsInJellyfin(
media: Media,
is4k: boolean
): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map<number, boolean> }> {
const ratingKey = media.jellyfinMediaId;
const ratingKey4k = media.jellyfinMediaId4k;
let existsInJellyfin = false;
let preventSeasonSearch = false;
// Check each jellyfin instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInJellyfin
try {
let jellyfinMedia: JellyfinLibraryItem | undefined;
if (ratingKey && !is4k) {
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey);
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
this.jellyfinSeasonsCache[ratingKey] =
await this.jellyfinClient?.getSeasons(ratingKey);
}
}
if (ratingKey4k && is4k) {
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k);
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
this.jellyfinSeasonsCache[ratingKey4k] =
await this.jellyfinClient?.getSeasons(ratingKey4k);
}
}
if (jellyfinMedia) {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
// Here we check each season in jellyfin for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
if (media.mediaType === 'tv') {
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInJellyfin(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInJellyfin, seasonsMap };
}
return { existsInJellyfin };
}
private async seasonExistsInJellyfin(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
const ratingKey = media.jellyfinMediaId;
const ratingKey4k = media.jellyfinMediaId4k;
let seasonExistsInJellyfin = false;
// Check each jellyfin instance to see if the season exists
let jellyfinSeasons: JellyfinLibraryItem[] | undefined;
if (ratingKey && !is4k) {
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey];
}
if (ratingKey4k && is4k) {
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k];
}
const seasonIsAvailable = jellyfinSeasons?.find(
(jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber
);
if (seasonIsAvailable) {
seasonExistsInJellyfin = true;
}
return seasonExistsInJellyfin;
}
} }
const availabilitySync = new AvailabilitySync(); const availabilitySync = new AvailabilitySync();

View File

@@ -14,12 +14,7 @@ import {
import type { NotificationAgent, NotificationPayload } from './agent'; import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent'; import { BaseAgent } from './agent';
interface PushoverImagePayload { interface PushoverPayload {
attachment_base64: string;
attachment_type: string;
}
interface PushoverPayload extends PushoverImagePayload {
token: string; token: string;
user: string; user: string;
title: string; title: string;
@@ -48,36 +43,10 @@ class PushoverAgent
return true; return true;
} }
private async getImagePayload( private getNotificationPayload(
imageUrl: string
): Promise<Partial<PushoverImagePayload>> {
try {
const response = await axios.get(imageUrl, {
responseType: 'arraybuffer',
});
const base64 = Buffer.from(response.data, 'binary').toString('base64');
const contentType = (
response.headers['Content-Type'] || response.headers['content-type']
)?.toString();
return {
attachment_base64: base64,
attachment_type: contentType,
};
} catch (e) {
logger.error('Error getting image payload', {
label: 'Notifications',
errorMessage: e.message,
response: e.response?.data,
});
return {};
}
}
private async getNotificationPayload(
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Promise<Partial<PushoverPayload>> { ): Partial<PushoverPayload> {
const { applicationUrl, applicationTitle } = getSettings().main; const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject; const title = payload.event ?? payload.subject;
@@ -153,16 +122,6 @@ class PushoverAgent
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}` ? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined; : undefined;
let attachment_base64;
let attachment_type;
if (payload.image) {
const imagePayload = await this.getImagePayload(payload.image);
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
attachment_base64 = imagePayload.attachment_base64;
attachment_type = imagePayload.attachment_type;
}
}
return { return {
title, title,
message, message,
@@ -170,8 +129,6 @@ class PushoverAgent
url_title, url_title,
priority, priority,
html: 1, html: 1,
attachment_base64,
attachment_type,
}; };
} }
@@ -181,10 +138,7 @@ class PushoverAgent
): Promise<boolean> { ): Promise<boolean> {
const settings = this.getSettings(); const settings = this.getSettings();
const endpoint = 'https://api.pushover.net/1/messages.json'; const endpoint = 'https://api.pushover.net/1/messages.json';
const notificationPayload = await this.getNotificationPayload( const notificationPayload = this.getNotificationPayload(type, payload);
type,
payload
);
// Send system notification // Send system notification
if ( if (

View File

@@ -141,7 +141,7 @@ class WebhookAgent
const payloadString = Buffer.from( const payloadString = Buffer.from(
this.getSettings().options.jsonPayload, this.getSettings().options.jsonPayload,
'base64' 'base64'
).toString('utf8'); ).toString('ascii');
const parsedJSON = JSON.parse(JSON.parse(payloadString)); const parsedJSON = JSON.parse(JSON.parse(payloadString));

View File

@@ -1,11 +1,10 @@
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
import { Permission } from './permissions';
export interface Library { export interface Library {
id: string; id: string;
@@ -39,12 +38,8 @@ export interface PlexSettings {
export interface JellyfinSettings { export interface JellyfinSettings {
name: string; name: string;
ip: string; hostname: string;
port: number;
useSsl?: boolean;
urlBase?: string;
externalHostname?: string; externalHostname?: string;
jellyfinForgotPasswordUrl?: string;
libraries: Library[]; libraries: Library[];
serverId: string; serverId: string;
} }
@@ -134,8 +129,8 @@ interface FullPublicSettings extends PublicSettings {
region: string; region: string;
originalLanguage: string; originalLanguage: string;
mediaServerType: number; mediaServerType: number;
jellyfinHost?: string;
jellyfinExternalHost?: string; jellyfinExternalHost?: string;
jellyfinForgotPasswordUrl?: string;
jellyfinServerName?: string; jellyfinServerName?: string;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean; cacheImages: boolean;
@@ -277,7 +272,7 @@ export type JobId =
| 'image-cache-cleanup' | 'image-cache-cleanup'
| 'availability-sync'; | 'availability-sync';
export interface AllSettings { interface AllSettings {
clientId: string; clientId: string;
vapidPublic: string; vapidPublic: string;
vapidPrivate: string; vapidPrivate: string;
@@ -294,7 +289,7 @@ export interface AllSettings {
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/settings.json` ? `${process.env.CONFIG_DIRECTORY}/settings.json`
: path.join(__dirname, '../../../config/settings.json'); : path.join(__dirname, '../../config/settings.json');
class Settings { class Settings {
private data: AllSettings; private data: AllSettings;
@@ -334,12 +329,8 @@ class Settings {
}, },
jellyfin: { jellyfin: {
name: '', name: '',
ip: '', hostname: '',
port: 8096,
useSsl: false,
urlBase: '',
externalHostname: '', externalHostname: '',
jellyfinForgotPasswordUrl: '',
libraries: [], libraries: [],
serverId: '', serverId: '',
}, },
@@ -543,7 +534,6 @@ class Settings {
applicationUrl: this.data.main.applicationUrl, applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable, hideAvailable: this.data.main.hideAvailable,
localLogin: this.data.main.localLogin, localLogin: this.data.main.localLogin,
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
movie4kEnabled: this.data.radarr.some( movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault (radarr) => radarr.is4k && radarr.isDefault
), ),
@@ -553,6 +543,8 @@ class Settings {
region: this.data.main.region, region: this.data.main.region,
originalLanguage: this.data.main.originalLanguage, originalLanguage: this.data.main.originalLanguage,
mediaServerType: this.main.mediaServerType, mediaServerType: this.main.mediaServerType,
jellyfinHost: this.jellyfin.hostname,
jellyfinExternalHost: this.jellyfin.externalHostname,
partialRequestsEnabled: this.data.main.partialRequestsEnabled, partialRequestsEnabled: this.data.main.partialRequestsEnabled,
cacheImages: this.data.main.cacheImages, cacheImages: this.data.main.cacheImages,
vapidPublic: this.vapidPublic, vapidPublic: this.vapidPublic,
@@ -641,11 +633,7 @@ class Settings {
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
const parsedJson = JSON.parse(data); this.data = merge(this.data, JSON.parse(data));
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
this.save(); this.save();
} }
return this; return this;

View File

@@ -1,30 +0,0 @@
import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin;
if (oldJellyfinSettings && oldJellyfinSettings.hostname) {
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname;
if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = {
...settings.jellyfin,
ip,
port: port || (useSsl ? 443 : 80),
useSsl,
urlBase: urlBase ? urlBase.replace(/\/$/, '') : '',
};
}
}
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings;
};
export default migrateHostname;

View File

@@ -1,21 +0,0 @@
import type { AllSettings } from '@server/lib/settings';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
// eslint-disable-next-line @typescript-eslint/no-var-requires
.map((file) => require(path.join(migrationsDir, file)).default);
let migrated = settings;
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;
};

View File

@@ -1,6 +1,5 @@
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
@@ -10,12 +9,8 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import net from 'net';
const authRoutes = Router(); const authRoutes = Router();
@@ -223,39 +218,30 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
username?: string; username?: string;
password?: string; password?: string;
hostname?: string; hostname?: string;
port?: number;
urlBase?: string;
useSsl?: boolean;
email?: string; email?: string;
}; };
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured //Make sure jellyfin login is enabled, but only if jellyfin is not already configured
if ( if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED settings.jellyfin.hostname !== ''
) { ) {
return res.status(500).json({ error: 'Jellyfin login is disabled' }); return res.status(500).json({ error: 'Jellyfin login is disabled' });
} else if (!body.username) { } else if (!body.username) {
return res.status(500).json({ error: 'You must provide an username' }); return res.status(500).json({ error: 'You must provide an username' });
} else if (settings.jellyfin.ip !== '' && body.hostname) { } else if (settings.jellyfin.hostname !== '' && body.hostname) {
return res return res
.status(500) .status(500)
.json({ error: 'Jellyfin hostname already configured' }); .json({ error: 'Jellyfin hostname already configured' });
} else if (settings.jellyfin.ip === '' && !body.hostname) { } else if (settings.jellyfin.hostname === '' && !body.hostname) {
return res.status(500).json({ error: 'No hostname provided.' }); return res.status(500).json({ error: 'No hostname provided.' });
} }
try { try {
const hostname = const hostname =
settings.jellyfin.ip !== '' settings.jellyfin.hostname !== ''
? getHostname() ? settings.jellyfin.hostname
: getHostname({ : body.hostname ?? '';
useSsl: body.useSsl,
ip: body.hostname,
port: body.port,
urlBase: body.urlBase,
});
const { externalHostname } = getSettings().jellyfin; const { externalHostname } = getSettings().jellyfin;
// Try to find deviceId that corresponds to jellyfin user, else generate a new one // Try to find deviceId that corresponds to jellyfin user, else generate a new one
@@ -271,123 +257,41 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
'base64' 'base64'
); );
} }
// First we need to attempt to log the user in to jellyfin // First we need to attempt to log the user in to jellyfin
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
const jellyfinHost = let jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
const ip = req.ip; jellyfinHost = jellyfinHost.endsWith('/')
let clientIp; ? jellyfinHost.slice(0, -1)
: jellyfinHost;
if (ip) {
if (net.isIPv4(ip)) {
clientIp = ip;
} else if (net.isIPv6(ip)) {
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
}
}
const account = await jellyfinserver.login(
body.username,
body.password,
clientIp
);
const account = await jellyfinserver.login(body.username, body.password);
// Next let's see if the user already exists // Next let's see if the user already exists
user = await userRepository.findOne({ user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
if (!user && !(await userRepository.count())) { if (user) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permission
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
userType: UserType.JELLYFIN,
});
const serverName = await jellyfinserver.getServerName();
settings.jellyfin.name = serverName;
settings.jellyfin.serverId = account.User.ServerId;
settings.jellyfin.ip = body.hostname ?? '';
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.save();
startJobs();
await userRepository.save(user);
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {
logger.info(
`Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
} user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? 'Jellyfin'
: 'Emby'
}`,
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// Let's check if their authtoken is up to date // Let's check if their authtoken is up to date
if (user.jellyfinAuthToken !== account.AccessToken) { if (user.jellyfinAuthToken !== account.AccessToken) {
user.jellyfinAuthToken = account.AccessToken; user.jellyfinAuthToken = account.AccessToken;
} }
// Update the users avatar with their jellyfin profile pic (incase it changed) // Update the users avatar with their jellyfin profile pic (incase it changed)
if (account.User.PrimaryImageTag) { if (account.User.PrimaryImageTag) {
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else { } else {
user.avatar = gravatarUrl(user.email, { user.avatar = '/os_logo_square.png';
default: 'mm',
size: 200,
});
} }
user.jellyfinUsername = account.User.Name; user.jellyfinUsername = account.User.Name;
if (user.username === account.User.Name) { if (user.username === account.User.Name) {
user.username = ''; user.username = '';
} }
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
// if (process.env.JELLYFIN_TYPE === 'emby') {
// settings.main.mediaServerType = MediaServerType.EMBY;
// settings.save();
// }
await userRepository.save(user); await userRepository.save(user);
} else if (!settings.main.newPlexLogin) { } else if (!settings.main.newPlexLogin) {
logger.warn( logger.warn(
@@ -403,38 +307,69 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
status: 403, status: 403,
message: 'Access denied.', message: 'Access denied.',
}); });
} else if (!user) { } else {
logger.info( // Here we check if it's the first user. If it is, we create the user with no check
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user', // and give them admin permissions
{ const totalUsers = await userRepository.count();
label: 'API', if (totalUsers === 0) {
ip: req.ip, logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user = new User({
email: body.email,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: '/os_logo_square.png',
userType: UserType.JELLYFIN,
});
await userRepository.save(user);
//Update hostname in settings if it doesn't exist (initial configuration)
//Also set mediaservertype to JELLYFIN
if (settings.jellyfin.hostname === '') {
settings.main.mediaServerType = MediaServerType.JELLYFIN;
settings.jellyfin.hostname = body.hostname ?? '';
settings.jellyfin.serverId = account.User.ServerId;
settings.save();
startJobs();
} }
);
if (!body.email) {
throw new Error('add_email');
} }
user = new User({ if (!user) {
email: body.email, if (!body.email) {
jellyfinUsername: account.User.Name, throw new Error('add_email');
jellyfinUserId: account.User.Id, }
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken, user = new User({
permissions: settings.main.defaultPermissions, email: body.email,
avatar: account.User.PrimaryImageTag jellyfinUsername: account.User.Name,
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` jellyfinUserId: account.User.Id,
: gravatarUrl(body.email, { default: 'mm', size: 200 }), jellyfinDeviceId: deviceId,
userType: UserType.JELLYFIN, jellyfinAuthToken: account.AccessToken,
}); permissions: settings.main.defaultPermissions,
//initialize Jellyfin/Emby users with local login avatar: account.User.PrimaryImageTag
const passedExplicitPassword = body.password && body.password.length > 0; ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
if (passedExplicitPassword) { : '/os_logo_square.png',
await user.setPassword(body.password ?? ''); userType: UserType.JELLYFIN,
});
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword =
body.password && body.password.length > 0;
if (passedExplicitPassword) {
await user.setPassword(body.password ?? '');
}
await userRepository.save(user);
} }
await userRepository.save(user);
} }
// Set logged in session // Set logged in session
@@ -444,68 +379,33 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {}); return res.status(200).json(user?.filter() ?? {});
} catch (e) { } catch (e) {
switch (e.errorCode) { if (e.message === 'Unauthorized') {
case ApiErrorCode.InvalidUrl: logger.warn(
logger.error( 'Failed login attempt from user with incorrect Jellyfin credentials',
`The provided ${ {
process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin' label: 'Auth',
} is invalid or the server is not reachable.`, account: {
{ ip: req.ip,
label: 'Auth', email: body.username,
error: e.errorCode, password: '__REDACTED__',
status: e.statusCode, },
hostname: getHostname({ }
useSsl: body.useSsl, );
ip: body.hostname, return next({
port: body.port, status: 401,
urlBase: body.urlBase, message: 'Unauthorized',
}), });
} } else if (e.message === 'add_email') {
); return next({
return next({ status: 406,
status: e.statusCode, message: 'CREDENTIAL_ERROR_ADD_EMAIL',
message: e.errorCode, });
}); } else {
logger.error(e.message, { label: 'Auth' });
case ApiErrorCode.InvalidCredentials: return next({
logger.warn( status: 500,
'Failed login attempt from user with incorrect Jellyfin credentials', message: 'Something went wrong.',
{ });
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
password: '__REDACTED__',
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
case ApiErrorCode.NotAdmin:
logger.warn(
'Failed login attempt from user without admin permissions',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default:
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong.',
});
} }
} }
}); });

View File

@@ -12,7 +12,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
try { try {
const collection = await tmdb.getCollection({ const collection = await tmdb.getCollection({
collectionId: Number(req.params.id), collectionId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -166,7 +166,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@@ -211,7 +211,7 @@ discoverRoutes.get<{ genreId: string }>(
try { try {
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const genre = genres.find( const genre = genres.find(
@@ -224,7 +224,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
genre: req.params.genreId as string, genre: req.params.genreId as string,
}); });
@@ -272,7 +272,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
studio: req.params.studioId as string, studio: req.params.studioId as string,
}); });
@@ -322,7 +322,7 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
try { try {
const data = await tmdb.getDiscoverMovies({ const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
primaryReleaseDateGte: date, primaryReleaseDateGte: date,
}); });
@@ -447,7 +447,7 @@ discoverRoutes.get<{ language: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
originalLanguage: req.params.language, originalLanguage: req.params.language,
}); });
@@ -492,7 +492,7 @@ discoverRoutes.get<{ genreId: string }>(
try { try {
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const genre = genres.find( const genre = genres.find(
@@ -505,7 +505,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
genre: req.params.genreId, genre: req.params.genreId,
}); });
@@ -553,7 +553,7 @@ discoverRoutes.get<{ networkId: string }>(
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
network: Number(req.params.networkId), network: Number(req.params.networkId),
}); });
@@ -603,7 +603,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
try { try {
const data = await tmdb.getDiscoverTv({ const data = await tmdb.getDiscoverTv({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
firstAirDateGte: date, firstAirDateGte: date,
}); });
@@ -643,7 +643,7 @@ discoverRoutes.get('/trending', async (req, res, next) => {
try { try {
const data = await tmdb.getAllTrending({ const data = await tmdb.getAllTrending({
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -698,7 +698,7 @@ discoverRoutes.get<{ keywordId: string }>(
const data = await tmdb.getMoviesByKeyword({ const data = await tmdb.getMoviesByKeyword({
keywordId: Number(req.params.keywordId), keywordId: Number(req.params.keywordId),
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -743,7 +743,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
await Promise.all( await Promise.all(
@@ -787,7 +787,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
const mappedGenres: GenreSliderItem[] = []; const mappedGenres: GenreSliderItem[] = [];
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
await Promise.all( await Promise.all(

View File

@@ -237,7 +237,7 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => {
try { try {
const genres = await tmdb.getMovieGenres({ const genres = await tmdb.getMovieGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(genres); return res.status(200).json(genres);
@@ -258,7 +258,7 @@ router.get('/genres/tv', isAuthenticated(), async (req, res, next) => {
try { try {
const genres = await tmdb.getTvGenres({ const genres = await tmdb.getTvGenres({
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(genres); return res.status(200).json(genres);

View File

@@ -17,7 +17,7 @@ movieRoutes.get('/:id', async (req, res, next) => {
try { try {
const tmdbMovie = await tmdb.getMovie({ const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id), movieId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
@@ -43,7 +43,7 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getMovieRecommendations({ const results = await tmdb.getMovieRecommendations({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -85,7 +85,7 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getMovieSimilar({ const results = await tmdb.getMovieSimilar({
movieId: Number(req.params.id), movieId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -16,7 +16,7 @@ personRoutes.get('/:id', async (req, res, next) => {
try { try {
const person = await tmdb.getPerson({ const person = await tmdb.getPerson({
personId: Number(req.params.id), personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(mapPersonDetails(person)); return res.status(200).json(mapPersonDetails(person));
} catch (e) { } catch (e) {
@@ -38,7 +38,7 @@ personRoutes.get('/:id/combined_credits', async (req, res, next) => {
try { try {
const combinedCredits = await tmdb.getPersonCombinedCredits({ const combinedCredits = await tmdb.getPersonCombinedCredits({
personId: Number(req.params.id), personId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const castMedia = await Media.getRelatedMedia( const castMedia = await Media.getRelatedMedia(

View File

@@ -20,7 +20,7 @@ searchRoutes.get('/', async (req, res, next) => {
.match(searchProvider.pattern) as RegExpMatchArray; .match(searchProvider.pattern) as RegExpMatchArray;
results = await searchProvider.search({ results = await searchProvider.search({
id, id,
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
query: queryString, query: queryString,
}); });
} else { } else {
@@ -29,7 +29,7 @@ searchRoutes.get('/', async (req, res, next) => {
results = await tmdb.searchMulti({ results = await tmdb.searchMulti({
query: queryString, query: queryString,
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
} }

View File

@@ -2,7 +2,6 @@ import JellyfinAPI from '@server/api/jellyfin';
import PlexAPI from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli'; import TautulliAPI from '@server/api/tautulli';
import { ApiErrorCode } from '@server/constants/error';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
@@ -13,26 +12,23 @@ import type {
LogsResultsResponse, LogsResultsResponse,
SettingsAboutResponse, SettingsAboutResponse,
} from '@server/interfaces/api/settingsInterfaces'; } from '@server/interfaces/api/settingsInterfaces';
import { jobJellyfinFullSync } from '@server/job/jellyfinsync';
import { scheduledJobs } from '@server/job/schedule'; import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache'; import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin';
import { plexFullScanner } from '@server/lib/scanners/plex'; import { plexFullScanner } from '@server/lib/scanners/plex';
import type { JobId, Library, MainSettings } from '@server/lib/settings'; import type { JobId, Library, MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover'; import discoverSettingRoutes from '@server/routes/settings/discover';
import { ApiError } from '@server/types/error';
import { appDataPath } from '@server/utils/appDataVolume'; import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import fs from 'fs'; import fs from 'fs';
import gravatarUrl from 'gravatar-url';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule'; import { rescheduleJob } from 'node-schedule';
import path from 'path'; import path from 'path';
@@ -255,64 +251,16 @@ settingsRoutes.get('/jellyfin', (_req, res) => {
res.status(200).json(settings.jellyfin); res.status(200).json(settings.jellyfin);
}); });
settingsRoutes.post('/jellyfin', async (req, res, next) => { settingsRoutes.post('/jellyfin', (req, res) => {
const userRepository = getRepository(User);
const settings = getSettings(); const settings = getSettings();
try { settings.jellyfin = merge(settings.jellyfin, req.body);
const admin = await userRepository.findOneOrFail({ settings.save();
where: { id: 1 },
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
const tempJellyfinSettings = { ...settings.jellyfin, ...req.body };
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
const result = await jellyfinClient.getSystemInfo();
if (!result?.Id) {
throw new ApiError(result?.status, ApiErrorCode.InvalidUrl);
}
Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName;
settings.save();
} catch (e) {
if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', {
label: 'API',
status: e.statusCode,
errorMessage: ApiErrorCode.InvalidUrl,
});
return next({
status: e.statusCode,
message: ApiErrorCode.InvalidUrl,
});
} else {
logger.error('Something went wrong', {
label: 'API',
errorMessage: e.message,
});
return next({
status: e.statusCode ?? 500,
message: ApiErrorCode.Unknown,
});
}
}
return res.status(200).json(settings.jellyfin); return res.status(200).json(settings.jellyfin);
}); });
settingsRoutes.get('/jellyfin/library', async (req, res, next) => { settingsRoutes.get('/jellyfin/library', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
if (req.query.sync) { if (req.query.sync) {
@@ -323,7 +271,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
getHostname(), settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -332,22 +280,6 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
const libraries = await jellyfinClient.getLibraries(); const libraries = await jellyfinClient.getLibraries();
if (libraries.length === 0) {
// Check if no libraries are found due to the fallback to user views
// This only affects LDAP users
const account = await jellyfinClient.getUser();
// Automatic Library grouping is not supported when user views are used to get library
if (account.Configuration.GroupedFolders.length > 0) {
return next({
status: 501,
message: ApiErrorCode.SyncErrorGroupedFolders,
});
}
return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries });
}
const newLibraries: Library[] = libraries.map((library) => { const newLibraries: Library[] = libraries.map((library) => {
const existing = settings.jellyfin.libraries.find( const existing = settings.jellyfin.libraries.find(
(l) => l.id === library.key && l.name === library.title (l) => l.id === library.key && l.name === library.title
@@ -376,12 +308,16 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
}); });
settingsRoutes.get('/jellyfin/users', async (req, res) => { settingsRoutes.get('/jellyfin/users', async (req, res) => {
const { externalHostname } = getSettings().jellyfin; const settings = getSettings();
const jellyfinHost = const { hostname, externalHostname } = getSettings().jellyfin;
let jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: getHostname(); : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({ const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'], select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
@@ -389,6 +325,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -400,7 +337,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
id: user.Id, id: user.Id,
thumb: user.PrimaryImageTag thumb: user.PrimaryImageTag
? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90`
: gravatarUrl(user.Name, { default: 'mm', size: 200 }), : '/os_logo_square.png',
email: user.Name, email: user.Name,
})); }));
@@ -408,16 +345,16 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
}); });
settingsRoutes.get('/jellyfin/sync', (_req, res) => { settingsRoutes.get('/jellyfin/sync', (_req, res) => {
return res.status(200).json(jellyfinFullScanner.status()); return res.status(200).json(jobJellyfinFullSync.status());
}); });
settingsRoutes.post('/jellyfin/sync', (req, res) => { settingsRoutes.post('/jellyfin/sync', (req, res) => {
if (req.body.cancel) { if (req.body.cancel) {
jellyfinFullScanner.cancel(); jobJellyfinFullSync.cancel();
} else if (req.body.start) { } else if (req.body.start) {
jellyfinFullScanner.run(); jobJellyfinFullSync.run();
} }
return res.status(200).json(jellyfinFullScanner.status()); return res.status(200).json(jobJellyfinFullSync.status());
}); });
settingsRoutes.get('/tautulli', (_req, res) => { settingsRoutes.get('/tautulli', (_req, res) => {
const settings = getSettings(); const settings = getSettings();

View File

@@ -275,7 +275,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
...webhookSettings.options, ...webhookSettings.options,
jsonPayload: JSON.parse( jsonPayload: JSON.parse(
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString( Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
'utf8' 'ascii'
) )
), ),
}, },

View File

@@ -14,7 +14,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
try { try {
const tv = await tmdb.getTvShow({ const tv = await tmdb.getTvShow({
tvId: Number(req.params.id), tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getMedia(tv.id, MediaType.TV); const media = await Media.getMedia(tv.id, MediaType.TV);
@@ -40,7 +40,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const season = await tmdb.getTvSeason({ const season = await tmdb.getTvSeason({
tvId: Number(req.params.id), tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber), seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
return res.status(200).json(mapSeasonWithEpisodes(season)); return res.status(200).json(mapSeasonWithEpisodes(season));
@@ -65,7 +65,7 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => {
const results = await tmdb.getTvRecommendations({ const results = await tmdb.getTvRecommendations({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(
@@ -106,7 +106,7 @@ tvRoutes.get('/:id/similar', async (req, res, next) => {
const results = await tmdb.getTvSimilar({ const results = await tmdb.getTvSimilar({
tvId: Number(req.params.id), tvId: Number(req.params.id),
page: Number(req.query.page), page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale, language: req.locale ?? (req.query.language as string),
}); });
const media = await Media.getRelatedMedia( const media = await Media.getRelatedMedia(

View File

@@ -20,7 +20,6 @@ import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
@@ -183,21 +182,25 @@ router.post<
} }
}); });
router.get<{ id: string }>('/:id', async (req, res, next) => { router.get<{ id: string }>(
try { '/:id',
const userRepository = getRepository(User); isAuthenticated([Permission.MANAGE_USERS, Permission.WATCHLIST_VIEW]),
async (req, res, next) => {
try {
const userRepository = getRepository(User);
const user = await userRepository.findOneOrFail({ const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) }, where: { id: Number(req.params.id) },
}); });
return res return res
.status(200) .status(200)
.json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS))); .json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS)));
} catch (e) { } catch (e) {
next({ status: 404, message: 'User not found.' }); next({ status: 404, message: 'User not found.' });
}
} }
}); );
router.use('/:id/settings', userSettingsRoutes); router.use('/:id/settings', userSettingsRoutes);
@@ -497,6 +500,7 @@ router.post(
order: { id: 'ASC' }, order: { id: 'ASC' },
}); });
const jellyfinClient = new JellyfinAPI( const jellyfinClient = new JellyfinAPI(
settings.jellyfin.hostname ?? '',
admin.jellyfinAuthToken ?? '', admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? '' admin.jellyfinDeviceId ?? ''
); );
@@ -504,14 +508,15 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers(); //const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = []; const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin; const { hostname, externalHostname } = getSettings().jellyfin;
const hostname = getHostname(); let jellyfinHost =
const jellyfinHost =
externalHostname && externalHostname.length > 0 externalHostname && externalHostname.length > 0
? externalHostname ? externalHostname
: hostname; : hostname;
jellyfinHost = jellyfinHost.endsWith('/')
? jellyfinHost.slice(0, -1)
: jellyfinHost;
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsers = await jellyfinClient.getUsers();
@@ -536,10 +541,7 @@ router.post(
permissions: settings.main.defaultPermissions, permissions: settings.main.defaultPermissions,
avatar: jellyfinUser?.PrimaryImageTag avatar: jellyfinUser?.PrimaryImageTag
? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90`
: gravatarUrl(jellyfinUser?.Name ?? '', { : '/os_logo_square.png',
default: 'mm',
size: 200,
}),
userType: UserType.JELLYFIN, userType: UserType.JELLYFIN,
}); });

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