Compare commits

..

12 Commits

Author SHA1 Message Date
fallenbagel
f02755f03d fix: fixes some typos 2025-12-13 09:51:13 +08:00
fallenbagel
f2c771727c refactor(quickconnect): improve secret validation for quick connect endpoints 2025-12-13 09:43:01 +08:00
fallenbagel
87b51b809b refactor(quickconnect): implement useQuickConnect hook for managing quick connect flow 2025-12-13 09:34:58 +08:00
fallenbagel
43553cb2d5 refactor(jellyfin-login): simplify error handling for quick connect errors 2025-12-13 09:34:25 +08:00
fallenbagel
8bb7d4e380 refactor(quickconnect): validate secret length and format in quick connect check 2025-12-13 09:33:31 +08:00
fallenbagel
8c4e39d098 feat(openapi): add quick connect endpoint for linking jellyfin/emby accounts 2025-12-13 09:32:56 +08:00
fallenbagel
973e43f1cc chore(i18n): extracted translations 2025-12-13 08:36:29 +08:00
fallenbagel
a93716eb15 feat(linked-accounts): add quick connect linking in the linked-accounts module 2025-12-13 08:20:29 +08:00
fallenbagel
6000c36c69 fix(quick-connect): prevent multiple initiations of Quick Connect 2025-12-13 08:20:29 +08:00
fallenbagel
6c9aaf9777 fix(quick-connect): prevent memory leak by having one active poll at a time 2025-12-13 08:20:29 +08:00
fallenbagel
c4d06540a6 chore(i18n): extracted translations 2025-12-13 08:20:29 +08:00
fallenbagel
98a6075cb6 feat: add jellyfin/emby quick connect authentication
Implements a quick connect authentication flow for jellyfin and emby servers.

fix #1595
2025-12-13 08:20:29 +08:00
212 changed files with 6834 additions and 4011 deletions

View File

@@ -16,7 +16,6 @@ module.exports = {
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn', // disable the rule for now to replicate previous behavior
'@typescript-eslint/camelcase': 0,
'@typescript-eslint/no-use-before-define': 0,
'jsx-a11y/no-noninteractive-tabindex': 0,

View File

@@ -91,14 +91,6 @@ body:
attributes:
label: Additional Context
description: Please provide any additional information that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this bug has already been reported?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -27,14 +27,6 @@ body:
attributes:
label: Additional Context
description: Provide any additional information or screenshots that may be relevant or helpful.
- type: checkboxes
id: search-existing
attributes:
label: Search Existing Issues
description: Have you searched existing issues to see if this feature has already been requested?
options:
- label: Yes, I have searched existing issues.
required: true
- type: checkboxes
id: terms
attributes:

View File

@@ -26,10 +26,10 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
container: node:22.20.0-alpine3.22@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -78,7 +78,7 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Warm cache (no push) — ${{ matrix.platform }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -114,7 +114,7 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -140,7 +140,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}

View File

@@ -37,20 +37,20 @@ jobs:
language: [actions, javascript]
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
category: '/language:${{ matrix.language }}'

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -67,7 +67,7 @@ jobs:
run: pnpm exec cypress install
- name: Cypress run
uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c # v6.10.2
with:
install: false
build: pnpm cypress:build

View File

@@ -23,7 +23,7 @@ jobs:
name: Build Docusaurus
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -36,13 +36,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Lychee link checker
uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 # v2.7.0
uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
with:
fail: false
args: >-

View File

@@ -28,7 +28,7 @@ jobs:
has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
if: needs.package-helm-chart.outputs.has_artifacts == 'true'
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -151,7 +151,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -28,7 +28,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -37,7 +37,7 @@ jobs:
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
- name: Set up chart-testing
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Ensure documentation is updated
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c

View File

@@ -33,7 +33,7 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Derive preview version from tag
id: ver
@@ -79,7 +79,7 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -88,7 +88,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -115,7 +115,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}

View File

@@ -27,14 +27,14 @@ jobs:
release_body: ${{ steps.git-cliff.outputs.content }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Generate changelog
id: git-cliff
uses: orhun/git-cliff-action@e16f179f0be49ecdfe63753837f20b9531642772 # v4.7.0
uses: orhun/git-cliff-action@d77b37db2e3f7398432d34b72a12aa3e2ba87e51 # v4.6.0
with:
config: .github/cliff.toml
args: -vv --current
@@ -50,7 +50,7 @@ jobs:
needs: changelog
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -76,7 +76,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -85,7 +85,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Warm cache [${{ matrix.platform }}]
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -115,7 +115,7 @@ jobs:
VERSION: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -124,7 +124,7 @@ jobs:
run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -141,7 +141,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ env.DOCKER_HUB }}
@@ -201,7 +201,7 @@ jobs:
COSIGN_YES: 'true'
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Install Trivy
uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5
uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 # v0.2.4
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -25,19 +25,19 @@ jobs:
if: github.actor == 'renovate[bot]'
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: 2138788
private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }}
- name: Set up chart-testing
uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Run chart-testing (list-changed)
id: list-changed

View File

@@ -21,7 +21,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
any-of-labels: "pending author's response"
exempt-issue-labels: 'confirmed'

View File

@@ -24,7 +24,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -56,6 +56,6 @@ jobs:
ignore-unfixed: true
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/upload-sarif@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
sarif_file: trivy.sarif

View File

@@ -4,17 +4,15 @@ dist/
config/
pnpm-lock.yaml
cypress/config/settings.cypress.json
.github
.vscode
# assets
src/assets/
docs/
public/*
public/
!public/sw.js
docs/
!/public/
/public/*
!/public/sw.js
# helm charts
**/charts
# Prettier breaks GitHub alert syntax in markdown
*.md

View File

@@ -1,5 +1,5 @@
module.exports = {
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
plugins: [require('./merged-prettier-plugin.js')],
singleQuote: true,
trailingComma: 'es5',
overrides: [
@@ -27,11 +27,5 @@ module.exports = {
rangeEnd: 0,
},
},
{
files: 'public/offline.html',
options: {
rangeEnd: 0,
},
},
],
};

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 AS base
ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
@@ -33,7 +33,7 @@ RUN pnpm build
RUN rm -rf .next/cache
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
ARG SOURCE_DATE_EPOCH
ARG COMMIT_TAG
ENV NODE_ENV=production

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -8,7 +8,7 @@
<p align="center">
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
@@ -32,28 +32,10 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
Check out our documentation for instructions on how to install and run Seerr:
https://docs.seerr.dev/getting-started/
> [!IMPORTANT]
> **Seerr is not officially released yet.**
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
The documentation linked above is for running the **latest Jellyseerr** release.
> [!WARNING]
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
Instead, follow the dedicated migration guide (with `:develop` tag):
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
> [!CAUTION]
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
## Preview
<img src="./public/preview.jpg">

View File

@@ -60,12 +60,12 @@
.table-of-contents__link--active,
a:not(
.card,
.menu__link,
.menu__link--sublist,
.menu__link--sublist-item,
.table-of-contents__link
) {
.card,
.menu__link,
.menu__link--sublist,
.menu__link--sublist-item,
.table-of-contents__link
) {
/* color: #793ae8; */
color: #6366f1;
}

21
merged-prettier-plugin.js Normal file
View File

@@ -0,0 +1,21 @@
/* eslint-disable */
const tailwind = require('prettier-plugin-tailwindcss');
const organizeImports = require('prettier-plugin-organize-imports');
const combinedFormatter = {
...tailwind,
parsers: {
...tailwind.parsers,
...Object.keys(organizeImports.parsers).reduce((acc, key) => {
acc[key] = {
...tailwind.parsers[key],
preprocess(code, options) {
return organizeImports.parsers[key].preprocess(code, options);
},
};
return acc;
}, {}),
},
};
module.exports = combinedFormatter;

View File

@@ -5,6 +5,7 @@
"packageManager": "pnpm@10.24.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"postinstall": "node postinstall-win.js",
"dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
@@ -16,7 +17,7 @@
"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:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
"format": "prettier --log-level warn --write --cache .",
"format": "prettier --loglevel warn --write --cache .",
"format:check": "prettier --check --cache .",
"typecheck": "pnpm typecheck:server && pnpm typecheck:client",
"typecheck:server": "tsc --project server/tsconfig.json --noEmit",
@@ -37,18 +38,18 @@
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-utils": "3.8.4",
"@formatjs/swc-plugin-experimental": "^0.4.0",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.2.0",
"@seerr-team/react-tailwindcss-datepicker": "^1.3.4",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.56",
"@types/ua-parser-js": "^0.7.36",
"@types/wink-jaro-distance": "^2.0.2",
"ace-builds": "1.43.4",
"axios": "1.13.3",
"axios": "1.13.2",
"axios-rate-limit": "1.4.0",
"bcrypt": "6.0.0",
"bcrypt": "5.1.0",
"bowser": "2.13.1",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.7",
@@ -67,15 +68,16 @@
"gravatar-url": "3.1.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"lodash": "4.17.23",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.35",
"next": "^14.2.25",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
"nodemailer": "7.0.12",
"openpgp": "6.3.0",
"pg": "8.17.2",
"nodemailer": "6.10.0",
"openpgp": "5.11.2",
"pg": "8.16.3",
"plex-api": "5.3.2",
"pug": "3.0.3",
"react": "^18.3.1",
"react-ace": "10.1.0",
@@ -88,6 +90,7 @@
"react-popper-tooltip": "4.4.2",
"react-select": "5.10.2",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-toast-notifications": "2.5.1",
"react-transition-group": "^4.4.5",
"react-truncate-markup": "5.1.2",
@@ -98,28 +101,28 @@
"sharp": "^0.33.4",
"sqlite3": "5.1.7",
"swagger-ui-express": "4.6.2",
"swr": "2.3.8",
"swr": "2.3.7",
"tailwind-merge": "^2.6.0",
"typeorm": "0.3.28",
"typeorm": "0.3.12",
"ua-parser-js": "^1.0.35",
"undici": "^7.18.2",
"undici": "^7.16.0",
"validator": "^13.15.23",
"web-push": "3.6.7",
"wink-jaro-distance": "^2.0.0",
"winston": "3.19.0",
"winston": "3.18.3",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.5.0",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "4.3.6"
"zod": "3.24.2"
},
"devDependencies": {
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/bcrypt": "6.0.0",
"@tailwindcss/aspect-ratio": "0.4.2",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.10",
"@types/country-flag-icons": "1.2.2",
"@types/csurf": "1.11.5",
@@ -130,7 +133,7 @@
"@types/mime": "3",
"@types/node": "22.10.5",
"@types/node-schedule": "2.1.8",
"@types/nodemailer": "7",
"@types/nodemailer": "6.4.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-transition-group": "4.4.12",
@@ -139,20 +142,20 @@
"@types/swagger-ui-express": "4.1.8",
"@types/validator": "^13.15.10",
"@types/web-push": "3.6.4",
"@types/xml2js": "0.4.14",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"autoprefixer": "^10.4.23",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.22",
"baseline-browser-mapping": "^2.8.32",
"commitizen": "4.3.1",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "14.5.4",
"cypress": "14.1.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.57.1",
"eslint-config-next": "^14.2.35",
"eslint": "8.35.0",
"eslint-config-next": "^14.2.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -163,20 +166,24 @@
"husky": "8.0.3",
"lint-staged": "13.1.2",
"nodemon": "3.1.11",
"postcss": "^8.5.6",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "3.4.19",
"postcss": "8.5.6",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"tailwindcss": "3.2.7",
"ts-node": "10.9.2",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.4.5"
"typescript": "4.9.5"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^10.0.0"
},
"overrides": {
"sqlite3/node-gyp": "8.4.1",
"@types/express-session": "1.18.2"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
@@ -203,10 +210,6 @@
"cypress",
"sharp",
"sqlite3"
],
"overrides": {
"sqlite3>node-gyp": "8.4.1",
"@types/express-session": "1.18.2"
}
]
}
}

6208
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

13
postinstall-win.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
if (process.platform === 'win32') {
const typeormPath = path.resolve('node_modules/typeorm');
if (fs.existsSync(typeormPath)) {
process.stdout.write('> Installing typeorm@0.3.11 for Windows\n');
execSync('pnpm add typeorm@0.3.11', { stdio: 'inherit' });
}
}

View File

@@ -3984,6 +3984,85 @@ paths:
required:
- username
- password
/auth/jellyfin/quickconnect/initiate:
post:
summary: Initiate Jellyfin Quick Connect
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
security: []
tags:
- auth
responses:
'200':
description: Quick Connect session initiated
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: '123456'
secret:
type: string
example: 'abc123def456'
'500':
description: Failed to initiate Quick Connect
/auth/jellyfin/quickconnect/check:
get:
summary: Check Quick Connect authorization status
description: Checks if the Quick Connect code has been authorized by the user.
security: []
tags:
- auth
parameters:
- in: query
name: secret
required: true
schema:
type: string
description: The secret returned from the initiate endpoint
responses:
'200':
description: Authorization status returned
content:
application/json:
schema:
type: object
properties:
authenticated:
type: boolean
example: false
'404':
description: Quick Connect session not found or expired
/auth/jellyfin/quickconnect/authenticate:
post:
summary: Authenticate with Quick Connect
description: Completes the Quick Connect authentication flow and creates a user session.
security: []
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
secret:
type: string
required:
- secret
responses:
'200':
description: Successfully authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'403':
description: Quick Connect not authorized or access denied
'500':
description: Authentication failed
/auth/local:
post:
summary: Sign in using a local account
@@ -4913,6 +4992,38 @@ paths:
description: Unlink request invalid
'404':
description: User does not exist
/user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
post:
summary: Link Jellyfin/Emby account with Quick Connect
description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
secret:
type: string
required:
- secret
responses:
'204':
description: Account successfully linked
'401':
description: Unauthorized
'422':
description: Account already linked
'500':
description: Server error
/user/{userId}/settings/notifications:
get:
summary: Get notification settings for a user

View File

@@ -13,7 +13,6 @@ const DEFAULT_ROLLING_BUFFER = 10000;
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
timeout?: number;
rateLimit?: {
maxRPS: number;
maxRequests: number;
@@ -33,7 +32,6 @@ class ExternalAPI {
this.axios = axios.create({
baseURL: baseUrl,
params,
timeout: options.timeout,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',

View File

@@ -57,7 +57,7 @@ interface GithubCommit {
sha: string;
url: string;
html_url: string;
},
}
];
}

View File

@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
AccessToken: string;
}
export interface QuickConnectInitiateResponse {
Secret: string;
Code: string;
DateAdded: string;
}
export interface QuickConnectStatusResponse {
Authenticated: boolean;
Secret: string;
Code: string;
DeviceId: string;
DeviceName: string;
AppName: string;
AppVersion: string;
DateAdded: string;
}
export interface JellyfinUserListResponse {
users: JellyfinUserResponse[];
}
@@ -216,6 +233,62 @@ class JellyfinAPI extends ExternalAPI {
}
}
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
try {
const response = await this.post<QuickConnectInitiateResponse>(
'/QuickConnect/Initiate'
);
return response;
} catch (e) {
logger.error(
`Something went wrong while initiating Quick Connect: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public async checkQuickConnect(
secret: string
): Promise<QuickConnectStatusResponse> {
try {
const response = await this.get<QuickConnectStatusResponse>(
'/QuickConnect/Connect',
{ params: { secret } }
);
return response;
} catch (e) {
logger.error(
`Something went wrong while getting Quick Connect status: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public async authenticateQuickConnect(
secret: string
): Promise<JellyfinLoginResponse> {
try {
const response = await this.post<JellyfinLoginResponse>(
'/Users/AuthenticateWithQuickConnect',
{ Secret: secret }
);
return response;
} catch (e) {
logger.error(
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
{ label: 'Jellyfin API', error: e.response?.status }
);
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
}
}
public setUserId(userId: string): void {
this.userId = userId;
return;
@@ -420,7 +493,7 @@ class JellyfinAPI extends ExternalAPI {
}
public async getEpisodes<
T extends { includeMediaInfo?: boolean } | undefined = undefined,
T extends { includeMediaInfo?: boolean } | undefined = undefined
>(
seriesID: string,
seasonID: string,

View File

@@ -1,14 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { Library, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
interface PlexStatusResponse {
MediaContainer: {
machineIdentifier: string;
friendlyName: string;
};
}
import NodePlexAPI from 'plex-api';
export interface PlexLibraryItem {
ratingKey: string;
@@ -91,7 +84,9 @@ interface PlexMetadataResponse {
};
}
class PlexAPI extends ExternalAPI {
class PlexAPI {
private plexClient: NodePlexAPI;
constructor({
plexToken,
plexSettings,
@@ -102,33 +97,48 @@ class PlexAPI extends ExternalAPI {
timeout?: number;
}) {
const settings = getSettings();
const settingsPlex = plexSettings ?? settings.plex;
let settingsPlex: PlexSettings | undefined;
plexSettings
? (settingsPlex = plexSettings)
: (settingsPlex = getSettings().plex);
const protocol = settingsPlex.useSsl ? 'https' : 'http';
const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`;
super(
baseUrl,
{},
{
timeout,
headers: {
'X-Plex-Token': plexToken ?? '',
'X-Plex-Client-Identifier': settings.clientId,
'X-Plex-Product': 'Seerr',
'X-Plex-Device-Name': 'Seerr',
'X-Plex-Platform': 'Seerr',
this.plexClient = new NodePlexAPI({
hostname: settingsPlex.ip,
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken ?? undefined,
authenticator: {
authenticate: (
_plexApi,
cb: (err?: string, token?: string) => void
) => {
if (!plexToken) {
return cb('Plex Token not found!');
}
cb(undefined, plexToken);
},
}
);
},
// requestOptions: {
// includeChildren: 1,
// },
options: {
identifier: settings.clientId,
product: 'Seerr',
deviceName: 'Seerr',
platform: 'Seerr',
},
});
}
public async getStatus(): Promise<PlexStatusResponse> {
return await this.get('/');
public async getStatus() {
return await this.plexClient.query('/');
}
public async getLibraries(): Promise<PlexLibrary[]> {
const response = await this.get<PlexLibrariesResponse>('/library/sections');
const response = await this.plexClient.query<PlexLibrariesResponse>(
'/library/sections'
);
return response.MediaContainer.Directory;
}
@@ -177,15 +187,13 @@ class PlexAPI extends ExternalAPI {
id: string,
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?includeGuids=1`,
{
headers: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
}
);
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?includeGuids=1`,
extraHeaders: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
});
return {
totalSize: response.MediaContainer.totalSize,
@@ -197,7 +205,7 @@ class PlexAPI extends ExternalAPI {
key: string,
options: { includeChildren?: boolean } = {}
): Promise<PlexMetadata> {
const response = await this.get<PlexMetadataResponse>(
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}${
options.includeChildren ? '?includeChildren=1' : ''
}`
@@ -207,7 +215,7 @@ class PlexAPI extends ExternalAPI {
}
public async getChildrenMetadata(key: string): Promise<PlexMetadata[]> {
const response = await this.get<PlexMetadataResponse>(
const response = await this.plexClient.query<PlexMetadataResponse>(
`/library/metadata/${key}/children`
);
@@ -221,17 +229,15 @@ class PlexAPI extends ExternalAPI {
},
mediaType: 'movie' | 'show'
): Promise<PlexLibraryItem[]> {
const response = await this.get<PlexLibraryResponse>(
`/library/sections/${id}/all?type=${
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
{
headers: {
'X-Plex-Container-Start': '0',
'X-Plex-Container-Size': '500',
},
}
);
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,
},
});
return response.MediaContainer.Metadata;
}

View File

@@ -21,7 +21,7 @@ export const mapSounds = (sounds: {
({
name,
description,
}) as PushoverSound
} as PushoverSound)
);
class PushoverAPI extends ExternalAPI {

View File

@@ -157,8 +157,8 @@ class RottenTomatoes extends ExternalAPI {
criticsRating: movie.rottenTomatoes.certifiedFresh
? 'Certified Fresh'
: movie.rottenTomatoes.criticsScore >= 60
? 'Fresh'
: 'Rotten',
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
audienceRating:
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',

View File

@@ -209,34 +209,6 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data,
});
try {
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
const episodeIdsToMonitor = episodes
.filter(
(ep) =>
options.seasons.includes(ep.seasonNumber) && !ep.monitored
)
.map((ep) => ep.id);
if (episodeIdsToMonitor.length > 0) {
logger.debug(
'Re-monitoring unmonitored episodes for requested seasons.',
{
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
episodeCount: episodeIdsToMonitor.length,
}
);
await this.monitorEpisodes(episodeIdsToMonitor);
}
} catch (e) {
logger.warn('Failed to re-monitor episodes', {
label: 'Sonarr',
errorMessage: e.message,
seriesId: newSeriesResponse.data.id,
});
}
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
@@ -346,38 +318,6 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
try {
const response = await this.axios.get<EpisodeResult[]>('/episode', {
params: { seriesId },
});
return response.data;
} catch (e) {
logger.error('Failed to retrieve episodes', {
label: 'Sonarr API',
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
}
}
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
try {
await this.axios.put('/episode/monitor', {
episodeIds,
monitored: true,
});
} catch (e) {
logger.error('Failed to monitor episodes', {
label: 'Sonarr API',
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]

View File

@@ -269,8 +269,8 @@ class TautulliAPI {
recordA.grandparent_rating_key && recordB.grandparent_rating_key
? recordA.grandparent_rating_key === recordB.grandparent_rating_key
: recordA.parent_rating_key && recordB.parent_rating_key
? recordA.parent_rating_key === recordB.parent_rating_key
: recordA.rating_key === recordB.rating_key
? recordA.parent_rating_key === recordB.parent_rating_key
: recordA.rating_key === recordB.rating_key
);
start += take;

View File

@@ -536,8 +536,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
? undefined
: this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
@@ -630,8 +630,8 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
? undefined
: this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,

View File

@@ -392,10 +392,8 @@ export interface TmdbPersonCombinedCredits {
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes extends Omit<
TmdbTvSeasonResult,
'episode_count'
> {
export interface TmdbSeasonWithEpisodes
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}

View File

@@ -21,7 +21,6 @@ import {
AfterUpdate,
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
@@ -333,12 +332,6 @@ export class MediaRequest {
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: ${requestBody.mediaType}, TMDB ID: ${requestBody.mediaId}, persisted media id: ${media.id}`
);
}
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
@@ -449,12 +442,6 @@ export class MediaRequest {
await mediaRepository.save(media);
if (!media.id) {
throw new Error(
`Failed to save media before creating request. Media type: TV, TMDB ID: ${requestBody.mediaId}, is4k: ${requestBody.is4k}`
);
}
const request = new MediaRequest({
type: MediaType.TV,
media,
@@ -532,7 +519,6 @@ export class MediaRequest {
eager: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'mediaId' })
public media: Media;
@ManyToOne(() => User, (user) => user.requests, {

View File

@@ -5,11 +5,11 @@ import { Watchlist } from '@server/entity/Watchlist';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import PreparedEmail from '@server/lib/email';
import type { PermissionCheckOptions } from '@server/lib/permissions';
import { Permission, hasPermission } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { AfterDate } from '@server/utils/dateHelpers';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import path from 'path';
@@ -271,7 +271,7 @@ export class User {
});
const movieQuotaLimit = !canBypass
? (this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit)
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
: 0;
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
@@ -295,7 +295,7 @@ export class User {
: 0;
const tvQuotaLimit = !canBypass
? (this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit)
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
: 0;
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;

View File

@@ -1,15 +1,8 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from './User';
@Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription {
@PrimaryGeneratedColumn()
public id: number;

View File

@@ -97,10 +97,7 @@ app
// Register HTTP proxy
if (settings.network.proxy.enabled) {
await createCustomProxyAgent(
settings.network.proxy,
settings.network.forceIpv4First
);
await createCustomProxyAgent(settings.network.proxy);
}
// Migrate library types

View File

@@ -300,6 +300,7 @@ class AvailabilitySync {
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -310,7 +311,48 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false)
);
// non-4k
const finalSeasons: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
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();
media.seasons
.filter(
(season) =>
@@ -321,32 +363,44 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
let finalSeasons: Map<number, boolean>;
let finalSeasons4k: Map<number, boolean>;
// 4k
const finalSeasons4k: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
} else {
// Jellyfin/Emby
finalSeasons = new Map([
...filteredSeasonsMap,
...jellyfinSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...jellyfinSeasonsMap4k,
...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);
}
});
}
if (
@@ -513,8 +567,8 @@ class AvailabilitySync {
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
? 'jellyfin'
: 'emby'
} instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
@@ -588,8 +642,8 @@ class AvailabilitySync {
mediaServerType === MediaServerType.PLEX
? 'plex'
: mediaServerType === MediaServerType.JELLYFIN
? 'jellyfin'
: 'emby'
? 'jellyfin'
: 'emby'
} instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
@@ -612,13 +666,6 @@ class AvailabilitySync {
): Promise<boolean> {
let existsInRadarr = false;
const hasSameServerInBothModes = this.radarrServers.some((a) =>
this.radarrServers.some(
(b) =>
a.is4k !== b.is4k && a.hostname === b.hostname && a.port === b.port
)
);
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
for (const server of this.radarrServers.filter(
@@ -649,14 +696,7 @@ class AvailabilitySync {
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
const is4kMovie =
resolution?.length === 2 && Number(resolution[0]) >= 2000;
if (hasSameServerInBothModes && resolution?.length === 2) {
// Same server in both modes then use resolution to distinguish
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
} else {
// One server type and if file exists, count it
existsInRadarr = true;
}
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
}
} catch (ex) {
if (!ex.message.includes('404')) {
@@ -672,8 +712,6 @@ class AvailabilitySync {
);
}
}
if (existsInRadarr) break;
}
return existsInRadarr;
@@ -832,50 +870,6 @@ class AvailabilitySync {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
if (plexMedia) {
if (ratingKey === ratingKey4k) {
plexMedia = undefined;
}
if (
plexMedia &&
media.mediaType === 'movie' &&
!plexMedia.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
) {
plexMedia = undefined;
}
if (plexMedia && media.mediaType === 'tv') {
const cachedSeasons = this.plexSeasonsCache[ratingKey4k];
if (cachedSeasons?.length) {
let has4kInAnySeason = false;
for (const season of cachedSeasons) {
try {
const episodes = await this.plexClient?.getChildrenMetadata(
season.ratingKey
);
const has4kEpisode = episodes?.some((episode) =>
episode.Media?.some(
(mediaItem) => (mediaItem.width ?? 0) >= 2000
)
);
if (has4kEpisode) {
has4kInAnySeason = true;
break;
}
} catch {
// If we can't fetch episodes for a season, continue checking other seasons
}
}
if (!has4kInAnySeason) {
plexMedia = undefined;
}
}
}
}
}
if (plexMedia) {
@@ -999,8 +993,8 @@ class AvailabilitySync {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = true;
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -2,12 +2,12 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentDiscord } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -209,8 +209,8 @@ class DiscordAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
return {

View File

@@ -4,7 +4,7 @@ import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import PreparedEmail from '@server/lib/email';
import type { NotificationAgentEmail } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import type { EmailOptions } from 'email-templates';
import path from 'path';

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentGotify } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentNtfy } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushbullet } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentPushover } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -45,17 +45,7 @@ class PushoverAgent
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
return true;
}
private async getImagePayload(
@@ -158,8 +148,8 @@ class PushoverAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`

View File

@@ -3,7 +3,7 @@ import type { NotificationAgentSlack } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -183,8 +183,8 @@ class SlackAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {

View File

@@ -3,12 +3,12 @@ import { MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import type { NotificationAgentTelegram } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import {
Notification,
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
@@ -133,8 +133,8 @@ class TelegramAgent
? payload.issue
? `${applicationUrl}/issues/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {

View File

@@ -5,7 +5,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import axios from 'axios';
import { get } from 'lodash';
import { Notification, hasNotificationType } from '..';
import { hasNotificationType, Notification } from '..';
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
@@ -122,7 +122,7 @@ class WebhookAgent
`{{${keymapKey}}}`,
typeof keymapValue === 'function'
? keymapValue(payload, type)
: (get(payload, keymapValue) ?? '')
: get(payload, keymapValue) ?? ''
);
});
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
@@ -186,8 +186,8 @@ class WebhookAgent
type === Notification.TEST_NOTIFICATION
? 'test'
: typeof keymapValue === 'function'
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
? keymapValue(payload, type)
: get(payload, keymapValue) || 'test';
webhookUrl = webhookUrl.replace(
new RegExp(`{{${keymapKey}}}`, 'g'),
encodeURIComponent(variableValue)

View File

@@ -5,7 +5,7 @@ import MediaRequest from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { NotificationAgentConfig } from '@server/lib/settings';
import { NotificationAgentKey, getSettings } from '@server/lib/settings';
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
import logger from '@server/logger';
import webpush from 'web-push';
import { Notification, shouldSendAdminNotification } from '..';
@@ -24,15 +24,6 @@ interface PushNotificationPayload {
isAdmin?: boolean;
}
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent
@@ -128,8 +119,8 @@ class WebPushAgent
const actionUrl = payload.issue
? `/issues/${payload.issue.id}`
: payload.media
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
const actionUrlTitle = actionUrl
? `View ${payload.issue ? 'Issue' : 'Media'}`
@@ -197,30 +188,19 @@ class WebPushAgent
notificationPayload
);
} catch (e) {
const webPushError = e as WebPushError;
const statusCode = webPushError.statusCode || webPushError.status;
const errorMessage = webPushError.message || String(e);
// RFC 8030: 410/404 are permanent failures, others are transient
const isPermanentFailure = statusCode === 410 || statusCode === 404;
logger.error(
isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
'Error sending web push notification; removing subscription',
{
label: 'Notifications',
recipient: pushSub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage,
statusCode: statusCode || 'unknown',
errorMessage: e.message,
}
);
if (isPermanentFailure) {
await userPushSubRepository.remove(pushSub);
}
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(pushSub);
}
};

View File

@@ -34,8 +34,6 @@ interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
@@ -97,8 +95,6 @@ class BaseScanner<T> {
is4k = false,
mediaAddedAt,
ratingKey,
jellyfinMediaId,
imdbId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -115,11 +111,9 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -139,21 +133,6 @@ class BaseScanner<T> {
changedExisting = true;
}
if (
jellyfinMediaId &&
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
jellyfinMediaId
) {
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
jellyfinMediaId;
changedExisting = true;
}
if (imdbId && !existing.imdbId) {
existing.imdbId = imdbId;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
@@ -194,20 +173,19 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
@@ -225,13 +203,6 @@ class BaseScanner<T> {
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
if (jellyfinMediaId) {
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
newMedia.jellyfinMediaId4k =
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
@@ -250,12 +221,11 @@ class BaseScanner<T> {
*/
protected async processShow(
tmdbId: number,
tvdbId: number | undefined,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
jellyfinMediaId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -287,7 +257,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
@@ -301,23 +271,6 @@ class BaseScanner<T> {
media.ratingKey4k = ratingKey;
}
if (
media &&
season.episodes > 0 &&
media.jellyfinMediaId !== jellyfinMediaId
) {
media.jellyfinMediaId = jellyfinMediaId;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== jellyfinMediaId
) {
media.jellyfinMediaId4k = jellyfinMediaId;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
@@ -327,17 +280,12 @@ class BaseScanner<T> {
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status;
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
@@ -347,17 +295,12 @@ class BaseScanner<T> {
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k;
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
@@ -366,20 +309,20 @@ class BaseScanner<T> {
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
@@ -486,37 +429,37 @@ class BaseScanner<T> {
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status4k === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status4k === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
@@ -548,50 +491,34 @@ class BaseScanner<T> {
)
? ratingKey
: undefined,
jellyfinMediaId: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
jellyfinMediaId4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? jellyfinMediaId
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);

File diff suppressed because it is too large Load Diff

View File

@@ -59,11 +59,12 @@ searchProviders.push({
const successfulResponses = responses.filter(
(r) => r.status === 'fulfilled'
) as (
| PromiseFulfilledResult<TmdbMovieDetails>
| PromiseFulfilledResult<TmdbTvDetails>
| PromiseFulfilledResult<TmdbPersonDetails>
)[];
) as
| (
| PromiseFulfilledResult<TmdbMovieDetails>
| PromiseFulfilledResult<TmdbTvDetails>
| PromiseFulfilledResult<TmdbPersonDetails>
)[];
const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = [];
@@ -184,10 +185,11 @@ searchProviders.push({
const successfulResponses = responses.filter(
(r) => r.status === 'fulfilled'
) as (
| PromiseFulfilledResult<TmdbSearchMovieResponse>
| PromiseFulfilledResult<TmdbSearchTvResponse>
)[];
) as
| (
| PromiseFulfilledResult<TmdbSearchMovieResponse>
| PromiseFulfilledResult<TmdbSearchTvResponse>
)[];
const results: (TmdbMovieResult | TmdbTvResult)[] = [];

View File

@@ -13,7 +13,9 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}
const userRepository = getRepository(User);
const users = await userRepository.find();
const users = await userRepository.find({
select: ['id'],
});
let errorOccurred = false;
@@ -28,26 +30,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const radarrTags = await radarr.getTags();
for (const user of users) {
const userTag = radarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
const userTag = radarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
);
if (!userTag) {
continue;
}
await radarr.renameTag({
id: userTag.id,
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
});
}
} catch (error) {
@@ -70,26 +61,15 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const sonarrTags = await sonarr.getTags();
for (const user of users) {
const userTag = sonarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
const userTag = sonarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
);
if (!userTag) {
continue;
}
await sonarr.renameTag({
id: userTag.id,
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
});
}
} catch (error) {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734786596045 implements MigrationInterface {
export class AddTelegramMessageThreadId1734786596045
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734786596045';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAvatarCacheFields1743107707465 implements MigrationInterface {
export class AddUserAvatarCacheFields1743107707465
implements MigrationInterface
{
name = 'AddUserAvatarCacheFields1743107707465';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,26 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
);
}
}

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface {
export class AddUserRequestDeleteCascades1608219049304
implements MigrationInterface
{
name = 'AddUserRequestDeleteCascades1608219049304';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface {
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface
{
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface {
export class ForceDropImdbUniqueConstraint1608477467935
implements MigrationInterface
{
name = 'ForceDropImdbUniqueConstraint1608477467936';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface {
export class RemoveTmdbIdUniqueConstraint1609236552057
implements MigrationInterface
{
name = 'RemoveTmdbIdUniqueConstraint1609236552057';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface {
export class AddMediaAddedFieldToMedia1610522845513
implements MigrationInterface
{
name = 'AddMediaAddedFieldToMedia1610522845513';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface {
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface
{
name = 'SonarrRadarrSyncServiceFields1611757511674';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface {
export class AddResetPasswordGuidAndExpiryDate1612482778137
implements MigrationInterface
{
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface {
export class UpdateUserSettingsRegions1613955393450
implements MigrationInterface
{
name = 'UpdateUserSettingsRegions1613955393450';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface {
export class AddTelegramSettingsToUserSettings1614334195680
implements MigrationInterface
{
name = 'AddTelegramSettingsToUserSettings1614334195680';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface {
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface
{
name = 'CreateTagsFieldonMediaRequest1617624225464';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface {
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface
{
name = 'AddUserSettingsNotificationAgentsField1617730837489';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface {
export class CreateUserPushSubscriptions1618912653565
implements MigrationInterface
{
name = 'CreateUserPushSubscriptions1618912653565';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface {
export class AddUserSettingsNotificationTypes1619339817343
implements MigrationInterface
{
name = 'AddUserSettingsNotificationTypes1619339817343';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface {
export class AddPushbulletPushoverUserSettings1635079863457
implements MigrationInterface
{
name = 'AddPushbulletPushoverUserSettings1635079863457';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWatchlistSyncUserSetting1660632269368 implements MigrationInterface {
export class AddWatchlistSyncUserSetting1660632269368
implements MigrationInterface
{
name = 'AddWatchlistSyncUserSetting1660632269368';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaRequestIsAutoRequestedField1660714479373 implements MigrationInterface {
export class AddMediaRequestIsAutoRequestedField1660714479373
implements MigrationInterface
{
name = 'AddMediaRequestIsAutoRequestedField1660714479373';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsStreamingRegion1727907530757 implements MigrationInterface {
export class AddUserSettingsStreamingRegion1727907530757
implements MigrationInterface
{
name = 'AddUserSettingsStreamingRegion1727907530757';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTelegramMessageThreadId1734287582736 implements MigrationInterface {
export class AddTelegramMessageThreadId1734287582736
implements MigrationInterface
{
name = 'AddTelegramMessageThreadId1734287582736';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,6 +1,8 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAvatarCacheFields1743107645301 implements MigrationInterface {
export class AddUserAvatarCacheFields1743107645301
implements MigrationInterface
{
name = 'AddUserAvatarCacheFields1743107645301';
public async up(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,24 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface {
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
}
}

View File

@@ -7,7 +7,6 @@ import type {
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type { Video } from './Movie';
import type {
Cast,
Crew,
@@ -25,6 +24,7 @@ import {
mapVideos,
mapWatchProviders,
} from './common';
import type { Video } from './Movie';
interface Episode {
id: number;

View File

@@ -149,7 +149,7 @@ export const mapWatchProviders = (watchProvidersResult: {
link: provider.link,
buy: mapWatchProviderDetails(provider.buy ?? []),
flatrate: mapWatchProviderDetails(provider.flatrate ?? []),
}) as WatchProviders
} as WatchProviders)
);
export const mapWatchProviderDetails = (
@@ -162,10 +162,10 @@ export const mapWatchProviderDetails = (
logoPath: provider.logo_path,
id: provider.provider_id,
name: provider.provider_name,
}) as WatchProviderDetails
} as WatchProviderDetails)
);
const siteUrlCreator = (site: Video['site'], key: string): string =>
({
YouTube: `https://www.youtube.com/watch?v=${key}`,
})[site];
}[site]);

View File

@@ -594,6 +594,189 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
});
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
hostname ?? '',
undefined,
undefined
);
const response = await jellyfinServer.initiateQuickConnect();
return res.status(200).json({
code: response.Code,
secret: response.Secret,
});
} catch (error) {
logger.error('Error initiating Jellyfin quick connect', {
label: 'Auth',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Failed to initiate quick connect.',
});
}
});
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
const secret = req.query.secret as string;
if (
!secret ||
typeof secret !== 'string' ||
secret.length < 8 ||
secret.length > 128 ||
!/^[A-Fa-f0-9]+$/.test(secret)
) {
return next({
status: 400,
message: 'Invalid secret format',
});
}
try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
hostname ?? '',
undefined,
undefined
);
const response = await jellyfinServer.checkQuickConnect(secret);
return res.status(200).json({ authenticated: response.Authenticated });
} catch (e) {
return next({
status: e.statusCode || 500,
message: 'Failed to check Quick Connect status',
});
}
});
authRoutes.post(
'/jellyfin/quickconnect/authenticate',
async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { secret?: string };
if (
!body.secret ||
typeof body.secret !== 'string' ||
body.secret.length < 8 ||
body.secret.length > 128 ||
!/^[A-Fa-f0-9]+$/.test(body.secret)
) {
return next({
status: 400,
message: 'Secret required',
});
}
if (
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
!(await userRepository.count())
) {
return next({
status: 403,
message: 'Quick Connect is not available during initial setup.',
});
}
try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
hostname ?? '',
undefined,
undefined
);
const account = await jellyfinServer.authenticateQuickConnect(
body.secret
);
let user = await userRepository.findOne({
where: { jellyfinUserId: account.User.Id },
});
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
'base64'
);
if (user) {
logger.info('Quick Connect sign-in from existing user', {
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
userId: user.id,
});
user.jellyfinAuthToken = account.AccessToken;
user.jellyfinDeviceId = deviceId;
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
} else if (!settings.main.newPlexLogin) {
logger.warn(
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
{
label: 'API',
ip: req.ip,
jellyfinUserId: account.User.Id,
jellyfinUsername: account.User.Name,
}
);
return next({
status: 403,
message: 'Access denied.',
});
} else {
logger.info(
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user = new User({
email: account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
}
// Set session
if (req.session) {
req.session.userId = user.id;
}
return res.status(200).json(user?.filter() ?? {});
} catch (e) {
logger.error('Quick Connect authentication failed', {
label: 'Auth',
error: e.message,
ip: req.ip,
});
return next({
status: e.statusCode || 500,
message: ApiErrorCode.InvalidCredentials,
});
}
}
);
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
@@ -626,6 +809,76 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!user.plexId) {
try {
const plexUsersResponse = await mainPlexTv.getUsers();
const account = plexUsersResponse.MediaContainer.User.find(
(account) =>
account.$.email &&
account.$.email.toLowerCase() === user.email.toLowerCase()
)?.$;
if (
account &&
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
) {
logger.info(
'Found matching Plex user; updating user with Plex data',
{
label: 'API',
ip: req.ip,
email: body.email,
userId: user.id,
plexId: account.id,
plexUsername: account.username,
}
);
user.plexId = parseInt(account.id);
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
await userRepository.save(user);
}
} catch (e) {
logger.error('Something went wrong fetching Plex users', {
label: 'API',
errorMessage: e.message,
});
}
}
if (
user.plexId &&
user.plexId !== mainUser.plexId &&
!(await mainPlexTv.checkUserAccess(user.plexId))
) {
logger.warn(
'Failed sign-in attempt from Plex user without access to the media server',
{
label: 'API',
account: {
ip: req.ip,
email: body.email,
userId: user.id,
plexId: user.plexId,
},
}
);
return next({
status: 403,
message: 'Access denied.',
});
}
// Set logged in session
if (user && req.session) {
req.session.userId = user.id;
@@ -705,7 +958,7 @@ authRoutes.post('/logout', async (req, res, next) => {
});
return next({ status: 500, message: 'Failed to destroy session.' });
}
logger.debug('Successfully logged out user', {
logger.info('Successfully logged out user', {
label: 'Auth',
userId,
});

View File

@@ -33,15 +33,15 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
user?.settings?.streamingRegion === 'all'
? ''
: user?.settings?.streamingRegion
? user?.settings?.streamingRegion
: settings.main.discoverRegion;
? user?.settings?.streamingRegion
: settings.main.discoverRegion;
const originalLanguage =
user?.settings?.originalLanguage === 'all'
? ''
: user?.settings?.originalLanguage
? user?.settings?.originalLanguage
: settings.main.originalLanguage;
? user?.settings?.originalLanguage
: settings.main.originalLanguage;
return new TheMovieDb({
discoverRegion,
@@ -697,16 +697,16 @@ discoverRoutes.get('/trending', async (req, res, next) => {
)
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
(med) =>
med.tmdbId === result.id && med.mediaType === MediaType.TV
)
)
),
});
} catch (e) {

View File

@@ -12,9 +12,9 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import { mapWatchProviderDetails } from '@server/models/common';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';

View File

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
@@ -17,7 +17,7 @@ import type {
UserResultsResponse,
UserWatchDataResponse,
} from '@server/interfaces/api/userInterfaces';
import { Permission, hasPermission } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -25,8 +25,7 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import { In } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -189,82 +188,30 @@ router.post<
}
>('/registerPushSubscription', async (req, res, next) => {
try {
// This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
const userPushSubRepository = getRepository(UserPushSubscription);
// Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true },
where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
});
const existingSubs = await userPushSubRepository.find({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
if (existingSubscription) {
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
if (
existingSubscription.endpoint === req.body.endpoint &&
existingSubscription.auth !== req.body.auth
) {
existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
await transactionalRepo.save(existingSubscription);
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
logger.debug(
'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' }
);
return;
}
logger.debug(
'Duplicate subscription detected. Skipping registration.',
{ label: 'API' }
);
return;
}
// Clean up old subscriptions from the same device (userAgent) for this user
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
// Only clean up if we're creating a new subscription (not updating an existing one)
if (req.body.userAgent) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
userAgent: req.body.userAgent,
user: { id: req.user?.id },
// Only remove subscriptions with different endpoints (stale ones)
// Keep subscriptions that might be from different browsers/tabs
endpoint: Not(req.body.endpoint),
},
});
if (staleSubscriptions.length > 0) {
await transactionalRepo.remove(staleSubscriptions);
logger.debug(
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
{ label: 'API' }
);
}
}
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
await transactionalRepo.save(userPushSubscription);
}
);
userPushSubRepository.save(userPushSubscription);
return res.status(204).send();
} catch (e) {

View File

@@ -543,6 +543,81 @@ userSettingsRoutes.delete<{ id: string }>(
}
);
userSettingsRoutes.post<{ secret: string }>(
'/linked-accounts/jellyfin/quickconnect',
isOwnProfile(),
async (req, res) => {
const settings = getSettings();
const userRepository = getRepository(User);
if (!req.user) {
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
}
const secret = req.body.secret;
if (
!secret ||
typeof secret !== 'string' ||
secret.length < 8 ||
secret.length > 128 ||
!/^[A-Fa-f0-9]+$/.test(secret)
) {
return res.status(400).json({ message: 'Invalid secret format' });
}
if (
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
settings.main.mediaServerType !== MediaServerType.EMBY
) {
return res
.status(500)
.json({ message: 'Jellyfin/Emby login is disabled' });
}
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(hostname);
try {
const account = await jellyfinServer.authenticateQuickConnect(secret);
if (
await userRepository.exist({
where: { jellyfinUserId: account.User.Id },
})
) {
return res.status(422).json({
message: 'The specified account is already linked to a Seerr user',
});
}
const user = req.user;
const deviceId = Buffer.from(
`BOT_seerr_qc_link_${account.User.Id}`
).toString('base64');
user.userType =
settings.main.mediaServerType === MediaServerType.EMBY
? UserType.EMBY
: UserType.JELLYFIN;
user.jellyfinUserId = account.User.Id;
user.jellyfinUsername = account.User.Name;
user.jellyfinAuthToken = account.AccessToken;
user.jellyfinDeviceId = deviceId;
await userRepository.save(user);
return res.status(204).send();
} catch (e) {
logger.error('Failed to link account with Quick Connect.', {
label: 'API',
ip: req.ip,
error: e,
});
return res.status(500).send();
}
}
);
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),

View File

@@ -13,7 +13,9 @@ import type { EntitySubscriberInterface, InsertEvent } from 'typeorm';
import { EventSubscriber } from 'typeorm';
@EventSubscriber()
export class IssueCommentSubscriber implements EntitySubscriberInterface<IssueComment> {
export class IssueCommentSubscriber
implements EntitySubscriberInterface<IssueComment>
{
public listenTo(): typeof IssueComment {
return IssueComment;
}

View File

@@ -67,16 +67,16 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
: ''
}Issue Reported`
: type === Notification.ISSUE_RESOLVED
? `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Resolved`
: `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reopened`,
? `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Resolved`
: `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reopened`,
subject: title,
message: firstComment.message,
issue: entity,

View File

@@ -29,18 +29,10 @@ import type {
} from 'typeorm';
import { EventSubscriber } from 'typeorm';
const sanitizeDisplayName = (displayName: string): string => {
return displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
@EventSubscriber()
export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRequest> {
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
{
private async notifyAvailableMovie(
entity: MediaRequest,
event?: UpdateEvent<MediaRequest>
@@ -318,15 +310,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await radarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {
@@ -571,8 +559,8 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
? [...sonarrSettings.tags]
: [];
if (
entity.rootFolder &&
@@ -643,15 +631,11 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {

View File

@@ -1,10 +1,7 @@
import type { ApiErrorCode } from '@server/constants/error';
export class ApiError extends Error {
constructor(
public statusCode: number,
public errorCode: ApiErrorCode
) {
constructor(public statusCode: number, public errorCode: ApiErrorCode) {
super();
this.name = 'apiError';

33
server/types/plex-api.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
declare module 'plex-api' {
export default class PlexAPI {
constructor(intiialOptions: {
hostname: string;
port: number;
token?: string;
https?: boolean;
timeout?: number;
authenticator: {
authenticate: (
_plexApi: PlexAPI,
cb: (err?: string, token?: string) => void
) => void;
};
options: {
identifier: string;
product: string;
deviceName: string;
platform: string;
};
requestOptions?: Record<string, string | number>;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: <T extends Record<string, any>>(
endpoint:
| string
| {
uri: string;
extraHeaders?: Record<string, string | number>;
}
) => Promise<T>;
}
}

View File

@@ -11,14 +11,9 @@ export let requestInterceptorFunction: (
) => InternalAxiosRequestConfig;
export default async function createCustomProxyAgent(
proxySettings: ProxySettings,
forceIpv4First?: boolean
proxySettings: ProxySettings
) {
const defaultAgent = new Agent({
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
const skipUrl = (url: string | URL) => {
const hostname =
@@ -72,23 +67,16 @@ export default async function createCustomProxyAgent(
uri: proxyUrl,
token,
keepAliveTimeout: 5000,
connections: 50,
connect: forceIpv4First ? { family: 4 } : undefined,
});
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
const agentOptions = {
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 5000,
scheduling: 'lifo' as const,
family: forceIpv4First ? 4 : undefined,
};
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions);
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions);
});
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
headers: token ? { 'proxy-authorization': token } : undefined,
});
requestInterceptorFunction = (config) => {
const url = config.baseURL

View File

@@ -385,7 +385,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</div>
</div>
<div className="z-10 ml-4 mt-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">

View File

@@ -329,7 +329,7 @@ const BlacklistedTagImportForm = forwardRef<
const VerifyClearIndicator = <
Option,
IsMuti extends boolean,
Group extends GroupBase<Option>,
Group extends GroupBase<Option>
>(
props: ClearIndicatorProps<Option, IsMuti, Group>
) => {

View File

@@ -134,10 +134,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
type: 'or',
}) &&
data.parts.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo.status === MediaStatus.DELETED ||
part.mediaInfo.status === MediaStatus.UNKNOWN
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
).length > 0;
const hasRequestable4k =
@@ -147,9 +144,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}) &&
data.parts.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo.status4k === MediaStatus.DELETED ||
part.mediaInfo.status4k === MediaStatus.UNKNOWN
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
).length > 0;
const collectionAttributes: React.ReactNode[] = [];

View File

@@ -13,7 +13,7 @@ export type ButtonType =
// Helper type to override types (overrides onClick)
type MergeElementProps<
T extends React.ElementType,
P extends Record<string, unknown>,
P extends Record<string, unknown>
> = Omit<React.ComponentProps<T>, keyof P> & P;
type ElementTypes = 'button' | 'a';

View File

@@ -57,7 +57,7 @@ const DropdownItems = ({
>
<Menu.Items
className={[
'absolute right-0 z-40 -mr-1 mt-2 w-56 origin-top-right rounded-md p-1 shadow-lg',
'absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md p-1 shadow-lg',
dropdownType === 'ghost'
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
: 'bg-indigo-600',

View File

@@ -91,7 +91,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition.Child
appear
as="div"
className="fixed bottom-0 left-0 right-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -116,7 +116,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pb-4 pt-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -135,7 +135,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
ref={modalRef}
>
{backdrop && (
<div className="absolute left-0 right-0 top-0 z-0 h-64 max-h-full w-full">
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage
type="tmdb"
alt=""

View File

@@ -83,7 +83,7 @@ const MultiRangeSlider = ({
max={max}
value={valueMax}
step="1"
className={`pointer-events-none absolute left-0 right-0 top-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
onChange={(e) => {
const value = Number(e.target.value);

View File

@@ -35,8 +35,8 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
isHidden
? 'password'
: props.type !== 'password'
? (props.type ?? 'text')
: 'text'
? props.type ?? 'text'
: 'text'
}
/>
<button

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