Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac453878e | ||
|
|
528db09954 | ||
|
|
2f6be955b5 | ||
|
|
85bbc85714 | ||
|
|
8dc1d8196c | ||
|
|
63dc27d400 | ||
|
|
29034b350d | ||
|
|
7438042757 | ||
|
|
0b0b76e58c | ||
|
|
a5cb505609 | ||
|
|
7cb127ec3f | ||
|
|
1635932375 | ||
|
|
c1aeab9538 | ||
|
|
70fb1f2b00 | ||
|
|
4cd02babba | ||
|
|
f5b3a526cb | ||
|
|
e5ab847547 | ||
|
|
40539cc4b1 | ||
|
|
0bd6d57834 | ||
|
|
5663ac1af3 | ||
|
|
3b23da4ed7 | ||
|
|
f884ac9c66 | ||
|
|
c2d9d00b41 | ||
|
|
77a36f9714 | ||
|
|
f773e0fb2a | ||
|
|
767a24164d | ||
|
|
8394eb5ad4 | ||
|
|
b8425d6388 | ||
|
|
ebb7f00305 | ||
|
|
418d51590d | ||
|
|
a6dd4a8fed | ||
|
|
4d1163c343 | ||
|
|
b085e12ff9 | ||
|
|
33e7a153aa | ||
|
|
9891a7577c | ||
|
|
4560c0f843 | ||
|
|
2cd843535d | ||
|
|
077e355c77 | ||
|
|
21ab20bba9 | ||
|
|
cdfb30ea16 | ||
|
|
771ecdf781 | ||
|
|
863b675c77 | ||
|
|
5b998bef82 | ||
|
|
ceaf0b6df5 | ||
|
|
73c7ff257f | ||
|
|
0113612ced | ||
|
|
f8c9689745 | ||
|
|
af8d6b475c | ||
|
|
dcc13080bc | ||
|
|
e97a13e1e4 | ||
|
|
1de518d915 | ||
|
|
4e44282387 | ||
|
|
67bd639a43 | ||
|
|
ada467ecf4 | ||
|
|
9cc6930fed | ||
|
|
3b4d6bf5b8 | ||
|
|
07e4662205 | ||
|
|
4eddbaa71b | ||
|
|
27112be933 | ||
|
|
a790b1abcc | ||
|
|
f0a6055774 | ||
|
|
a3f4773a35 | ||
|
|
73d8efaa54 | ||
|
|
9712f56054 | ||
|
|
b1f07f0eb2 | ||
|
|
64f05bcad6 | ||
|
|
80927b9705 | ||
|
|
d563b36186 | ||
|
|
117617188e | ||
|
|
525a538f34 | ||
|
|
0d2273ff6e | ||
|
|
e035cd84ae | ||
|
|
438ccfe9c3 | ||
|
|
c181cee328 | ||
|
|
98a5b05816 | ||
|
|
b29959b063 | ||
|
|
9a2c12e51c | ||
|
|
620135aeac | ||
|
|
2dbd1096d2 | ||
|
|
24d3f523fc | ||
|
|
2b7974fa06 | ||
|
|
907ba6fdea | ||
|
|
efaad21554 | ||
|
|
6ab463285d | ||
|
|
418f0c2eb8 | ||
|
|
002557d2d0 | ||
|
|
62c1a70b37 | ||
|
|
1b325e7c32 | ||
|
|
f247642b76 | ||
|
|
396cd968ef | ||
|
|
ca739315b2 | ||
|
|
9143a6c027 | ||
|
|
d7fc03650f | ||
|
|
80fc5c1a78 | ||
|
|
95737d36e6 | ||
|
|
0fd6ca85a4 | ||
|
|
7cee9b475d | ||
|
|
ff9af866f8 | ||
|
|
5ffe6419ee | ||
|
|
8afcf5a8d8 | ||
|
|
17d93a8cb9 | ||
|
|
549082c53e | ||
|
|
fbef7e2c72 | ||
|
|
93d2e26ae9 | ||
|
|
f09a432635 | ||
|
|
a8f84d4f74 | ||
|
|
88e96fa163 | ||
|
|
2d814c1416 | ||
|
|
2f4b848b2c | ||
|
|
0ee3e69a61 |
@@ -7,7 +7,7 @@
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "jellyseerr",
|
||||
"projectOwner": "Fallenbagel",
|
||||
"projectOwner": "fallenbagel",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true,
|
||||
@@ -94,7 +94,8 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
||||
"profile": "https://github.com/jab416171",
|
||||
"contributions": [
|
||||
"doc"
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -248,7 +249,8 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
|
||||
"profile": "http://www.piribisoft.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -338,7 +340,8 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||
"profile": "https://gauthierth.fr/",
|
||||
"contributions": [
|
||||
"code"
|
||||
"code",
|
||||
"maintenance"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -592,6 +595,222 @@
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "andrewkolda",
|
||||
"name": "andrewkolda",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/158614532?v=4",
|
||||
"profile": "https://github.com/andrewkolda",
|
||||
"contributions": [
|
||||
"design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ishanjain28",
|
||||
"name": "Ishan Jain",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
|
||||
"profile": "https://ishanjain.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "michaelhthomas",
|
||||
"name": "Michael Thomas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
|
||||
"profile": "http://michaelt.xyz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "j0srisk",
|
||||
"name": "Joseph Risk",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||
"profile": "http://josephrisk.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Loetwiek",
|
||||
"name": "Loetwiek",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||
"profile": "https://github.com/Loetwiek",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Fuochi",
|
||||
"name": "Fuochi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||
"profile": "https://github.com/Fuochi",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "demrich",
|
||||
"name": "David Emrich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||
"profile": "https://github.com/demrich",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "maxnatamo",
|
||||
"name": "Max T. Kristiansen",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||
"profile": "https://maxtrier.dk",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DamsDev1",
|
||||
"name": "Damien Fajole",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||
"profile": "https://damsdev.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "AhmedNSidd",
|
||||
"name": "Ahmed Siddiqui",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||
"profile": "https://github.com/AhmedNSidd",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JackW6809",
|
||||
"name": "JackOXI",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
||||
"profile": "https://github.com/JackW6809",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "StancuFlorin",
|
||||
"name": "Stancu Florin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
||||
"profile": "http://indicus.ro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "RankWeis",
|
||||
"name": "RankWeis",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/733691?v=4",
|
||||
"profile": "https://github.com/RankWeis",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "j0srisk",
|
||||
"name": "Joseph Risk",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||
"profile": "http://josephrisk.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Loetwiek",
|
||||
"name": "Loetwiek",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||
"profile": "https://github.com/Loetwiek",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Fuochi",
|
||||
"name": "Fuochi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||
"profile": "https://github.com/Fuochi",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "demrich",
|
||||
"name": "David Emrich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||
"profile": "https://github.com/demrich",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "maxnatamo",
|
||||
"name": "Max T. Kristiansen",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||
"profile": "https://maxtrier.dk",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "DamsDev1",
|
||||
"name": "Damien Fajole",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||
"profile": "https://damsdev.me",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "AhmedNSidd",
|
||||
"name": "Ahmed Siddiqui",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||
"profile": "https://github.com/AhmedNSidd",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JackW6809",
|
||||
"name": "JackOXI",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
||||
"profile": "https://github.com/JackW6809",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "StancuFlorin",
|
||||
"name": "Stancu Florin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
||||
"profile": "http://indicus.ro",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lmiklosko",
|
||||
"name": "Lukas Miklosko",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4",
|
||||
"profile": "https://github.com/lmiklosko",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "gauthier-th",
|
||||
"name": "Gauthier",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||
"profile": "https://gauthierth.fr/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
87
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
container: node:22-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -43,15 +43,23 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
build_and_push:
|
||||
build:
|
||||
name: Build & Publish Docker Images
|
||||
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
- runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
outputs:
|
||||
digest-amd64: ${{ steps.set_outputs.outputs.digest-amd64 }}
|
||||
digest-arm64: ${{ steps.set_outputs.outputs.digest-arm64 }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
@@ -70,24 +78,79 @@ jobs:
|
||||
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
- name: Build and push
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
fallenbagel/jellyseerr
|
||||
ghcr.io/${{ env.OWNER_LC }}/jellyseerr
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
tags: |
|
||||
fallenbagel/jellyseerr:develop
|
||||
ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||
BUILD_VERSION=develop
|
||||
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||
outputs: |
|
||||
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
|
||||
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
provenance: false
|
||||
- name: Set outputs
|
||||
id: set_outputs
|
||||
run: |
|
||||
platform="${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}"
|
||||
echo "digest-${platform}=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT
|
||||
|
||||
merge_and_push:
|
||||
name: Create and Push Multi-arch Manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set lower case owner name
|
||||
run: |
|
||||
echo "OWNER_LC=${OWNER,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker manifest create fallenbagel/jellyseerr:develop \
|
||||
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||
--amend fallenbagel/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||
docker manifest push fallenbagel/jellyseerr:develop
|
||||
|
||||
# GHCR manifest
|
||||
docker manifest create ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop \
|
||||
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-amd64 }} \
|
||||
--amend ghcr.io/${{ env.OWNER_LC }}/jellyseerr@${{ needs.build.outputs.digest-arm64 }}
|
||||
docker manifest push ghcr.io/${{ env.OWNER_LC }}/jellyseerr:develop
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build_and_push
|
||||
needs: merge_and_push
|
||||
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v3
|
||||
|
||||
8
.github/workflows/cypress.yml
vendored
@@ -36,3 +36,11 @@ jobs:
|
||||
# Fix test titles in cypress dashboard
|
||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||
- name: Upload video files
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: |
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
2
.github/workflows/preview.yml
vendored
@@ -33,5 +33,7 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||
tags: |
|
||||
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
96
CHANGELOG.md
@@ -1,3 +1,99 @@
|
||||
## [2.5.2](https://github.com/fallenbagel/jellyseerr/compare/v2.5.1...v2.5.2) (2025-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** Bitwarden autofill fix on local/Jellyfin login (2) ([#1487](https://github.com/fallenbagel/jellyseerr/issues/1487)) ([85bbc85](https://github.com/fallenbagel/jellyseerr/commit/85bbc857141d38bcf5244078437ed6a3318bba67))
|
||||
* **avatar:** fix avatar cache busting by using avatarVersion ([#1537](https://github.com/fallenbagel/jellyseerr/issues/1537)) ([29034b3](https://github.com/fallenbagel/jellyseerr/commit/29034b350d35ebaed52556448e46436aeb644e77))
|
||||
* correct "Remove from *arr" button ([#1544](https://github.com/fallenbagel/jellyseerr/issues/1544)) ([8dc1d81](https://github.com/fallenbagel/jellyseerr/commit/8dc1d8196c67bee0e772941445c294f0ca367961)), closes [#1476](https://github.com/fallenbagel/jellyseerr/issues/1476) [#1494](https://github.com/fallenbagel/jellyseerr/issues/1494)
|
||||
* **helm:** apply annotations to pvc ([#1489](https://github.com/fallenbagel/jellyseerr/issues/1489)) ([e5ab847](https://github.com/fallenbagel/jellyseerr/commit/e5ab847547564869c3aa6443b1e22208c09a7810))
|
||||
* **jellyfin:** ensure deviceID is never empty ([#1538](https://github.com/fallenbagel/jellyseerr/issues/1538)) ([7438042](https://github.com/fallenbagel/jellyseerr/commit/7438042757cb0e81534cf9f766d84dd3ff57fd84))
|
||||
* **job:** handle media removal for 4k on the same server ([#1543](https://github.com/fallenbagel/jellyseerr/issues/1543)) ([63dc27d](https://github.com/fallenbagel/jellyseerr/commit/63dc27d400ecc80a18442fc42dd417cc03c3f9e1))
|
||||
* **job:** rename Plex Sync to Jellyfin Sync ([#1549](https://github.com/fallenbagel/jellyseerr/issues/1549)) ([2f6be95](https://github.com/fallenbagel/jellyseerr/commit/2f6be955b51e8920c8954413286577e6fea4aee2))
|
||||
* **migrations:** add missing Postgres migration and fix SQLite migration ([#1532](https://github.com/fallenbagel/jellyseerr/issues/1532)) ([0b0b76e](https://github.com/fallenbagel/jellyseerr/commit/0b0b76e58c583fc7c31d7821e7825e32065f7944)), closes [#1466](https://github.com/fallenbagel/jellyseerr/issues/1466)
|
||||
* **ui:** handle import-from-plex response as array ([#1510](https://github.com/fallenbagel/jellyseerr/issues/1510)) ([4cd02ba](https://github.com/fallenbagel/jellyseerr/commit/4cd02babbace98c01bcef153a50d34cb36dd1d4b))
|
||||
* **ui:** resolve discover language dropdown overlap ([#1497](https://github.com/fallenbagel/jellyseerr/issues/1497)) ([f5b3a52](https://github.com/fallenbagel/jellyseerr/commit/f5b3a526cb9b12c19e5ff6a79240e3d85685ff9b)), closes [#1475](https://github.com/fallenbagel/jellyseerr/issues/1475)
|
||||
|
||||
## [2.5.1](https://github.com/fallenbagel/jellyseerr/compare/v2.5.0...v2.5.1) (2025-03-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** Bitwarden autofill fix on local/Jellyfin login ([#1459](https://github.com/fallenbagel/jellyseerr/issues/1459)) ([b085e12](https://github.com/fallenbagel/jellyseerr/commit/b085e12ff9df9f57d71ca1fe27fefa8319229a2a))
|
||||
* **blacklist:** add back the blacklist button on TitleCard for Plex ([#1463](https://github.com/fallenbagel/jellyseerr/issues/1463)) ([4d1163c](https://github.com/fallenbagel/jellyseerr/commit/4d1163c34384efa59fe9b5401c5bd42d7f0435fc)), closes [#1398](https://github.com/fallenbagel/jellyseerr/issues/1398)
|
||||
* check if the file still exists in the service before deleting ([#1476](https://github.com/fallenbagel/jellyseerr/issues/1476)) ([f773e0f](https://github.com/fallenbagel/jellyseerr/commit/f773e0fb2a62f4f316ca7f8fe3d8dabdebae2ab7))
|
||||
* **job:** resolve edge case issue with season availability updates ([#1483](https://github.com/fallenbagel/jellyseerr/issues/1483)) ([77a36f9](https://github.com/fallenbagel/jellyseerr/commit/77a36f971444ee5dc0d15b2d34a8daaf4e1f28b5))
|
||||
* **mediarequest:** correct download sync for Radarr ([#1484](https://github.com/fallenbagel/jellyseerr/issues/1484)) ([c2d9d00](https://github.com/fallenbagel/jellyseerr/commit/c2d9d00b415fecbb5a8d7ca28a6ed76ea3ba3c19)), closes [#1376](https://github.com/fallenbagel/jellyseerr/issues/1376)
|
||||
* **proxy:** update http proxy to accept bypass list with undici v7 ([#1456](https://github.com/fallenbagel/jellyseerr/issues/1456)) ([9891a75](https://github.com/fallenbagel/jellyseerr/commit/9891a7577cc0874f41c38ff0e6e5a6b4d8315281)), closes [#1454](https://github.com/fallenbagel/jellyseerr/issues/1454)
|
||||
* **requestlist:** hide the remove from *arr button when no service exists ([#1457](https://github.com/fallenbagel/jellyseerr/issues/1457)) ([33e7a15](https://github.com/fallenbagel/jellyseerr/commit/33e7a153aa64461a715595d070fba53d52b34767)), closes [#1449](https://github.com/fallenbagel/jellyseerr/issues/1449)
|
||||
* **smtp-notification-test:** missing allowSelfSigned option in test function ([#1461](https://github.com/fallenbagel/jellyseerr/issues/1461)) ([b8425d6](https://github.com/fallenbagel/jellyseerr/commit/b8425d6388003322edd7b4b2473aeb24c06e4802))
|
||||
* **ui:** correct seasons badge order ([#1485](https://github.com/fallenbagel/jellyseerr/issues/1485)) ([f884ac9](https://github.com/fallenbagel/jellyseerr/commit/f884ac9c660d1931c8b3815dcaefd109da249f2a))
|
||||
* **ui:** move watch trailer button above the 4k request button ([#1465](https://github.com/fallenbagel/jellyseerr/issues/1465)) ([a6dd4a8](https://github.com/fallenbagel/jellyseerr/commit/a6dd4a8fedb9af9810581b1cc18cfea53b3cfd39)), closes [#1462](https://github.com/fallenbagel/jellyseerr/issues/1462)
|
||||
* **ui:** resolve streaming region dropdown overlap ([#1477](https://github.com/fallenbagel/jellyseerr/issues/1477)) ([767a241](https://github.com/fallenbagel/jellyseerr/commit/767a24164d6c9d101e613c53960985f4fbe2ce93)), closes [#1475](https://github.com/fallenbagel/jellyseerr/issues/1475)
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* **airdate:** reverts airdate offset & changes relative time to only display date (not time) ([#1467](https://github.com/fallenbagel/jellyseerr/issues/1467)) ([8394eb5](https://github.com/fallenbagel/jellyseerr/commit/8394eb5ad405a90e840952d5977712e1ab890530)), closes [#1390](https://github.com/fallenbagel/jellyseerr/issues/1390)
|
||||
|
||||
# [2.5.0](https://github.com/fallenbagel/jellyseerr/compare/v2.4.0...v2.5.0) (2025-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** correct media action icon size ([#1444](https://github.com/fallenbagel/jellyseerr/issues/1444)) ([771ecdf](https://github.com/fallenbagel/jellyseerr/commit/771ecdf7812004eec0f516cc424f9982936c8a2a)), closes [#1440](https://github.com/fallenbagel/jellyseerr/issues/1440)
|
||||
* **users:** correct user list for Postgres ([#1443](https://github.com/fallenbagel/jellyseerr/issues/1443)) ([5b998be](https://github.com/fallenbagel/jellyseerr/commit/5b998bef82388dccaaa462ff2ff3a526dd03338c)), closes [#1333](https://github.com/fallenbagel/jellyseerr/issues/1333)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **helm:** upgrade jellyseerr to 2.4.0 ([#1438](https://github.com/fallenbagel/jellyseerr/issues/1438)) ([077e355](https://github.com/fallenbagel/jellyseerr/commit/077e355c775af92ff4dd2341543555d473c1abbb))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* reverts csrf-csrf back to csurf ([#1442](https://github.com/fallenbagel/jellyseerr/issues/1442)) ([21ab20b](https://github.com/fallenbagel/jellyseerr/commit/21ab20bba97102fe9eb9d4af4213a604c05e0acc)), closes [#1393](https://github.com/fallenbagel/jellyseerr/issues/1393)
|
||||
|
||||
# [2.4.0](https://github.com/fallenbagel/jellyseerr/compare/v2.3.0...v2.4.0) (2025-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add email requirement for local users ([#1389](https://github.com/fallenbagel/jellyseerr/issues/1389)) ([f0a6055](https://github.com/fallenbagel/jellyseerr/commit/f0a605577469248a2a7c2170be8310e106131c59)), closes [#900](https://github.com/fallenbagel/jellyseerr/issues/900) [#1367](https://github.com/fallenbagel/jellyseerr/issues/1367)
|
||||
* **api:** make item endpoints user-independent ([#1413](https://github.com/fallenbagel/jellyseerr/issues/1413)) ([9cc6930](https://github.com/fallenbagel/jellyseerr/commit/9cc6930fed31c834201fe4e8a2a2f456b878dec6))
|
||||
* assign the keep-alive value explicitly ([#1368](https://github.com/fallenbagel/jellyseerr/issues/1368)) ([438ccfe](https://github.com/fallenbagel/jellyseerr/commit/438ccfe9c37f4848b84e60a2ce64687e0b4e4dc0)), closes [#1365](https://github.com/fallenbagel/jellyseerr/issues/1365)
|
||||
* corrected spelling errors in function names ([#1366](https://github.com/fallenbagel/jellyseerr/issues/1366)) ([e035cd8](https://github.com/fallenbagel/jellyseerr/commit/e035cd84ae24502f43cf842d6d10621f28719682))
|
||||
* disable first page revalidation in useSWRInfinite ([#1386](https://github.com/fallenbagel/jellyseerr/issues/1386)) ([d563b36](https://github.com/fallenbagel/jellyseerr/commit/d563b361869d8183041cb6aea91279e17a513070)), closes [#1380](https://github.com/fallenbagel/jellyseerr/issues/1380)
|
||||
* disallow admins to edit other admins in bulk edit ([#1340](https://github.com/fallenbagel/jellyseerr/issues/1340)) ([2dbd109](https://github.com/fallenbagel/jellyseerr/commit/2dbd1096d2756a7213209419d1d4da36e7267959)), closes [#1309](https://github.com/fallenbagel/jellyseerr/issues/1309)
|
||||
* **emby:** throw the right error message if no library exists ([#1415](https://github.com/fallenbagel/jellyseerr/issues/1415)) ([67bd639](https://github.com/fallenbagel/jellyseerr/commit/67bd639a432d724bb34b7d6fed76c0bb66d94147))
|
||||
* fix remove from *arr in item details ([#1387](https://github.com/fallenbagel/jellyseerr/issues/1387)) ([9712f56](https://github.com/fallenbagel/jellyseerr/commit/9712f5605471a673edb3d25048dc08d1addd58db))
|
||||
* **helm:** no change, fixing OCI manifest corruption ([#1310](https://github.com/fallenbagel/jellyseerr/issues/1310)) ([418f0c2](https://github.com/fallenbagel/jellyseerr/commit/418f0c2eb844e8814aca0d280292e9fb372cc118))
|
||||
* **jobs:** run plex/jellyfin jobs only for the relevant media server ([#1331](https://github.com/fallenbagel/jellyseerr/issues/1331)) ([2b7974f](https://github.com/fallenbagel/jellyseerr/commit/2b7974fa06f196b40de270ad24e54b227143b081)), closes [#1329](https://github.com/fallenbagel/jellyseerr/issues/1329)
|
||||
* make watchlist buttons consistent ([#1272](https://github.com/fallenbagel/jellyseerr/issues/1272)) ([f247642](https://github.com/fallenbagel/jellyseerr/commit/f247642b76ebefd9eeb8aed485573b5d6b133673)), closes [#1270](https://github.com/fallenbagel/jellyseerr/issues/1270)
|
||||
* **mediarequest:** optimise more typeorm lifecycle triggers ([#1376](https://github.com/fallenbagel/jellyseerr/issues/1376)) ([80927b9](https://github.com/fallenbagel/jellyseerr/commit/80927b97058a219fca9fa580243cb3f966fb0b37)), closes [#513](https://github.com/fallenbagel/jellyseerr/issues/513)
|
||||
* missing plex.tv url in images remotePatterns ([#1356](https://github.com/fallenbagel/jellyseerr/issues/1356)) ([b29959b](https://github.com/fallenbagel/jellyseerr/commit/b29959b0637fd8add9598d2a3d05f9a0972b65df))
|
||||
* **overriderules:** allows every user to be added to the override rules ([#1333](https://github.com/fallenbagel/jellyseerr/issues/1333)) ([af8d6b4](https://github.com/fallenbagel/jellyseerr/commit/af8d6b475c0040f7b96f04e3783ac8b4c702b3db))
|
||||
* **overriderules:** correct disabled condition for override rule creation ([#1419](https://github.com/fallenbagel/jellyseerr/issues/1419)) ([1de518d](https://github.com/fallenbagel/jellyseerr/commit/1de518d9154ea7809688c73ebefdcac66d27bdf8))
|
||||
* **overriderules:** enable override rules only when a service exists ([#1417](https://github.com/fallenbagel/jellyseerr/issues/1417)) ([4e44282](https://github.com/fallenbagel/jellyseerr/commit/4e44282387e7b511daecd961cdc9da98cb4b0139))
|
||||
* resolve a vulnerability with admin token ([#1345](https://github.com/fallenbagel/jellyseerr/issues/1345)) ([620135a](https://github.com/fallenbagel/jellyseerr/commit/620135aeac6d9fc284a3daddcafd1964474d2789))
|
||||
* **settings:** remove dns server option ([#1416](https://github.com/fallenbagel/jellyseerr/issues/1416)) ([ada467e](https://github.com/fallenbagel/jellyseerr/commit/ada467ecf40c7c27d57ae69ad515bd245d7bb639)), closes [#1266](https://github.com/fallenbagel/jellyseerr/issues/1266)
|
||||
* **setup:** resolve looping library validation error message ([#1316](https://github.com/fallenbagel/jellyseerr/issues/1316)) ([6ab4632](https://github.com/fallenbagel/jellyseerr/commit/6ab463285d566c18ef0b4034fbfd0b5863a4f7a5))
|
||||
* **watchlist:** disable Jellyseerr's watchlist for Plex users ([#1398](https://github.com/fallenbagel/jellyseerr/issues/1398)) ([4eddbaa](https://github.com/fallenbagel/jellyseerr/commit/4eddbaa71b7972b6db33976102501fb8b6333206)), closes [#1344](https://github.com/fallenbagel/jellyseerr/issues/1344)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add a robots.txt file ([#1335](https://github.com/fallenbagel/jellyseerr/issues/1335)) ([24d3f52](https://github.com/fallenbagel/jellyseerr/commit/24d3f523fc07ff4b28d041b2a74cfb5ab0a788a7)), closes [#1323](https://github.com/fallenbagel/jellyseerr/issues/1323)
|
||||
* add linked accounts page ([#883](https://github.com/fallenbagel/jellyseerr/issues/883)) ([64f05bc](https://github.com/fallenbagel/jellyseerr/commit/64f05bcad6956f7e8cbe3fdf5f430af1f30ddd6d))
|
||||
* **airdatebadge:** convert airDate from UTC to local timezone ([#1390](https://github.com/fallenbagel/jellyseerr/issues/1390)) ([a790b1a](https://github.com/fallenbagel/jellyseerr/commit/a790b1abccfa9c3f8272ade8cd055017905dd87f)), closes [#1373](https://github.com/fallenbagel/jellyseerr/issues/1373)
|
||||
* **api:** make rottentomatoes matching more robust ([#1265](https://github.com/fallenbagel/jellyseerr/issues/1265)) ([907ba6f](https://github.com/fallenbagel/jellyseerr/commit/907ba6fdea0341e8d0f429eaf6aaa404dbc7daff))
|
||||
* **helm:** Add possibility to pass volumes and volume mounts ([#1291](https://github.com/fallenbagel/jellyseerr/issues/1291)) ([62c1a70](https://github.com/fallenbagel/jellyseerr/commit/62c1a70b373ee574ad9ff98d322085976dbc7868))
|
||||
* revamp login page and support disabling media server login ([#1286](https://github.com/fallenbagel/jellyseerr/issues/1286)) ([73d8efa](https://github.com/fallenbagel/jellyseerr/commit/73d8efaa54888b5282624e618c1461c23653f0b9))
|
||||
* **settings:** add a disclaimer for dns servers and ipv4 first settings ([#1375](https://github.com/fallenbagel/jellyseerr/issues/1375)) ([1176171](https://github.com/fallenbagel/jellyseerr/commit/117617188ed988bd8a90e9fbe8bada08d5b14513))
|
||||
* **ui:** prevent password manager interference & improve service links ([#1396](https://github.com/fallenbagel/jellyseerr/issues/1396)) ([e97a13e](https://github.com/fallenbagel/jellyseerr/commit/e97a13e1e46298be9f334c8e6c6028fb8a99c53d)), closes [#3989](https://github.com/fallenbagel/jellyseerr/issues/3989)
|
||||
* update Jellyfin logo ([#1359](https://github.com/fallenbagel/jellyseerr/issues/1359)) ([c181cee](https://github.com/fallenbagel/jellyseerr/commit/c181cee328eb867f90d906757b8bddaeb74ba9f2))
|
||||
* upgrade chart to 2.0.0 ([#1268](https://github.com/fallenbagel/jellyseerr/issues/1268)) ([0ee3e69](https://github.com/fallenbagel/jellyseerr/commit/0ee3e69a6101f5a8818b6d4c5654d84f6aac322b))
|
||||
|
||||
# [2.3.0](https://github.com/fallenbagel/jellyseerr/compare/v2.2.3...v2.3.0) (2025-01-16)
|
||||
|
||||
|
||||
|
||||
@@ -58,12 +58,27 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
|
||||
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
|
||||
- Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
|
||||
### Helm Chart
|
||||
|
||||
Tools Required:
|
||||
|
||||
- [Helm](https://helm.sh/docs/intro/install/)
|
||||
- [helm-docs](https://github.com/norwoodj/helm-docs)
|
||||
|
||||
Steps:
|
||||
|
||||
1. Make the necessary changes.
|
||||
2. Test your changes.
|
||||
3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/).
|
||||
4. Run the `helm-docs` command to regenerate the chart's README.
|
||||
|
||||
### Contributing Code
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
|
||||
@@ -97,7 +112,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Jellyseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||
|
||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||
|
||||
|
||||
19
Dockerfile
@@ -14,7 +14,7 @@ RUN \
|
||||
;; \
|
||||
esac
|
||||
|
||||
RUN npm install --global pnpm
|
||||
RUN npm install --global pnpm@9
|
||||
|
||||
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
||||
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||
@@ -29,7 +29,7 @@ RUN pnpm build
|
||||
# remove development dependencies
|
||||
RUN pnpm prune --prod --ignore-scripts
|
||||
|
||||
RUN rm -rf src server .next/cache
|
||||
RUN rm -rf src server .next/cache charts gen-docs docs
|
||||
|
||||
RUN touch config/DOCKER
|
||||
|
||||
@@ -38,14 +38,23 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
# Metadata for Github Package Registry
|
||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
||||
# OCI Meta information
|
||||
ARG BUILD_DATE
|
||||
ARG BUILD_VERSION
|
||||
LABEL \
|
||||
org.opencontainers.image.authors="Fallenbagel" \
|
||||
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||
org.opencontainers.image.created=${BUILD_DATE} \
|
||||
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||
org.opencontainers.image.title="Jellyseerr" \
|
||||
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@9
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app ./
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM node:22-alpine
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
Run npm install --global pnpm
|
||||
RUN npm install --global pnpm@9
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
121
README.md
@@ -11,7 +11,7 @@
|
||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-64-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-77-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -86,88 +86,103 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -285,7 +300,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||
</tr>
|
||||
@@ -318,6 +333,10 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 1.3.0
|
||||
appVersion: "2.2.3"
|
||||
version: 2.3.2
|
||||
appVersion: "2.5.1"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
url: https://github.com/Fallenbagel/jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
@@ -25,10 +25,6 @@ Kubernetes: `>=1.23.0-0`
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| affinity | object | `{}` | |
|
||||
| autoscaling.enabled | bool | `false` | |
|
||||
| autoscaling.maxReplicas | int | `100` | |
|
||||
| autoscaling.minReplicas | int | `1` | |
|
||||
| autoscaling.targetCPUUtilizationPercentage | int | `80` | |
|
||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
||||
| config.persistence.annotations | object | `{}` | Annotations for PVCs |
|
||||
@@ -39,7 +35,7 @@ Kubernetes: `>=1.23.0-0`
|
||||
| extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the jellyseerr pods |
|
||||
| fullnameOverride | string | `""` | |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| image.registry | string | `"docker.io"` | |
|
||||
| image.registry | string | `"ghcr.io"` | |
|
||||
| image.repository | string | `"fallenbagel/jellyseerr"` | |
|
||||
| image.sha | string | `""` | |
|
||||
| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. |
|
||||
@@ -67,3 +63,5 @@ Kubernetes: `>=1.23.0-0`
|
||||
| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template |
|
||||
| strategy | object | `{"type":"Recreate"}` | Deployment strategy |
|
||||
| tolerations | list | `[]` | |
|
||||
| volumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. |
|
||||
| volumes | list | `[]` | Additional volumes on the output Deployment definition. |
|
||||
|
||||
@@ -5,9 +5,7 @@ metadata:
|
||||
labels:
|
||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
type: {{ .Values.strategy.type }}
|
||||
selector:
|
||||
@@ -67,10 +65,16 @@ spec:
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/config
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "jellyseerr.configPersistenceName" . }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "jellyseerr.fullname" . }}
|
||||
labels:
|
||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "jellyseerr.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -4,6 +4,10 @@ metadata:
|
||||
name: {{ include "jellyseerr.configPersistenceName" . }}
|
||||
labels:
|
||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||
{{- with .Values.config.persistence.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.config.persistence.accessModes }}
|
||||
accessModes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
registry: docker.io
|
||||
registry: ghcr.io
|
||||
repository: fallenbagel/jellyseerr
|
||||
pullPolicy: IfNotPresent
|
||||
# -- Overrides the image tag whose default is the chart appVersion.
|
||||
@@ -94,12 +94,18 @@ resources: {}
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
# -- Additional volumes on the output Deployment definition.
|
||||
volumes: []
|
||||
# - name: foo
|
||||
# secret:
|
||||
# secretName: mysecret
|
||||
# optional: false
|
||||
|
||||
# -- Additional volumeMounts on the output Deployment definition.
|
||||
volumeMounts: []
|
||||
# - name: foo
|
||||
# mountPath: "/etc/foo"
|
||||
# readOnly: true
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
projectId: 'xkm1b4',
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5055',
|
||||
video: true,
|
||||
experimentalSessionAndOrigin: true,
|
||||
},
|
||||
env: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||
"main": {
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Overseerr",
|
||||
"applicationTitle": "Jellyseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
@@ -24,7 +24,6 @@
|
||||
"partialRequestsEnabled": true,
|
||||
"enableSpecialEpisodes": false,
|
||||
"forceIpv4First": false,
|
||||
"dnsServers": "",
|
||||
"locale": "en"
|
||||
},
|
||||
"plex": {
|
||||
@@ -71,7 +70,7 @@
|
||||
"ignoreTls": false,
|
||||
"requireTls": false,
|
||||
"allowSelfSigned": false,
|
||||
"senderName": "Overseerr"
|
||||
"senderName": "Jellyseerr"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
|
||||
@@ -13,10 +13,10 @@ describe('General Settings', () => {
|
||||
});
|
||||
|
||||
it('modifies setting that requires restart', () => {
|
||||
cy.visit('/settings');
|
||||
cy.visit('/settings/network');
|
||||
|
||||
cy.get('#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=settings-network-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should(
|
||||
'contain',
|
||||
'Server Restart Required'
|
||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
|
||||
cy.get('[type=checkbox]#trustProxy').click();
|
||||
cy.get('[data-testid=settings-main-form]').submit();
|
||||
cy.get('[data-testid=settings-network-form]').submit();
|
||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
|
||||
[email, password],
|
||||
() => {
|
||||
cy.visit('/login');
|
||||
cy.contains('Use your Overseerr account').click();
|
||||
|
||||
cy.get('[data-testid=email]').type(email);
|
||||
cy.get('[data-testid=password]').type(password);
|
||||
|
||||
@@ -255,7 +255,8 @@ To run jellyseerr as a service:
|
||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||
2. Install NSSM:
|
||||
```powershell
|
||||
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"]
|
||||
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js"
|
||||
nssm set Jellyseerr AppDirectory "C:\jellyseerr"
|
||||
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
||||
```
|
||||
3. Start the service:
|
||||
|
||||
@@ -37,7 +37,7 @@ docker run -d \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
fallenbagel/jellyseerr
|
||||
ghcr.io/fallenbagel/jellyseerr
|
||||
```
|
||||
:::tip
|
||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
||||
@@ -55,7 +55,7 @@ docker stop jellyseerr && docker rm Jellyseerr
|
||||
```
|
||||
Pull the latest image:
|
||||
```bash
|
||||
docker pull fallenbagel/jellyseerr
|
||||
docker pull ghcr.io/fallenbagel/jellyseerr
|
||||
```
|
||||
Finally, run the container with the same parameters originally used to create the container:
|
||||
```bash
|
||||
@@ -78,7 +78,7 @@ Define the `jellyseerr` service in your `compose.yaml` as follows:
|
||||
---
|
||||
services:
|
||||
jellyseerr:
|
||||
image: fallenbagel/jellyseerr:latest
|
||||
image: ghcr.io/fallenbagel/jellyseerr:latest
|
||||
container_name: jellyseerr
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
@@ -146,7 +146,7 @@ Then, create and start the Jellyseerr container:
|
||||
<Tabs groupId="docker-methods" queryString>
|
||||
<TabItem value="docker-cli" label="Docker CLI">
|
||||
```bash
|
||||
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
||||
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped ghcr.io/fallenbagel/jellyseerr:latest
|
||||
```
|
||||
|
||||
#### Updating:
|
||||
|
||||
@@ -24,6 +24,12 @@ or for Cloudflare's DNS:
|
||||
```bash
|
||||
--dns=1.1.1.1
|
||||
```
|
||||
or for Quad9 DNS:
|
||||
```bash
|
||||
--dns=9.9.9.9
|
||||
```
|
||||
|
||||
You can try them all and see which one works for your network.
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -45,6 +51,16 @@ services:
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
```
|
||||
or for Quad9's DNS:
|
||||
```yaml
|
||||
---
|
||||
services:
|
||||
jellyseerr:
|
||||
dns:
|
||||
- 9.9.9.9
|
||||
```
|
||||
|
||||
You can try them all and see which one works for your network.
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -56,7 +72,7 @@ services:
|
||||
4. Click on Change adapter settings.
|
||||
5. Right-click the network interface connected to the internet and select Properties.
|
||||
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
|
||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS.
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -73,6 +89,10 @@ services:
|
||||
```bash
|
||||
nameserver 1.1.1.1
|
||||
```
|
||||
or for Quad9's DNS:
|
||||
```bash
|
||||
nameserver 9.9.9.9
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -81,7 +101,7 @@ services:
|
||||
|
||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
||||
|
||||
You can try to force the resolution to use IPV4 first by setting the `FORCE_IPV4_FIRST` environment variable to `true`:
|
||||
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting. You can also add the environment variable, `FORCE_IPV4_FIRST=true`:
|
||||
|
||||
<Tabs groupId="methods" queryString>
|
||||
<TabItem value="docker-cli" label="Docker CLI">
|
||||
|
||||
@@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Enable Jellyfin/Emby/Plex Sign-In
|
||||
|
||||
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
|
||||
|
||||
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||
|
||||
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config = {
|
||||
baseUrl: '/',
|
||||
trailingSlash: false,
|
||||
|
||||
organizationName: 'Fallenbagel',
|
||||
organizationName: 'fallenbagel',
|
||||
projectName: 'Jellyseerr',
|
||||
deploymentBranch: 'gh-pages',
|
||||
|
||||
@@ -32,7 +32,7 @@ const config: Config = {
|
||||
routeBasePath: '/',
|
||||
path: '../docs',
|
||||
editUrl:
|
||||
'https://github.com/Fallenbagel/jellyseerr/edit/develop/docs/',
|
||||
'https://github.com/fallenbagel/jellyseerr/edit/develop/docs/',
|
||||
},
|
||||
blog: false,
|
||||
pages: false,
|
||||
@@ -70,7 +70,7 @@ const config: Config = {
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: 'https://github.com/Fallenbagel/jellyseerr',
|
||||
href: 'https://github.com/fallenbagel/jellyseerr',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
openapi: '3.0.2'
|
||||
info:
|
||||
title: 'Overseerr API'
|
||||
title: 'Jellyseerr API'
|
||||
version: '1.0.0'
|
||||
description: |
|
||||
This is the documentation for the Overseerr API backend.
|
||||
This is the documentation for the Jellyseerr API backend.
|
||||
|
||||
Two primary authentication methods are supported:
|
||||
|
||||
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
|
||||
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
|
||||
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Jellyseerr.
|
||||
tags:
|
||||
- name: public
|
||||
description: Public API endpoints requiring no authentication.
|
||||
- name: settings
|
||||
description: Endpoints related to Overseerr's settings and configuration.
|
||||
description: Endpoints related to Jellyseerr's settings and configuration.
|
||||
- name: auth
|
||||
description: Endpoints related to logging in or out, and the currently authenticated user.
|
||||
- name: users
|
||||
@@ -160,16 +160,10 @@ components:
|
||||
example: en
|
||||
applicationTitle:
|
||||
type: string
|
||||
example: Overseerr
|
||||
example: Jellyseerr
|
||||
applicationUrl:
|
||||
type: string
|
||||
example: https://os.example.com
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: true
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
hideAvailable:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -191,12 +185,18 @@ components:
|
||||
enableSpecialEpisodes:
|
||||
type: boolean
|
||||
example: false
|
||||
NetworkSettings:
|
||||
type: object
|
||||
properties:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
forceIpv4First:
|
||||
type: boolean
|
||||
example: false
|
||||
dnsServers:
|
||||
type: string
|
||||
example: '1.1.1.1'
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: true
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1435,7 +1435,7 @@ components:
|
||||
example: no-reply@example.com
|
||||
senderName:
|
||||
type: string
|
||||
example: Overseerr
|
||||
example: Jellyseerr
|
||||
smtpHost:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
@@ -1966,8 +1966,8 @@ components:
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
summary: Get Overseerr status
|
||||
description: Returns the current Overseerr status in a JSON object.
|
||||
summary: Get Jellyseerr status
|
||||
description: Returns the current Jellyseerr status in a JSON object.
|
||||
security: []
|
||||
tags:
|
||||
- public
|
||||
@@ -2045,6 +2045,37 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/network:
|
||||
get:
|
||||
summary: Get network settings
|
||||
description: Retrieves all network settings in a JSON object.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
post:
|
||||
summary: Update network settings
|
||||
description: Updates network settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NetworkSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NetworkSettings'
|
||||
/settings/main/regenerate:
|
||||
post:
|
||||
summary: Get main settings with newly-generated API key
|
||||
@@ -3781,6 +3812,11 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: includeIds
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: A JSON array of all users
|
||||
@@ -3929,6 +3965,8 @@ paths:
|
||||
type: string
|
||||
p256dh:
|
||||
type: string
|
||||
userAgent:
|
||||
type: string
|
||||
required:
|
||||
- endpoint
|
||||
- auth
|
||||
@@ -3936,6 +3974,88 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Successfully registered push subscription
|
||||
/user/{userId}/pushSubscriptions:
|
||||
get:
|
||||
summary: Get all web push notification settings for a user
|
||||
description: |
|
||||
Returns all web push notification settings for a user in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: User web push notification settings in JSON
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
p256dh:
|
||||
type: string
|
||||
auth:
|
||||
type: string
|
||||
userAgent:
|
||||
type: string
|
||||
/user/{userId}/pushSubscription/{key}:
|
||||
get:
|
||||
summary: Get web push notification settings for a user
|
||||
description: |
|
||||
Returns web push notification settings for a user in a JSON object.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: User web push notification settings in JSON
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
p256dh:
|
||||
type: string
|
||||
auth:
|
||||
type: string
|
||||
userAgent:
|
||||
type: string
|
||||
delete:
|
||||
summary: Delete user push subscription by key
|
||||
description: Deletes the user push subscription with the provided key.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Successfully removed user push subscription
|
||||
/user/{userId}:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
@@ -4389,6 +4509,104 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: User password updated
|
||||
/user/{userId}/settings/linked-accounts/plex:
|
||||
post:
|
||||
summary: Link the provided Plex account to the current user
|
||||
description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authToken:
|
||||
type: string
|
||||
required:
|
||||
- authToken
|
||||
responses:
|
||||
'204':
|
||||
description: Linking account succeeded
|
||||
'403':
|
||||
description: Invalid credentials
|
||||
'422':
|
||||
description: Account already linked to a user
|
||||
delete:
|
||||
summary: Remove the linked Plex account for a user
|
||||
description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'204':
|
||||
description: Unlinking account succeeded
|
||||
'400':
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin:
|
||||
post:
|
||||
summary: Link the provided Jellyfin account to the current user
|
||||
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
password:
|
||||
type: string
|
||||
example: 'supersecret'
|
||||
responses:
|
||||
'204':
|
||||
description: Linking account succeeded
|
||||
'403':
|
||||
description: Invalid credentials
|
||||
'422':
|
||||
description: Account already linked to a user
|
||||
delete:
|
||||
summary: Remove the linked Jellyfin account for a user
|
||||
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'204':
|
||||
description: Unlinking account succeeded
|
||||
'400':
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
|
||||
@@ -11,6 +11,7 @@ module.exports = {
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: 'artworks.thetvdb.com' },
|
||||
{ hostname: 'plex.tv' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
44
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jellyseerr",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "node postinstall-win.js",
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"dev": "nodemon -e ts --watch server --watch jellyseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||
"build:next": "next build",
|
||||
"build": "pnpm build:next && pnpm build:server",
|
||||
@@ -32,6 +32,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dr.pogodin/csurf": "^1.14.1",
|
||||
"@formatjs/intl-displaynames": "6.2.6",
|
||||
"@formatjs/intl-locale": "3.1.1",
|
||||
"@formatjs/intl-pluralrules": "5.1.10",
|
||||
@@ -42,20 +43,21 @@
|
||||
"@supercharge/request-ip": "1.2.0",
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"ace-builds": "1.15.2",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"connect-typeorm": "1.1.4",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cookie-parser": "1.4.7",
|
||||
"copy-to-clipboard": "3.3.3",
|
||||
"country-flag-icons": "1.5.5",
|
||||
"cronstrue": "2.23.0",
|
||||
"csurf": "1.11.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"email-templates": "9.0.0",
|
||||
"email-templates": "12.0.1",
|
||||
"email-validator": "2.0.4",
|
||||
"express": "4.18.2",
|
||||
"express": "4.21.2",
|
||||
"express-openapi-validator": "4.13.8",
|
||||
"express-rate-limit": "6.7.0",
|
||||
"express-session": "1.17.3",
|
||||
@@ -63,15 +65,15 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"mime": "3",
|
||||
"next": "^14.2.4",
|
||||
"next": "^14.2.25",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"openpgp": "5.7.0",
|
||||
"nodemailer": "6.10.0",
|
||||
"openpgp": "5.11.2",
|
||||
"pg": "8.11.0",
|
||||
"plex-api": "5.3.2",
|
||||
"pug": "3.0.2",
|
||||
"pug": "3.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "10.1.0",
|
||||
"react-animate-height": "2.1.2",
|
||||
@@ -85,24 +87,28 @@
|
||||
"react-spring": "9.7.1",
|
||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||
"react-toast-notifications": "2.5.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-truncate-markup": "5.1.2",
|
||||
"react-use-clipboard": "1.0.9",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"secure-random-password": "0.2.3",
|
||||
"semver": "7.3.8",
|
||||
"semver": "7.7.1",
|
||||
"sharp": "^0.33.4",
|
||||
"sqlite3": "5.1.4",
|
||||
"sqlite3": "5.1.7",
|
||||
"swagger-ui-express": "4.6.2",
|
||||
"swr": "2.2.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typeorm": "0.3.11",
|
||||
"undici": "^6.20.1",
|
||||
"undici": "^7.3.0",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"web-push": "3.5.0",
|
||||
"wink-jaro-distance": "^2.0.0",
|
||||
"winston": "3.8.2",
|
||||
"winston-daily-rotate-file": "4.7.1",
|
||||
"xml2js": "0.4.23",
|
||||
"yamljs": "0.3.0",
|
||||
"yup": "0.32.11",
|
||||
"zod": "3.20.6"
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "17.4.4",
|
||||
@@ -112,8 +118,8 @@
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.3",
|
||||
"@tailwindcss/typography": "0.5.9",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/country-flag-icons": "1.2.0",
|
||||
@@ -142,7 +148,7 @@
|
||||
"commitizen": "4.3.0",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
"cypress": "12.7.0",
|
||||
"cypress": "14.1.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "8.35.0",
|
||||
"eslint-config-next": "^14.2.4",
|
||||
@@ -155,8 +161,8 @@
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"husky": "8.0.3",
|
||||
"lint-staged": "13.1.2",
|
||||
"nodemon": "2.0.20",
|
||||
"postcss": "8.4.21",
|
||||
"nodemon": "3.1.9",
|
||||
"postcss": "8.4.31",
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
|
||||
2826
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
21
public/sw.js
@@ -3,7 +3,7 @@
|
||||
// previously cached resources to be updated from the network.
|
||||
// This variable is intentionally declared and unused.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const OFFLINE_VERSION = 3;
|
||||
const OFFLINE_VERSION = 4;
|
||||
const CACHE_NAME = 'offline';
|
||||
// Customize this with a different URL if needed.
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
@@ -107,6 +107,25 @@ self.addEventListener('push', (event) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Set the badge with the amount of pending requests
|
||||
// Only update the badge if the payload confirms they are the admin
|
||||
if (
|
||||
(payload.notificationType === 'MEDIA_APPROVED' ||
|
||||
payload.notificationType === 'MEDIA_DECLINED') &&
|
||||
payload.isAdmin
|
||||
) {
|
||||
if ('setAppBadge' in navigator) {
|
||||
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||
if ('setAppBadge' in navigator) {
|
||||
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||
});
|
||||
|
||||
|
||||
@@ -289,7 +289,7 @@ class ExternalAPI {
|
||||
return data;
|
||||
}
|
||||
|
||||
protected removeCache(endpoint: string, options?: Record<string, string>) {
|
||||
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
...this.params,
|
||||
...options,
|
||||
|
||||
@@ -72,7 +72,7 @@ class GithubAPI extends ExternalAPI {
|
||||
);
|
||||
}
|
||||
|
||||
public async getOverseerrReleases({
|
||||
public async getJellyseerrReleases({
|
||||
take = 20,
|
||||
}: {
|
||||
take?: number;
|
||||
@@ -88,14 +88,14 @@ class GithubAPI extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
||||
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||
{ label: 'GitHub API', errorMessage: e.message }
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async getOverseerrCommits({
|
||||
public async getJellyseerrCommits({
|
||||
take = 20,
|
||||
branch = 'develop',
|
||||
}: {
|
||||
@@ -114,7 +114,7 @@ class GithubAPI extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||
{ label: 'GitHub API', errorMessage: e.message }
|
||||
);
|
||||
return [];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import availabilitySync from '@server/lib/availabilitySync';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
@@ -92,15 +94,34 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
export interface JellyfinItemsReponse {
|
||||
Items: JellyfinLibraryItemExtended[];
|
||||
TotalRecordCount: number;
|
||||
StartIndex: number;
|
||||
}
|
||||
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private userId?: string;
|
||||
private mediaServerType: MediaServerType;
|
||||
|
||||
constructor(
|
||||
jellyfinHost: string,
|
||||
authToken?: string | null,
|
||||
deviceId?: string | null
|
||||
) {
|
||||
const settings = getSettings();
|
||||
const safeDeviceId =
|
||||
deviceId && deviceId.length > 0
|
||||
? deviceId
|
||||
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||
let authHeaderVal: string;
|
||||
if (authToken) {
|
||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||
} else {
|
||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
|
||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
|
||||
}
|
||||
|
||||
super(
|
||||
@@ -112,6 +133,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.mediaServerType = settings.main.mediaServerType;
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -292,18 +315,15 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const libraryItemsResponse = await this.get<any>(
|
||||
`/Users/${this.userId}/Items`,
|
||||
{
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Series,Movie,Others',
|
||||
Recursive: 'true',
|
||||
StartIndex: '0',
|
||||
ParentId: id,
|
||||
collapseBoxSetItems: 'false',
|
||||
}
|
||||
);
|
||||
const libraryItemsResponse = await this.get<any>(`/Items`, {
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Series,Movie,Others',
|
||||
Recursive: 'true',
|
||||
StartIndex: '0',
|
||||
ParentId: id,
|
||||
collapseBoxSetItems: 'false',
|
||||
});
|
||||
|
||||
return libraryItemsResponse.Items.filter(
|
||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||
@@ -320,13 +340,22 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const itemResponse = await this.get<any>(
|
||||
`/Users/${this.userId}/Items/Latest`,
|
||||
{
|
||||
Limit: '12',
|
||||
ParentId: id,
|
||||
}
|
||||
);
|
||||
const endpoint =
|
||||
this.mediaServerType === MediaServerType.JELLYFIN
|
||||
? `/Items/Latest`
|
||||
: `/Users/${this.userId}/Items/Latest`;
|
||||
|
||||
const baseParams = {
|
||||
Limit: '12',
|
||||
ParentId: id,
|
||||
};
|
||||
|
||||
const params =
|
||||
this.mediaServerType === MediaServerType.JELLYFIN
|
||||
? { ...baseParams, userId: this.userId ?? `Me` }
|
||||
: baseParams;
|
||||
|
||||
const itemResponse = await this.get<any>(endpoint, params);
|
||||
|
||||
return itemResponse;
|
||||
} catch (e) {
|
||||
@@ -343,11 +372,12 @@ class JellyfinAPI extends ExternalAPI {
|
||||
id: string
|
||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||
try {
|
||||
const itemResponse = await this.get<any>(
|
||||
`/Users/${this.userId}/Items/${id}`
|
||||
);
|
||||
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
||||
ids: id,
|
||||
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
||||
});
|
||||
|
||||
return itemResponse;
|
||||
return itemResponse.Items?.[0];
|
||||
} catch (e) {
|
||||
if (availabilitySync.running) {
|
||||
if (e.cause?.status === 500) {
|
||||
|
||||
@@ -92,7 +92,7 @@ class PlexAPI {
|
||||
plexSettings,
|
||||
timeout,
|
||||
}: {
|
||||
plexToken?: string;
|
||||
plexToken?: string | null;
|
||||
plexSettings?: PlexSettings;
|
||||
timeout?: number;
|
||||
}) {
|
||||
@@ -107,7 +107,7 @@ class PlexAPI {
|
||||
port: settingsPlex.port,
|
||||
https: settingsPlex.useSsl,
|
||||
timeout: timeout,
|
||||
token: plexToken,
|
||||
token: plexToken ?? undefined,
|
||||
authenticator: {
|
||||
authenticate: (
|
||||
_plexApi,
|
||||
@@ -124,9 +124,9 @@ class PlexAPI {
|
||||
// },
|
||||
options: {
|
||||
identifier: settings.clientId,
|
||||
product: 'Overseerr',
|
||||
deviceName: 'Overseerr',
|
||||
platform: 'Overseerr',
|
||||
product: 'Jellyseerr',
|
||||
deviceName: 'Jellyseerr',
|
||||
platform: 'Jellyseerr',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import jaro from 'wink-jaro-distance';
|
||||
|
||||
interface RTAlgoliaSearchResponse {
|
||||
results: {
|
||||
@@ -15,7 +16,7 @@ interface RTAlgoliaHit {
|
||||
tmsId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
titles: string[];
|
||||
titles?: string[];
|
||||
description: string;
|
||||
releaseYear: number;
|
||||
rating: string;
|
||||
@@ -24,9 +25,9 @@ interface RTAlgoliaHit {
|
||||
isEmsSearchable: boolean;
|
||||
rtId: number;
|
||||
vanity: string;
|
||||
aka: string[];
|
||||
aka?: string[];
|
||||
posterImageUrl: string;
|
||||
rottenTomatoes: {
|
||||
rottenTomatoes?: {
|
||||
audienceScore: number;
|
||||
criticsIconUrl: string;
|
||||
wantToSeeCount: number;
|
||||
@@ -47,6 +48,47 @@ export interface RTRating {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Tunables
|
||||
const INEXACT_TITLE_FACTOR = 0.25;
|
||||
const ALTERNATE_TITLE_FACTOR = 0.8;
|
||||
const PER_YEAR_PENALTY = 0.4;
|
||||
const MINIMUM_SCORE = 0.175;
|
||||
|
||||
// Normalization for title comparisons.
|
||||
// Lowercase and strip non-alphanumeric (unicode-aware).
|
||||
const norm = (s: string): string =>
|
||||
s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '');
|
||||
|
||||
// Title similarity. 1 if exact, quarter-jaro otherwise.
|
||||
const similarity = (a: string, b: string): number =>
|
||||
a === b ? 1 : jaro(a, b).similarity * INEXACT_TITLE_FACTOR;
|
||||
|
||||
// Gets the best similarity score between the searched title and all alternate
|
||||
// titles of the search result. Non-main titles are penalized.
|
||||
const t_score = ({ title, titles, aka }: RTAlgoliaHit, s: string): number => {
|
||||
const f = (t: string, i: number) =>
|
||||
similarity(norm(t), norm(s)) * (i ? ALTERNATE_TITLE_FACTOR : 1);
|
||||
return Math.max(...[title].concat(aka || [], titles || []).map(f));
|
||||
};
|
||||
|
||||
// Year difference to score: 0 -> 1.0, 1 -> 0.6, 2 -> 0.2, 3+ -> 0.0
|
||||
const y_score = (r: RTAlgoliaHit, y?: number): number =>
|
||||
y ? Math.max(0, 1 - Math.abs(r.releaseYear - y) * PER_YEAR_PENALTY) : 1;
|
||||
|
||||
// Cut score in half if result has no ratings.
|
||||
const extra_score = (r: RTAlgoliaHit): number => (r.rottenTomatoes ? 1 : 0.5);
|
||||
|
||||
// Score search result as product of all subscores
|
||||
const score = (r: RTAlgoliaHit, name: string, year?: number): number =>
|
||||
t_score(r, name) * y_score(r, year) * extra_score(r);
|
||||
|
||||
// Score each search result and return the highest scoring result, if any
|
||||
const best = (rs: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit =>
|
||||
rs
|
||||
.map((r) => ({ score: score(r, name, year), result: r }))
|
||||
.filter(({ score }) => score > MINIMUM_SCORE)
|
||||
.sort(({ score: a }, { score: b }) => b - a)[0]?.result;
|
||||
|
||||
/**
|
||||
* This is a best-effort API. The Rotten Tomatoes API is technically
|
||||
* private and getting access costs money/requires approval.
|
||||
@@ -90,47 +132,21 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"movie"');
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
query: name.replace(/\bthe\b ?/gi, ''),
|
||||
params: `filters=${filters}&hitsPerPage=20`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
const movie = best(contentResults?.hits || [], name, year);
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First, attempt to match exact name and year
|
||||
let movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year && movie.title === name
|
||||
);
|
||||
|
||||
// If we don't find a movie, try to match partial name and year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find(
|
||||
(movie) => movie.releaseYear === year && movie.title.includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
// If we still dont find a movie, try to match just on year
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
|
||||
}
|
||||
|
||||
// One last try, try exact name match only
|
||||
if (!movie) {
|
||||
movie = contentResults.hits.find((movie) => movie.title === name);
|
||||
}
|
||||
|
||||
if (!movie?.rottenTomatoes) {
|
||||
return null;
|
||||
}
|
||||
if (!movie?.rottenTomatoes) return null;
|
||||
|
||||
return {
|
||||
title: movie.title,
|
||||
@@ -158,33 +174,21 @@ class RottenTomatoes extends ExternalAPI {
|
||||
year?: number
|
||||
): Promise<RTRating | null> {
|
||||
try {
|
||||
const filters = encodeURIComponent('isEmsSearchable=1 AND type:"tv"');
|
||||
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query: name,
|
||||
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
|
||||
params: `filters=${filters}&hitsPerPage=20`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentResults = data.results.find((r) => r.index === 'content_rt');
|
||||
const tvshow = best(contentResults?.hits || [], name, year);
|
||||
|
||||
if (!contentResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
|
||||
|
||||
if (year) {
|
||||
tvshow = contentResults.hits.find(
|
||||
(series) => series.releaseYear === year
|
||||
);
|
||||
}
|
||||
|
||||
if (!tvshow || !tvshow.rottenTomatoes) {
|
||||
return null;
|
||||
}
|
||||
if (!tvshow?.rottenTomatoes) return null;
|
||||
|
||||
return {
|
||||
title: tvshow.title,
|
||||
|
||||
@@ -28,6 +28,39 @@ export interface RadarrMovie {
|
||||
qualityProfileId: number;
|
||||
added: string;
|
||||
hasFile: boolean;
|
||||
tags: number[];
|
||||
movieFile?: {
|
||||
id: number;
|
||||
movieId: number;
|
||||
relativePath?: string;
|
||||
path?: string;
|
||||
size: number;
|
||||
dateAdded: string;
|
||||
sceneName?: string;
|
||||
releaseGroup?: string;
|
||||
edition?: string;
|
||||
indexerFlags?: number;
|
||||
mediaInfo: {
|
||||
id: number;
|
||||
audioBitrate: number;
|
||||
audioChannels: number;
|
||||
audioCodec?: string;
|
||||
audioLanguages?: string;
|
||||
audioStreamCount: number;
|
||||
videoBitDepth: number;
|
||||
videoBitrate: number;
|
||||
videoCodec?: string;
|
||||
videoFps: number;
|
||||
videoDynamicRange?: string;
|
||||
videoDynamicRangeType?: string;
|
||||
resolution?: string;
|
||||
runTime?: string;
|
||||
scanType?: string;
|
||||
subtitles?: string;
|
||||
};
|
||||
originalFilePath?: string;
|
||||
qualityCutoffNotMet: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
@@ -104,7 +137,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
minimumAvailability: options.minimumAvailability,
|
||||
tmdbId: options.tmdbId,
|
||||
year: options.year,
|
||||
tags: options.tags,
|
||||
tags: Array.from(new Set([...movie.tags, ...options.tags])),
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
monitored: options.monitored,
|
||||
addOptions: {
|
||||
@@ -241,10 +274,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
if (tmdbId) {
|
||||
this.removeCache('/movie/lookup', {
|
||||
term: `tmdb:${tmdbId}`,
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
}
|
||||
if (externalId) {
|
||||
this.removeCache(`/movie/${externalId}`);
|
||||
this.removeCache(`/movie/${externalId}`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,7 +184,9 @@ class SonarrAPI extends ServarrBase<{
|
||||
// If the series already exists, we will simply just update it
|
||||
if (series.id) {
|
||||
series.monitored = options.monitored ?? series.monitored;
|
||||
series.tags = options.tags ?? series.tags;
|
||||
series.tags = options.tags
|
||||
? Array.from(new Set([...series.tags, ...options.tags]))
|
||||
: series.tags;
|
||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||
|
||||
const newSeriesData = await this.put<SonarrSeries>(
|
||||
@@ -366,14 +368,18 @@ class SonarrAPI extends ServarrBase<{
|
||||
if (tvdbId) {
|
||||
this.removeCache('/series/lookup', {
|
||||
term: `tvdb:${tvdbId}`,
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
}
|
||||
if (externalId) {
|
||||
this.removeCache(`/series/${externalId}`);
|
||||
this.removeCache(`/series/${externalId}`, {
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
}
|
||||
if (title) {
|
||||
this.removeCache('/series/lookup', {
|
||||
term: title,
|
||||
headers: this.defaultHeaders,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,6 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
include_video_language: language + ', en',
|
||||
},
|
||||
43200
|
||||
);
|
||||
@@ -280,6 +281,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
language,
|
||||
append_to_response:
|
||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||
include_video_language: language + ', en',
|
||||
},
|
||||
43200
|
||||
);
|
||||
|
||||
@@ -7,5 +7,6 @@ export enum ApiErrorCode {
|
||||
NoAdminUser = 'NO_ADMIN_USER',
|
||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
Unknown = 'UNKNOWN',
|
||||
}
|
||||
|
||||
@@ -734,8 +734,11 @@ export class MediaRequest {
|
||||
media.mediaType === MediaType.MOVIE &&
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
await mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,8 +755,11 @@ export class MediaRequest {
|
||||
).length === 0 &&
|
||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||
) {
|
||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||
mediaRepository.save(media);
|
||||
const statusField = this.is4k ? 'status4k' : 'status';
|
||||
mediaRepository.update(
|
||||
{ id: this.media.id },
|
||||
{ [statusField]: MediaStatus.UNKNOWN }
|
||||
);
|
||||
}
|
||||
|
||||
// Approve child seasons if parent is approved
|
||||
@@ -955,8 +961,10 @@ export class MediaRequest {
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -986,18 +994,22 @@ export class MediaRequest {
|
||||
throw new Error('Media data not found');
|
||||
}
|
||||
|
||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
radarrMovie.titleSlug;
|
||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
|
||||
await mediaRepository.save(media);
|
||||
const updateFields = {
|
||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||
radarrMovie.id,
|
||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||
radarrMovie.titleSlug,
|
||||
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id,
|
||||
};
|
||||
|
||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||
})
|
||||
.catch(async () => {
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
this.status = MediaRequestStatus.FAILED;
|
||||
await requestRepository.save(this);
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.FAILED,
|
||||
});
|
||||
|
||||
logger.warn(
|
||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||
@@ -1113,8 +1125,9 @@ export class MediaRequest {
|
||||
});
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
this.status = MediaRequestStatus.APPROVED;
|
||||
await requestRepository.save(this);
|
||||
await requestRepository.update(this.id, {
|
||||
status: MediaRequestStatus.APPROVED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,11 @@ export class User {
|
||||
})
|
||||
public email: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexUsername?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public plexUsername?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinUsername?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinUsername?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public username?: string;
|
||||
@@ -77,20 +77,20 @@ export class User {
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
public userType: UserType;
|
||||
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
@Column({ type: 'integer', nullable: true, select: true })
|
||||
public plexId?: number | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinUserId?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinUserId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinDeviceId?: string;
|
||||
@Column({ type: 'varchar', nullable: true, select: false })
|
||||
public jellyfinDeviceId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinAuthToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true, select: false })
|
||||
public jellyfinAuthToken?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true, select: false })
|
||||
public plexToken?: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
public permissions = 0;
|
||||
@@ -98,6 +98,12 @@ export class User {
|
||||
@Column()
|
||||
public avatar: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public avatarETag?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public avatarVersion?: string | null;
|
||||
|
||||
@RelationCount((user: User) => user.requests)
|
||||
public requestCount: number;
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@@ -18,9 +24,15 @@ export class UserPushSubscription {
|
||||
@Column()
|
||||
public p256dh: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
@Column()
|
||||
public auth: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public userAgent: string;
|
||||
|
||||
@CreateDateColumn({ nullable: true })
|
||||
public createdAt: Date;
|
||||
|
||||
constructor(init?: Partial<UserPushSubscription>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import csurf from '@dr.pogodin/csurf';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
@@ -28,7 +29,6 @@ import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csurf from 'csurf';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
@@ -41,9 +41,9 @@ import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import YAML from 'yamljs';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
|
||||
|
||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const app = next({ dev });
|
||||
const handle = app.getRequestHandler();
|
||||
@@ -72,23 +72,20 @@ app
|
||||
|
||||
// Load Settings
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
restartFlag.initializeSettings(settings);
|
||||
|
||||
// Check if we force IPv4 first
|
||||
if (process.env.forceIpv4First === 'true' || settings.main.forceIpv4First) {
|
||||
if (
|
||||
process.env.forceIpv4First === 'true' ||
|
||||
settings.network.forceIpv4First
|
||||
) {
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
net.setDefaultAutoSelectFamily(false);
|
||||
}
|
||||
|
||||
if (settings.main.dnsServers.trim() !== '') {
|
||||
dns.setServers(
|
||||
settings.main.dnsServers.split(',').map((server) => server.trim())
|
||||
);
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.main.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.main.proxy);
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
@@ -143,7 +140,7 @@ app
|
||||
await DiscoverSlider.bootstrapSliders();
|
||||
|
||||
const server = express();
|
||||
if (settings.main.trustProxy) {
|
||||
if (settings.network.trustProxy) {
|
||||
server.enable('trust proxy');
|
||||
}
|
||||
server.use(cookieParser());
|
||||
@@ -164,7 +161,7 @@ app
|
||||
next();
|
||||
}
|
||||
});
|
||||
if (settings.main.csrfProtection) {
|
||||
if (settings.network.csrfProtection) {
|
||||
server.use(
|
||||
csurf({
|
||||
cookie: {
|
||||
@@ -194,7 +191,7 @@ app
|
||||
cookie: {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
||||
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
|
||||
secure: 'auto',
|
||||
},
|
||||
store: new TypeormStore({
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||
|
||||
export interface RequestResultsResponse extends PaginatedResponse {
|
||||
results: NonFunctionProperties<MediaRequest>[];
|
||||
results: (NonFunctionProperties<MediaRequest> & {
|
||||
profileName?: string;
|
||||
canRemove?: boolean;
|
||||
})[];
|
||||
}
|
||||
|
||||
export type MediaRequestBody = {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface PublicSettingsResponse {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
discoverRegion: string;
|
||||
|
||||
@@ -70,6 +70,35 @@ export const startJobs = (): void => {
|
||||
running: () => plexFullScanner.status().running,
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
scheduledJobs.push({
|
||||
id: 'plex-refresh-token',
|
||||
name: 'Plex Refresh Token',
|
||||
type: 'process',
|
||||
interval: 'fixed',
|
||||
cronSchedule: jobs['plex-refresh-token'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Refresh Token', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
refreshToken.run();
|
||||
}),
|
||||
});
|
||||
|
||||
// Watchlist Sync
|
||||
scheduledJobs.push({
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'seconds',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
@@ -112,21 +141,6 @@ export const startJobs = (): void => {
|
||||
});
|
||||
}
|
||||
|
||||
// Watchlist Sync
|
||||
scheduledJobs.push({
|
||||
id: 'plex-watchlist-sync',
|
||||
name: 'Plex Watchlist Sync',
|
||||
type: 'process',
|
||||
interval: 'seconds',
|
||||
cronSchedule: jobs['plex-watchlist-sync'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Watchlist Sync', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
watchlistSync.syncWatchlist();
|
||||
}),
|
||||
});
|
||||
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-scan',
|
||||
@@ -223,19 +237,5 @@ export const startJobs = (): void => {
|
||||
}),
|
||||
});
|
||||
|
||||
scheduledJobs.push({
|
||||
id: 'plex-refresh-token',
|
||||
name: 'Plex Refresh Token',
|
||||
type: 'process',
|
||||
interval: 'fixed',
|
||||
cronSchedule: jobs['plex-refresh-token'].schedule,
|
||||
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Plex Refresh Token', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
refreshToken.run();
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
};
|
||||
|
||||
@@ -404,6 +404,34 @@ class AvailabilitySync {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists &&
|
||||
(media.status === MediaStatus.AVAILABLE ||
|
||||
media.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||
))
|
||||
) {
|
||||
await this.mediaUpdater(media, false, mediaServerType);
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists4k &&
|
||||
(media.status4k === MediaStatus.AVAILABLE ||
|
||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||
))
|
||||
) {
|
||||
await this.mediaUpdater(media, true, mediaServerType);
|
||||
}
|
||||
|
||||
// TODO: Figure out how to run seasonUpdater for each season
|
||||
|
||||
if ([...finalSeasons.values()].includes(false)) {
|
||||
@@ -423,22 +451,6 @@ class AvailabilitySync {
|
||||
mediaServerType
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists &&
|
||||
(media.status === MediaStatus.AVAILABLE ||
|
||||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
await this.mediaUpdater(media, false, mediaServerType);
|
||||
}
|
||||
|
||||
if (
|
||||
!showExists4k &&
|
||||
(media.status4k === MediaStatus.AVAILABLE ||
|
||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
||||
) {
|
||||
await this.mediaUpdater(media, true, mediaServerType);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
@@ -466,6 +478,10 @@ class AvailabilitySync {
|
||||
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||
{ status4k: MediaStatus.AVAILABLE },
|
||||
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||
{ seasons: { status: MediaStatus.AVAILABLE } },
|
||||
{ seasons: { status: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||
{ seasons: { status4k: MediaStatus.AVAILABLE } },
|
||||
{ seasons: { status4k: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||
];
|
||||
|
||||
let mediaPage: Media[];
|
||||
@@ -731,7 +747,11 @@ class AvailabilitySync {
|
||||
}
|
||||
|
||||
if (radarr && radarr.hasFile) {
|
||||
existsInRadarr = true;
|
||||
const resolution =
|
||||
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||
const is4kMovie =
|
||||
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class PreparedEmail extends Email {
|
||||
},
|
||||
send: true,
|
||||
transport: transport,
|
||||
preview: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,14 +193,34 @@ class ImageProxy {
|
||||
public async clearCachedImage(path: string) {
|
||||
// find cacheKey
|
||||
const cacheKey = this.getCacheKey(path);
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
|
||||
try {
|
||||
await promises.access(directory);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
logger.debug(
|
||||
`Cache directory '${cacheKey}' does not exist; nothing to clear.`,
|
||||
{
|
||||
label: 'Image Cache',
|
||||
}
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
logger.error('Error checking cache directory existence', {
|
||||
label: 'Image Cache',
|
||||
message: e.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const files = await promises.readdir(directory);
|
||||
|
||||
await promises.rm(directory, { recursive: true });
|
||||
|
||||
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
|
||||
logger.debug(`Cleared ${files[0]} from cache 'avatar'`, {
|
||||
label: 'Image Cache',
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface NotificationPayload {
|
||||
request?: MediaRequest;
|
||||
issue?: Issue;
|
||||
comment?: IssueComment;
|
||||
pendingRequestsCount?: number;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
|
||||
@@ -188,7 +188,7 @@ class SlackAgent
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
action_id: 'open-in-overseerr',
|
||||
action_id: 'open-in-jellyseerr',
|
||||
type: 'button',
|
||||
url,
|
||||
text: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
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';
|
||||
@@ -19,6 +20,8 @@ interface PushNotificationPayload {
|
||||
actionUrl?: string;
|
||||
actionUrlTitle?: string;
|
||||
requestId?: number;
|
||||
pendingRequestsCount?: number;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
@@ -129,6 +132,8 @@ class WebPushAgent
|
||||
requestId: payload.request?.id,
|
||||
actionUrl,
|
||||
actionUrlTitle,
|
||||
pendingRequestsCount: payload.pendingRequestsCount,
|
||||
isAdmin: payload.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +157,51 @@ class WebPushAgent
|
||||
|
||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||
|
||||
const requestRepository = getRepository(MediaRequest);
|
||||
|
||||
const pendingRequests = await requestRepository.find({
|
||||
where: { status: MediaRequestStatus.PENDING },
|
||||
});
|
||||
|
||||
const webPushNotification = async (
|
||||
pushSub: UserPushSubscription,
|
||||
notificationPayload: Buffer
|
||||
) => {
|
||||
logger.debug('Sending web push notification', {
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: pushSub.endpoint,
|
||||
keys: {
|
||||
auth: pushSub.auth,
|
||||
p256dh: pushSub.p256dh,
|
||||
},
|
||||
},
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error sending web push notification; removing subscription',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
payload.notifyUser &&
|
||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||
@@ -169,7 +219,11 @@ class WebPushAgent
|
||||
pushSubs.push(...notifySubs);
|
||||
}
|
||||
|
||||
if (payload.notifyAdmin) {
|
||||
if (
|
||||
payload.notifyAdmin ||
|
||||
type === Notification.MEDIA_APPROVED ||
|
||||
type === Notification.MEDIA_DECLINED
|
||||
) {
|
||||
const users = await userRepository.find();
|
||||
|
||||
const manageUsers = users.filter(
|
||||
@@ -192,7 +246,42 @@ class WebPushAgent
|
||||
})
|
||||
.getMany();
|
||||
|
||||
pushSubs.push(...allSubs);
|
||||
// We only want to send the custom notification when type is approved or declined
|
||||
// Otherwise, default to the normal notification
|
||||
if (
|
||||
type === Notification.MEDIA_APPROVED ||
|
||||
type === Notification.MEDIA_DECLINED
|
||||
) {
|
||||
if (mainUser && allSubs.length > 0) {
|
||||
webpush.setVapidDetails(
|
||||
`mailto:${mainUser.email}`,
|
||||
settings.vapidPublic,
|
||||
settings.vapidPrivate
|
||||
);
|
||||
|
||||
// Custom payload only for updating the app badge
|
||||
const notificationBadgePayload = Buffer.from(
|
||||
JSON.stringify(
|
||||
this.getNotificationPayload(type, {
|
||||
subject: payload.subject,
|
||||
notifySystem: false,
|
||||
notifyAdmin: true,
|
||||
isAdmin: true,
|
||||
pendingRequestsCount: pendingRequests.length,
|
||||
})
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
allSubs.map(async (sub) => {
|
||||
webPushNotification(sub, notificationBadgePayload);
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pushSubs.push(...allSubs);
|
||||
}
|
||||
}
|
||||
|
||||
if (mainUser && pushSubs.length > 0) {
|
||||
@@ -202,6 +291,10 @@ class WebPushAgent
|
||||
settings.vapidPrivate
|
||||
);
|
||||
|
||||
if (type === Notification.MEDIA_PENDING) {
|
||||
payload = { ...payload, pendingRequestsCount: pendingRequests.length };
|
||||
}
|
||||
|
||||
const notificationPayload = Buffer.from(
|
||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||
'utf-8'
|
||||
@@ -209,39 +302,7 @@ class WebPushAgent
|
||||
|
||||
await Promise.all(
|
||||
pushSubs.map(async (sub) => {
|
||||
logger.debug('Sending web push notification', {
|
||||
label: 'Notifications',
|
||||
recipient: sub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
auth: sub.auth,
|
||||
p256dh: sub.p256dh,
|
||||
},
|
||||
},
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Error sending web push notification; removing subscription',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: sub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(sub);
|
||||
}
|
||||
webPushNotification(sub, notificationPayload);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ class JellyfinScanner {
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: jellyfinitem.Id,
|
||||
label: 'Jellyfin Sync',
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -204,8 +204,8 @@ class JellyfinScanner {
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: jellyfinitem.Id,
|
||||
label: 'Jellyfin Sync',
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@ export interface MainSettings {
|
||||
apiKey: string;
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
csrfProtection: boolean;
|
||||
cacheImages: boolean;
|
||||
defaultPermissions: number;
|
||||
defaultQuotas: {
|
||||
@@ -124,17 +123,21 @@ export interface MainSettings {
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
newPlexLogin: boolean;
|
||||
discoverRegion: string;
|
||||
streamingRegion: string;
|
||||
originalLanguage: string;
|
||||
trustProxy: boolean;
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
enableSpecialEpisodes: boolean;
|
||||
forceIpv4First: boolean;
|
||||
dnsServers: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
forceIpv4First: boolean;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
}
|
||||
|
||||
@@ -147,6 +150,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
discoverRegion: string;
|
||||
@@ -313,6 +317,7 @@ export interface AllSettings {
|
||||
public: PublicSettings;
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
network: NetworkSettings;
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -331,7 +336,6 @@ class Settings {
|
||||
apiKey: '',
|
||||
applicationTitle: 'Jellyseerr',
|
||||
applicationUrl: '',
|
||||
csrfProtection: false,
|
||||
cacheImages: false,
|
||||
defaultPermissions: Permission.REQUEST,
|
||||
defaultQuotas: {
|
||||
@@ -340,27 +344,15 @@ class Settings {
|
||||
},
|
||||
hideAvailable: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
newPlexLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
trustProxy: false,
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
enableSpecialEpisodes: false,
|
||||
forceIpv4First: false,
|
||||
dnsServers: '',
|
||||
locale: 'en',
|
||||
proxy: {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
},
|
||||
plex: {
|
||||
name: '',
|
||||
@@ -513,6 +505,21 @@ class Settings {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
},
|
||||
network: {
|
||||
csrfProtection: false,
|
||||
trustProxy: false,
|
||||
forceIpv4First: false,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
this.data = merge(this.data, initialSettings);
|
||||
@@ -582,6 +589,8 @@ class Settings {
|
||||
applicationUrl: this.data.main.applicationUrl,
|
||||
hideAvailable: this.data.main.hideAvailable,
|
||||
localLogin: this.data.main.localLogin,
|
||||
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||
movie4kEnabled: this.data.radarr.some(
|
||||
(radarr) => radarr.is4k && radarr.isDefault
|
||||
@@ -622,6 +631,14 @@ class Settings {
|
||||
this.data.jobs = data;
|
||||
}
|
||||
|
||||
get network(): NetworkSettings {
|
||||
return this.data.network;
|
||||
}
|
||||
|
||||
set network(data: NetworkSettings) {
|
||||
this.data.network = data;
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
return this.data.clientId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const migrateNetworkSettings = (settings: any): AllSettings => {
|
||||
if (settings.network) {
|
||||
return settings;
|
||||
}
|
||||
const newSettings = { ...settings };
|
||||
newSettings.network = {
|
||||
...settings.network,
|
||||
csrfProtection: settings.main.csrfProtection ?? false,
|
||||
trustProxy: settings.main.trustProxy ?? false,
|
||||
forceIpv4First: settings.main.forceIpv4First ?? false,
|
||||
proxy: settings.main.proxy ?? {
|
||||
enabled: false,
|
||||
hostname: '',
|
||||
port: 8080,
|
||||
useSsl: false,
|
||||
user: '',
|
||||
password: '',
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
};
|
||||
delete settings.main.csrfProtection;
|
||||
delete settings.main.trustProxy;
|
||||
delete settings.main.forceIpv4First;
|
||||
delete settings.main.proxy;
|
||||
return newSettings;
|
||||
};
|
||||
|
||||
export default migrateNetworkSettings;
|
||||
@@ -130,7 +130,7 @@ class WatchlistSync {
|
||||
|
||||
switch (e.constructor) {
|
||||
// During watchlist sync, these errors aren't necessarily
|
||||
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
||||
// a problem with Jellyseerr. Since we are auto syncing these constantly, it's
|
||||
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||
// instead log these as debug messages.
|
||||
case RequestPermissionError:
|
||||
|
||||
@@ -43,14 +43,14 @@ const logger = winston.createLogger({
|
||||
}),
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: process.env.CONFIG_DIRECTORY
|
||||
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
|
||||
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
||||
? `${process.env.CONFIG_DIRECTORY}/logs/jellyseerr-%DATE%.log`
|
||||
: path.join(__dirname, '../config/logs/jellyseerr-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '7d',
|
||||
createSymlink: true,
|
||||
symlinkName: 'overseerr.log',
|
||||
symlinkName: 'jellyseerr.log',
|
||||
}),
|
||||
new winston.transports.DailyRotateFile({
|
||||
filename: process.env.CONFIG_DIRECTORY
|
||||
|
||||
29
server/migration/postgres/1743023615532-UpdateWebPush.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateWebPush1743023615532 implements MigrationInterface {
|
||||
name = 'UpdateWebPush1743023615532';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD "userAgent" character varying`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107707465
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserAvatarCacheFields1743107707465';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ADD "avatarETag" character varying`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ADD "avatarVersion" character varying`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`);
|
||||
}
|
||||
}
|
||||
203
server/migration/sqlite/1743023610704-UpdateWebPush.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateWebPush1743023610704 implements MigrationInterface {
|
||||
name = 'UpdateWebPush1743023610704';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserAvatarCacheFields1743107645301
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserAvatarCacheFields1743107645301';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { checkAvatarChanged } from '@server/routes/avatarproxy';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
@@ -56,8 +57,9 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType != MediaServerType.PLEX &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
(settings.main.mediaServerLogin === false ||
|
||||
settings.main.mediaServerType != MediaServerType.PLEX)
|
||||
) {
|
||||
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||
}
|
||||
@@ -157,7 +159,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user',
|
||||
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
@@ -215,6 +217,10 @@ authRoutes.post('/plex', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
function getUserAvatarUrl(user: User): string {
|
||||
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
|
||||
}
|
||||
|
||||
authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
@@ -231,10 +237,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
|
||||
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
// media server not configured, allow login for setup
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
settings.jellyfin.ip !== ''
|
||||
(settings.main.mediaServerLogin === false ||
|
||||
// media server is neither jellyfin or emby
|
||||
(settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
settings.jellyfin.ip !== ''))
|
||||
) {
|
||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||
}
|
||||
@@ -263,13 +272,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
// Try to find deviceId that corresponds to jellyfin user, else generate a new one
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUsername: body.username },
|
||||
select: { id: true, jellyfinDeviceId: true },
|
||||
});
|
||||
|
||||
let deviceId = '';
|
||||
if (user) {
|
||||
deviceId = user.jellyfinDeviceId ?? '';
|
||||
} else {
|
||||
deviceId = Buffer.from(`BOT_overseerr_${body.username ?? ''}`).toString(
|
||||
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
|
||||
'base64'
|
||||
);
|
||||
}
|
||||
@@ -338,12 +348,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: `/avatarproxy/${account.User.Id}`,
|
||||
userType:
|
||||
body.serverType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
|
||||
await userRepository.save(user);
|
||||
} else {
|
||||
@@ -370,7 +380,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.permissions = Permission.ADMIN;
|
||||
user.avatar = `/avatarproxy/${account.User.Id}`;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
user.userType =
|
||||
body.serverType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
@@ -417,7 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
user.avatar = `/avatarproxy/${account.User.Id}`;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
|
||||
if (user.username === account.User.Name) {
|
||||
@@ -441,7 +451,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
});
|
||||
} else if (!user) {
|
||||
logger.info(
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
|
||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
@@ -455,12 +465,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: `/avatarproxy/${account.User.Id}`,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
|
||||
//initialize Jellyfin/Emby users with local login
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
@@ -470,6 +480,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
if (user && user.jellyfinUserId) {
|
||||
try {
|
||||
const { changed } = await checkAvatarChanged(user);
|
||||
|
||||
if (changed) {
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
logger.debug('Avatar updated during login', {
|
||||
userId: user.id,
|
||||
jellyfinUserId: user.jellyfinUserId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling avatar during login', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (req.session) {
|
||||
req.session.userId = user?.id;
|
||||
@@ -579,7 +609,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
.getOne();
|
||||
|
||||
if (!user || !(await user.passwordMatch(body.password))) {
|
||||
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
|
||||
logger.warn('Failed sign-in attempt using invalid Jellyseerr password', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
@@ -669,7 +699,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong authenticating with Overseerr password',
|
||||
'Something went wrong authenticating with Jellyseerr password',
|
||||
{
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
|
||||
@@ -8,10 +8,12 @@ import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
let _avatarImageProxy: ImageProxy | null = null;
|
||||
|
||||
async function initAvatarImageProxy() {
|
||||
if (!_avatarImageProxy) {
|
||||
const userRepository = getRepository(User);
|
||||
@@ -31,6 +33,79 @@ async function initAvatarImageProxy() {
|
||||
return _avatarImageProxy;
|
||||
}
|
||||
|
||||
function getJellyfinAvatarUrl(userId: string) {
|
||||
const settings = getSettings();
|
||||
return settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? `${getHostname()}/UserImage?UserId=${userId}`
|
||||
: `${getHostname()}/Users/${userId}/Images/Primary?quality=90`;
|
||||
}
|
||||
|
||||
function computeImageHash(buffer: Buffer): string {
|
||||
return createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
export async function checkAvatarChanged(
|
||||
user: User
|
||||
): Promise<{ changed: boolean; etag?: string }> {
|
||||
try {
|
||||
if (!user || !user.jellyfinUserId) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId);
|
||||
|
||||
const headResponse = await fetch(jellyfinAvatarUrl, { method: 'HEAD' });
|
||||
if (!headResponse.ok) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
let remoteVersion: string;
|
||||
if (settings.main.mediaServerType === MediaServerType.JELLYFIN) {
|
||||
const remoteLastModifiedStr =
|
||||
headResponse.headers.get('last-modified') || '';
|
||||
remoteVersion = (
|
||||
Date.parse(remoteLastModifiedStr) || Date.now()
|
||||
).toString();
|
||||
} else if (settings.main.mediaServerType === MediaServerType.EMBY) {
|
||||
remoteVersion =
|
||||
headResponse.headers.get('etag')?.replace(/"/g, '') ||
|
||||
Date.now().toString();
|
||||
} else {
|
||||
remoteVersion = Date.now().toString();
|
||||
}
|
||||
|
||||
if (user.avatarVersion && user.avatarVersion === remoteVersion) {
|
||||
return { changed: false, etag: user.avatarETag ?? undefined };
|
||||
}
|
||||
|
||||
const avatarImageCache = await initAvatarImageProxy();
|
||||
await avatarImageCache.clearCachedImage(jellyfinAvatarUrl);
|
||||
const imageData = await avatarImageCache.getImage(
|
||||
jellyfinAvatarUrl,
|
||||
gravatarUrl(user.email || 'none', { default: 'mm', size: 200 })
|
||||
);
|
||||
|
||||
const newHash = computeImageHash(imageData.imageBuffer);
|
||||
|
||||
const hasChanged = user.avatarETag !== newHash;
|
||||
|
||||
user.avatarVersion = remoteVersion;
|
||||
if (hasChanged) {
|
||||
user.avatarETag = newHash;
|
||||
}
|
||||
|
||||
await getRepository(User).save(user);
|
||||
|
||||
return { changed: hasChanged, etag: newHash };
|
||||
} catch (error) {
|
||||
logger.error('Error checking avatar changes', {
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return { changed: false };
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:jellyfinUserId', async (req, res) => {
|
||||
try {
|
||||
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
||||
@@ -46,6 +121,10 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
||||
|
||||
const avatarImageCache = await initAvatarImageProxy();
|
||||
|
||||
const userEtag = req.headers['if-none-match'];
|
||||
|
||||
const versionParam = req.query.v;
|
||||
|
||||
const user = await getRepository(User).findOne({
|
||||
where: { jellyfinUserId: req.params.jellyfinUserId },
|
||||
});
|
||||
@@ -55,13 +134,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
||||
size: 200,
|
||||
});
|
||||
|
||||
const setttings = getSettings();
|
||||
const jellyfinAvatarUrl =
|
||||
setttings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}`
|
||||
: `${getHostname()}/Users/${
|
||||
req.params.jellyfinUserId
|
||||
}/Images/Primary?quality=90`;
|
||||
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
|
||||
|
||||
let imageData = await avatarImageCache.getImage(
|
||||
jellyfinAvatarUrl,
|
||||
@@ -73,10 +146,15 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
||||
imageData = await avatarImageCache.getImage(fallbackUrl);
|
||||
}
|
||||
|
||||
if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': `image/${imageData.meta.extension}`,
|
||||
'Content-Length': imageData.imageBuffer.length,
|
||||
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||
ETag: `"${imageData.meta.etag}"`,
|
||||
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||
});
|
||||
|
||||
@@ -837,7 +837,8 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
select: ['id', 'plexToken'],
|
||||
});
|
||||
|
||||
if (activeUser) {
|
||||
if (activeUser && !activeUser?.plexToken) {
|
||||
// Non-Plex users can only see their own watchlist
|
||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||
where: { requestedBy: { id: activeUser?.id } },
|
||||
relations: {
|
||||
@@ -866,6 +867,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
||||
});
|
||||
}
|
||||
|
||||
// List watchlist from Plex
|
||||
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
||||
|
||||
const watchlist = await plexTV.getWatchlist({ offset });
|
||||
|
||||
@@ -55,7 +55,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
||||
let commitsBehind = 0;
|
||||
|
||||
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
||||
const commits = await githubApi.getOverseerrCommits();
|
||||
const commits = await githubApi.getJellyseerrCommits();
|
||||
|
||||
if (commits.length) {
|
||||
const filteredCommits = commits.filter(
|
||||
@@ -74,7 +74,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
||||
}
|
||||
}
|
||||
} else if (commitTag !== 'local') {
|
||||
const releases = await githubApi.getOverseerrReleases();
|
||||
const releases = await githubApi.getJellyseerrReleases();
|
||||
|
||||
if (releases.length) {
|
||||
const latestVersion = releases[0];
|
||||
@@ -403,7 +403,7 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Overseerr API',
|
||||
api: 'Jellyseerr API',
|
||||
version: '1.0',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,7 +189,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
);
|
||||
|
||||
// add profile names to the media requests, with undefined if not found
|
||||
const requestsWithProfileNames = requests.map((r) => {
|
||||
let mappedRequests = requests.map((r) => {
|
||||
switch (r.type) {
|
||||
case MediaType.MOVIE: {
|
||||
const profileName = radarrServers
|
||||
@@ -212,6 +212,36 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
}
|
||||
});
|
||||
|
||||
// add canRemove prop if user has permission
|
||||
if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) {
|
||||
mappedRequests = mappedRequests.map((r) => {
|
||||
switch (r.type) {
|
||||
case MediaType.MOVIE: {
|
||||
return {
|
||||
...r,
|
||||
// check if the radarr server for this request is configured
|
||||
canRemove: radarrServers.some(
|
||||
(server) =>
|
||||
server.id ===
|
||||
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||
),
|
||||
};
|
||||
}
|
||||
case MediaType.TV: {
|
||||
return {
|
||||
...r,
|
||||
// check if the sonarr server for this request is configured
|
||||
canRemove: sonarrServers.some(
|
||||
(server) =>
|
||||
server.id ===
|
||||
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
@@ -219,7 +249,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requestsWithProfileNames,
|
||||
results: mappedRequests,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
|
||||
@@ -78,6 +78,21 @@ settingsRoutes.post('/main', async (req, res) => {
|
||||
return res.status(200).json(settings.main);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/network', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.network);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/network', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.network = merge(settings.network, req.body);
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.network);
|
||||
});
|
||||
|
||||
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -337,7 +352,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
const account = await jellyfinClient.getUser();
|
||||
|
||||
// Automatic Library grouping is not supported when user views are used to get library
|
||||
if (account.Configuration.GroupedFolders.length > 0) {
|
||||
if (account.Configuration.GroupedFolders?.length > 0) {
|
||||
return next({
|
||||
status: 501,
|
||||
message: ApiErrorCode.SyncErrorGroupedFolders,
|
||||
|
||||
@@ -32,7 +32,14 @@ const router = Router();
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const includeIds = [
|
||||
...new Set(
|
||||
req.query.includeIds ? req.query.includeIds.toString().split(',') : []
|
||||
),
|
||||
];
|
||||
const pageSize = req.query.take
|
||||
? Number(req.query.take)
|
||||
: Math.max(10, includeIds.length);
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||
let query = getRepository(User).createQueryBuilder('user');
|
||||
@@ -44,27 +51,33 @@ router.get('/', async (req, res, next) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (includeIds.length > 0) {
|
||||
query.andWhereInIds(includeIds);
|
||||
}
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'updated':
|
||||
query = query.orderBy('user.updatedAt', 'DESC');
|
||||
break;
|
||||
case 'displayname':
|
||||
query = query.orderBy(
|
||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||
"user"."email"
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
'ASC'
|
||||
);
|
||||
query = query
|
||||
.addSelect(
|
||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||
"user"."email"
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
'displayname_sort_key'
|
||||
)
|
||||
.orderBy('displayname_sort_key', 'ASC');
|
||||
break;
|
||||
case 'requests':
|
||||
query = query
|
||||
@@ -84,6 +97,7 @@ router.get('/', async (req, res, next) => {
|
||||
const [users, userCount] = await query
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.distinct(true)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -170,13 +184,15 @@ router.post<
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
userAgent: string;
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
where: { auth: req.body.auth },
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
|
||||
if (existingSubs.length > 0) {
|
||||
@@ -191,6 +207,7 @@ router.post<
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
@@ -205,6 +222,79 @@ router.post<
|
||||
}
|
||||
});
|
||||
|
||||
router.get<{ userId: number }>(
|
||||
'/:userId/pushSubscriptions',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
const userPushSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { user: { id: req.params.userId } },
|
||||
});
|
||||
|
||||
return res.status(200).json(userPushSubs);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'User subscriptions not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ userId: number; key: string }>(
|
||||
'/:userId/pushSubscription/:key',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
user: { id: req.params.userId },
|
||||
p256dh: req.params.key,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json(userPushSub);
|
||||
} catch (e) {
|
||||
next({ status: 404, message: 'User subscription not found.' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete<{ userId: number; key: string }>(
|
||||
'/:userId/pushSubscription/:key',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
user: { id: req.params.userId },
|
||||
p256dh: req.params.key,
|
||||
},
|
||||
});
|
||||
|
||||
await userPushSubRepository.remove(userPushSub);
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong deleting the user push subcription', {
|
||||
label: 'API',
|
||||
key: req.params.key,
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User push subcription not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -12,9 +15,23 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import net from 'net';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
const isOwnProfile = (): Middleware => {
|
||||
return (req, res, next) => {
|
||||
if (req.user?.id !== Number(req.params.id)) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's settings.",
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
const isOwnProfileOrAdmin = (): Middleware => {
|
||||
const authMiddleware: Middleware = (req, res, next) => {
|
||||
if (
|
||||
@@ -102,28 +119,10 @@ userSettingsRoutes.post<
|
||||
}
|
||||
|
||||
const oldEmail = user.email;
|
||||
const oldUsername = user.username;
|
||||
user.username = req.body.username;
|
||||
if (user.jellyfinUsername) {
|
||||
if (user.userType !== UserType.PLEX) {
|
||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||
}
|
||||
// Edge case for local users, because they have no Jellyfin username to fall back on
|
||||
// if the email is not provided
|
||||
if (user.userType === UserType.LOCAL) {
|
||||
if (req.body.email) {
|
||||
user.email = req.body.email;
|
||||
if (
|
||||
!user.username &&
|
||||
user.email !== oldEmail &&
|
||||
!oldEmail.includes('@')
|
||||
) {
|
||||
user.username = oldEmail;
|
||||
}
|
||||
} else if (req.body.username) {
|
||||
user.email = oldUsername || user.email;
|
||||
user.username = req.body.username;
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await userRepository.findOne({
|
||||
where: { email: user.email },
|
||||
@@ -183,9 +182,8 @@ userSettingsRoutes.post<
|
||||
status: e.statusCode,
|
||||
message: e.errorCode,
|
||||
});
|
||||
} else {
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -290,6 +288,260 @@ userSettingsRoutes.post<
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRoutes.post<{ authToken: string }>(
|
||||
'/linked-accounts/plex',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(404).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
// Make sure Plex login is enabled
|
||||
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||
}
|
||||
|
||||
// First we need to use this auth token to get the user's email from plex.tv
|
||||
const plextv = new PlexTvAPI(req.body.authToken);
|
||||
const account = await plextv.getUser();
|
||||
|
||||
// Do not allow linking of an already linked account
|
||||
if (await userRepository.exist({ where: { plexId: account.id } })) {
|
||||
return res.status(422).json({
|
||||
message: 'This Plex account is already linked to a Jellyseerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
// Emails do not match
|
||||
if (user.email !== account.email) {
|
||||
return res.status(422).json({
|
||||
message:
|
||||
'This Plex account is registered under a different email address.',
|
||||
});
|
||||
}
|
||||
|
||||
// valid plex user found, link to current user
|
||||
user.userType = UserType.PLEX;
|
||||
user.plexId = account.id;
|
||||
user.plexUsername = account.username;
|
||||
user.plexToken = account.authToken;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.delete<{ id: string }>(
|
||||
'/linked-accounts/plex',
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
// Make sure Plex login is enabled
|
||||
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.password')
|
||||
.where({
|
||||
id: Number(req.params.id),
|
||||
})
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
return res.status(400).json({
|
||||
message:
|
||||
'Cannot unlink media server accounts for the primary administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.email || !user.password) {
|
||||
return res.status(400).json({
|
||||
message: 'User does not have a local email or password set.',
|
||||
});
|
||||
}
|
||||
|
||||
user.userType = UserType.LOCAL;
|
||||
user.plexId = null;
|
||||
user.plexUsername = null;
|
||||
user.plexToken = null;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ username: string; password: string }>(
|
||||
'/linked-accounts/jellyfin',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
// Make sure jellyfin login is enabled
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
// Do not allow linking of an already linked account
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUsername: req.body.username },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message: 'The specified account is already linked to a Jellyseerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_jellyseerr_${req.user.username ?? ''}`
|
||||
).toString('base64');
|
||||
|
||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||
|
||||
const ip = req.ip;
|
||||
let clientIp: string | undefined;
|
||||
if (ip) {
|
||||
if (net.isIPv4(ip)) {
|
||||
clientIp = ip;
|
||||
} else if (net.isIPv6(ip)) {
|
||||
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await jellyfinserver.login(
|
||||
req.body.username,
|
||||
req.body.password,
|
||||
clientIp
|
||||
);
|
||||
|
||||
// Do not allow linking of an already linked account
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message:
|
||||
'The specified account is already linked to a Jellyseerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
// valid jellyfin user found, link to current user
|
||||
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 to user.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
if (
|
||||
e instanceof ApiError &&
|
||||
e.errorCode === ApiErrorCode.InvalidCredentials
|
||||
) {
|
||||
return res.status(401).json({ code: e.errorCode });
|
||||
}
|
||||
|
||||
return res.status(500).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.delete<{ id: string }>(
|
||||
'/linked-accounts/jellyfin',
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
// Make sure jellyfin login is enabled
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.password')
|
||||
.where({
|
||||
id: Number(req.params.id),
|
||||
})
|
||||
.getOne();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
return res.status(400).json({
|
||||
message:
|
||||
'Cannot unlink media server accounts for the primary administrator.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.email || !user.password) {
|
||||
return res.status(400).json({
|
||||
message: 'User does not have a local email or password set.',
|
||||
});
|
||||
}
|
||||
|
||||
user.userType = UserType.LOCAL;
|
||||
user.jellyfinUserId = null;
|
||||
user.jellyfinUsername = null;
|
||||
user.jellyfinAuthToken = null;
|
||||
user.jellyfinDeviceId = null;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
return res.status(500).json({ message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
4
server/types/custom.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '@dr.pogodin/csurf' {
|
||||
import csrf = require('csurf');
|
||||
export = csrf;
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
export default async function createCustomProxyAgent(
|
||||
proxySettings: ProxySettings
|
||||
) {
|
||||
const defaultAgent = new Agent();
|
||||
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||
|
||||
const skipUrl = (url: string) => {
|
||||
const hostname = new URL(url).hostname;
|
||||
const skipUrl = (url: string | URL) => {
|
||||
const hostname =
|
||||
typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||
|
||||
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
||||
return true;
|
||||
@@ -38,8 +39,7 @@ export default async function createCustomProxyAgent(
|
||||
dispatch: Dispatcher['dispatch']
|
||||
): Dispatcher['dispatch'] => {
|
||||
return (opts, handler) => {
|
||||
const url = opts.origin?.toString();
|
||||
return url && skipUrl(url)
|
||||
return opts.origin && skipUrl(opts.origin)
|
||||
? defaultAgent.dispatch(opts, handler)
|
||||
: dispatch(opts, handler);
|
||||
};
|
||||
@@ -60,12 +60,10 @@ export default async function createCustomProxyAgent(
|
||||
':' +
|
||||
proxySettings.port,
|
||||
token,
|
||||
interceptors: {
|
||||
Client: [noProxyInterceptor],
|
||||
},
|
||||
keepAliveTimeout: 5000,
|
||||
});
|
||||
|
||||
setGlobalDispatcher(proxyAgent);
|
||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||
} catch (e) {
|
||||
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||
label: 'Proxy',
|
||||
@@ -94,7 +92,11 @@ export default async function createCustomProxyAgent(
|
||||
}
|
||||
|
||||
function isLocalAddress(hostname: string) {
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { MainSettings } from '@server/lib/settings';
|
||||
import type { AllSettings, NetworkSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
|
||||
class RestartFlag {
|
||||
private settings: MainSettings;
|
||||
private networkSettings: NetworkSettings;
|
||||
|
||||
public initializeSettings(settings: MainSettings): void {
|
||||
this.settings = { ...settings };
|
||||
public initializeSettings(settings: AllSettings): void {
|
||||
this.networkSettings = {
|
||||
...settings.network,
|
||||
proxy: { ...settings.network.proxy },
|
||||
};
|
||||
}
|
||||
|
||||
public isSet(): boolean {
|
||||
const settings = getSettings().main;
|
||||
const networkSettings = getSettings().network;
|
||||
|
||||
return (
|
||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
||||
this.settings.trustProxy !== settings.trustProxy ||
|
||||
this.settings.proxy.enabled !== settings.proxy.enabled ||
|
||||
this.settings.forceIpv4First !== settings.forceIpv4First ||
|
||||
this.settings.dnsServers !== settings.dnsServers
|
||||
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
||||
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
24
src/assets/services/jellyfin-icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- ***** BEGIN LICENSE BLOCK *****
|
||||
- Part of the Jellyfin project (https://jellyfin.media)
|
||||
-
|
||||
- All copyright belongs to the Jellyfin contributors; a full list can
|
||||
- be found in the file CONTRIBUTORS.md
|
||||
-
|
||||
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
||||
- ***** END LICENSE BLOCK ***** -->
|
||||
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
|
||||
<stop offset="0" style="stop-color:#AA5CC3"/>
|
||||
<stop offset="1" style="stop-color:#00A4DC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>icon-transparent</title>
|
||||
<g id="icon-transparent">
|
||||
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
|
||||
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
|
||||
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -1,85 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 361 157">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="plex-logo"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1000 460.89727"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="plex-logo.svg"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
||||
id="metadata25"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs23">
|
||||
</defs><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#111111"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview21"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:zoom="0.27956081"
|
||||
inkscape:cx="783.06912"
|
||||
inkscape:cy="-132.85701"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="plex-logo" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:#EBAF00;}
|
||||
</style>
|
||||
<path
|
||||
class="st0"
|
||||
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
|
||||
id="path4"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
||||
class="st0"
|
||||
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
|
||||
id="path6"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
|
||||
class="st1"
|
||||
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
|
||||
id="polygon8"
|
||||
style="fill:#ebaf00"
|
||||
transform="scale(6.7567568)" /><polygon
|
||||
class="st0"
|
||||
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
|
||||
id="polygon10"
|
||||
style="fill:#ffffff"
|
||||
transform="scale(6.7567568)" /><path
|
||||
class="st0"
|
||||
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
|
||||
id="path12"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
||||
style="fill:#ffffff;stroke-width:6.75675678"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path16"
|
||||
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
|
||||
class="st0" />
|
||||
.cls-2 {
|
||||
fill: #eaaf20;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<path id="path4" class="cls-1"
|
||||
d="M60.6,28.8c-14.3,0-23.5,3.9-31.3,13v-10H1.6v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM55.2,104.3c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||
<path id="path6" class="cls-1"
|
||||
d="M148.1,76.5c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.9h28.4l.2,75.6Z" />
|
||||
<polygon id="polygon8" class="cls-2"
|
||||
points="287.6 78.3 254.1 31.7 288.6 31.7 321.8 78.3 288.6 124.6 254.1 124.6 287.6 78.3" />
|
||||
<polygon id="polygon10" class="cls-1"
|
||||
points="330.8 73 360.6 31.7 326.2 31.7 313.8 48.9 330.8 73" />
|
||||
<path id="path12" class="cls-1"
|
||||
d="M313.8,107.7l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||
<path id="path16" class="cls-1"
|
||||
d="M228.7,97.9c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM211.9,50.7c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||
<path id="path4-2" data-name="path4" class="cls-1"
|
||||
d="M59.3,28.2c-14.3,0-23.5,3.9-31.3,13v-10H.4v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM54,103.8c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||
<path id="path6-2" data-name="path6" class="cls-1"
|
||||
d="M146.9,75.9c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.4h28.4l.2,75.6Z" />
|
||||
<polygon id="polygon8-2" data-name="polygon8" class="cls-2"
|
||||
points="286.4 77.8 252.9 31.2 287.3 31.2 320.6 77.8 287.3 124.1 252.9 124.1 286.4 77.8" />
|
||||
<polygon id="polygon10-2" data-name="polygon10" class="cls-1"
|
||||
points="329.5 72.5 359.4 31.2 324.9 31.2 312.6 48.3 329.5 72.5" />
|
||||
<path id="path12-2" data-name="path12" class="cls-1"
|
||||
d="M312.6,107.2l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||
<path id="path16-2" data-name="path16" class="cls-1"
|
||||
d="M227.4,97.4c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM210.7,50.1c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -17,13 +17,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
||||
const dAirDate = new Date(airDate);
|
||||
const nowDate = new Date();
|
||||
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
|
||||
|
||||
const compareWeek = new Date(
|
||||
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
|
||||
);
|
||||
|
||||
let showRelative = false;
|
||||
|
||||
if (
|
||||
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
|
||||
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
|
||||
@@ -31,6 +28,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
||||
showRelative = true;
|
||||
}
|
||||
|
||||
const diffInDays = Math.round(
|
||||
(dAirDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge badgeType="light">
|
||||
@@ -48,9 +49,9 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
||||
{
|
||||
relativeTime: (
|
||||
<FormattedRelativeTime
|
||||
value={(dAirDate.getTime() - Date.now()) / 1000}
|
||||
value={diffInDays}
|
||||
unit="day"
|
||||
numeric="auto"
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||
src={
|
||||
title?.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
|
||||
@@ -233,7 +233,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ForwardedRef } from 'react';
|
||||
import React from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type ButtonType =
|
||||
| 'default'
|
||||
@@ -97,7 +98,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
if (as === 'a') {
|
||||
return (
|
||||
<a
|
||||
className={buttonStyle.join(' ')}
|
||||
className={twMerge(buttonStyle)}
|
||||
{...(props as React.ComponentProps<'a'>)}
|
||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||
>
|
||||
@@ -107,7 +108,7 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
className={buttonStyle.join(' ')}
|
||||
className={twMerge(buttonStyle)}
|
||||
{...(props as React.ComponentProps<'button'>)}
|
||||
ref={ref as ForwardedRef<HTMLButtonElement>}
|
||||
>
|
||||
|
||||
@@ -1,77 +1,29 @@
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Menu } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const DropdownItem = ({
|
||||
children,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownItemProps) => {
|
||||
let styleClass = 'button-md text-white';
|
||||
|
||||
switch (buttonType) {
|
||||
case 'ghost':
|
||||
styleClass +=
|
||||
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
|
||||
break;
|
||||
default:
|
||||
styleClass +=
|
||||
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
|
||||
}
|
||||
return (
|
||||
<a
|
||||
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
interface ButtonWithDropdownProps {
|
||||
type ButtonWithDropdownProps = {
|
||||
text: React.ReactNode;
|
||||
dropdownIcon?: React.ReactNode;
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonWithDropdownProps {
|
||||
as?: 'button';
|
||||
}
|
||||
interface AnchorProps
|
||||
extends AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
ButtonWithDropdownProps {
|
||||
as: 'a';
|
||||
}
|
||||
} & (
|
||||
| ({ as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
|
||||
| ({ as: 'a' } & AnchorHTMLAttributes<HTMLAnchorElement>)
|
||||
);
|
||||
|
||||
const ButtonWithDropdown = ({
|
||||
as,
|
||||
text,
|
||||
children,
|
||||
dropdownIcon,
|
||||
className,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: ButtonProps | AnchorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||
|
||||
}: ButtonWithDropdownProps) => {
|
||||
const styleClasses = {
|
||||
mainButtonClasses: 'button-md text-white border',
|
||||
dropdownSideButtonClasses: 'button-md border',
|
||||
dropdownClasses: 'button-md',
|
||||
};
|
||||
|
||||
switch (buttonType) {
|
||||
@@ -79,72 +31,40 @@ const ButtonWithDropdown = ({
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||
styleClasses.dropdownClasses +=
|
||||
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
|
||||
break;
|
||||
default:
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownSideButtonClasses +=
|
||||
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
||||
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
|
||||
}
|
||||
|
||||
const TriggerElement = props.as ?? 'button';
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex h-full rounded-md shadow-sm">
|
||||
{as === 'a' ? (
|
||||
<a
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
||||
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
ref={buttonRef as RefObject<HTMLButtonElement>}
|
||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)}
|
||||
<Menu as="div" className="relative z-10 inline-flex">
|
||||
<TriggerElement
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||
styleClasses.mainButtonClasses
|
||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||
{...(props as Record<string, string>)}
|
||||
>
|
||||
{text}
|
||||
</TriggerElement>
|
||||
{children && (
|
||||
<span className="relative -ml-px block">
|
||||
<button
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
||||
aria-label="Expand"
|
||||
onClick={() => setIsOpen((state) => !state)}
|
||||
>
|
||||
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
||||
</button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
show={isOpen}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Menu.Button>
|
||||
<Dropdown.Items dropdownType={buttonType}>{children}</Dropdown.Items>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
export default withProperties(ButtonWithDropdown, { Item: DropdownItem });
|
||||
export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
|
||||
|
||||
117
src/components/Common/Dropdown/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { withProperties } from '@app/utils/typeHelpers';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||
import {
|
||||
Fragment,
|
||||
useRef,
|
||||
type AnchorHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const DropdownItem = ({
|
||||
children,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownItemProps) => {
|
||||
return (
|
||||
<Menu.Item>
|
||||
<a
|
||||
className={[
|
||||
'button-md flex cursor-pointer items-center rounded px-4 py-2 text-sm leading-5 text-white focus:text-white focus:outline-none',
|
||||
buttonType === 'ghost'
|
||||
? 'bg-transparent from-indigo-600 to-purple-600 hover:bg-gradient-to-br focus:border-gray-500'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700',
|
||||
].join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type DropdownItemsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
dropdownType: 'primary' | 'ghost';
|
||||
};
|
||||
|
||||
const DropdownItems = ({
|
||||
children,
|
||||
className,
|
||||
dropdownType,
|
||||
...props
|
||||
}: DropdownItemsProps) => {
|
||||
return (
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={[
|
||||
'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',
|
||||
className,
|
||||
].join(' ')}
|
||||
{...props}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: React.ReactNode;
|
||||
dropdownIcon?: React.ReactNode;
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
}
|
||||
|
||||
const Dropdown = ({
|
||||
text,
|
||||
children,
|
||||
dropdownIcon,
|
||||
className,
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}: DropdownProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative z-10">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={[
|
||||
'button-md inline-flex h-full items-center space-x-2 rounded-md border px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none',
|
||||
buttonType === 'ghost'
|
||||
? 'border-gray-600 bg-transparent hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
: 'focus:ring-blue border-indigo-500 bg-indigo-600 bg-opacity-80 hover:border-indigo-500 hover:bg-opacity-100 active:border-indigo-700 active:bg-indigo-700',
|
||||
className,
|
||||
].join(' ')}
|
||||
ref={buttonRef}
|
||||
disabled={!children}
|
||||
{...props}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{children && (dropdownIcon ? dropdownIcon : <ChevronDownIcon />)}
|
||||
</Menu.Button>
|
||||
{children && (
|
||||
<DropdownItems dropdownType={buttonType}>{children}</DropdownItems>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
export default withProperties(Dropdown, {
|
||||
Item: DropdownItem,
|
||||
Items: DropdownItems,
|
||||
});
|
||||
44
src/components/Common/LabeledCheckbox/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Field } from 'formik';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface LabeledCheckboxProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
onChange: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||
id,
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={twMerge('relative flex items-start', className)}>
|
||||
<div className="flex h-6 items-center">
|
||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor="localLogin" className="block">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{label}</span>
|
||||
<span className="font-normal text-gray-400">{description}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
/* can hold child checkboxes */
|
||||
children && <div className="mt-4 pl-10">{children}</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabeledCheckbox;
|
||||
@@ -29,11 +29,16 @@ interface ModalProps {
|
||||
secondaryDisabled?: boolean;
|
||||
tertiaryDisabled?: boolean;
|
||||
tertiaryButtonType?: ButtonType;
|
||||
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
disableScrollLock?: boolean;
|
||||
backgroundClickable?: boolean;
|
||||
loading?: boolean;
|
||||
backdrop?: string;
|
||||
children?: React.ReactNode;
|
||||
dialogClass?: string;
|
||||
}
|
||||
|
||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
@@ -61,6 +66,11 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
loading = false,
|
||||
onTertiary,
|
||||
backdrop,
|
||||
dialogClass,
|
||||
okButtonProps,
|
||||
cancelButtonProps,
|
||||
secondaryButtonProps,
|
||||
tertiaryButtonProps,
|
||||
},
|
||||
parentRef
|
||||
) => {
|
||||
@@ -106,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 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"
|
||||
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"
|
||||
@@ -189,6 +199,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
className="ml-3"
|
||||
disabled={okDisabled}
|
||||
data-testid="modal-ok-button"
|
||||
{...okButtonProps}
|
||||
>
|
||||
{okText ? okText : 'Ok'}
|
||||
</Button>
|
||||
@@ -200,6 +211,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
className="ml-3"
|
||||
disabled={secondaryDisabled}
|
||||
data-testid="modal-secondary-button"
|
||||
{...secondaryButtonProps}
|
||||
>
|
||||
{secondaryText}
|
||||
</Button>
|
||||
@@ -210,6 +222,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClick={onTertiary}
|
||||
className="ml-3"
|
||||
disabled={tertiaryDisabled}
|
||||
{...tertiaryButtonProps}
|
||||
>
|
||||
{tertiaryText}
|
||||
</Button>
|
||||
@@ -220,6 +233,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||
onClick={onCancel}
|
||||
className="ml-3 sm:ml-0"
|
||||
data-testid="modal-cancel-button"
|
||||
{...cancelButtonProps}
|
||||
>
|
||||
{cancelText
|
||||
? cancelText
|
||||
|
||||
@@ -25,6 +25,10 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
autoComplete="off"
|
||||
data-form-type="other"
|
||||
data-1pignore="true"
|
||||
data-lpignore="true"
|
||||
{...componentProps}
|
||||
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
||||
type={
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.IssueDetails', {
|
||||
@@ -155,6 +155,7 @@ const IssueDetails = () => {
|
||||
autoDismiss: true,
|
||||
});
|
||||
revalidateIssue();
|
||||
mutate('/api/v1/issue/count');
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
|
||||
appearance: 'error',
|
||||
@@ -169,6 +170,7 @@ const IssueDetails = () => {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
mutate('/api/v1/issue/count');
|
||||
|
||||
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
||||
appearance: 'success',
|
||||
@@ -240,7 +242,7 @@ const IssueDetails = () => {
|
||||
src={
|
||||
data.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
|
||||
@@ -142,7 +142,7 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
: '/images/jellyseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
sizes="100vw"
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Field, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.IssueModal.CreateIssueModal', {
|
||||
@@ -138,6 +138,8 @@ const CreateIssueModal = ({
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
|
||||
mutate('/api/v1/issue/count');
|
||||
}
|
||||
|
||||
if (onCancel) {
|
||||
|
||||
@@ -33,6 +33,7 @@ interface LanguageSelectorProps {
|
||||
setFieldValue: (property: string, value: string) => void;
|
||||
serverValue?: string;
|
||||
isUserSettings?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const LanguageSelector = ({
|
||||
@@ -40,6 +41,7 @@ const LanguageSelector = ({
|
||||
setFieldValue,
|
||||
serverValue,
|
||||
isUserSettings = false,
|
||||
isDisabled,
|
||||
}: LanguageSelectorProps) => {
|
||||
const intl = useIntl();
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
@@ -96,6 +98,7 @@ const LanguageSelector = ({
|
||||
<Select<OptionType, true>
|
||||
options={options}
|
||||
isMulti
|
||||
isDisabled={isDisabled}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import { menuMessages } from '@app/components/Layout/Sidebar';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
@@ -26,9 +27,16 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { cloneElement, useRef, useState } from 'react';
|
||||
import { cloneElement, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
interface MobileMenuProps {
|
||||
pendingRequestsCount: number;
|
||||
openIssuesCount: number;
|
||||
revalidateIssueCount: () => void;
|
||||
revalidateRequestsCount: () => void;
|
||||
}
|
||||
|
||||
interface MenuLink {
|
||||
href: string;
|
||||
svgIcon: JSX.Element;
|
||||
@@ -41,7 +49,12 @@ interface MenuLink {
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
const MobileMenu = () => {
|
||||
const MobileMenu = ({
|
||||
pendingRequestsCount,
|
||||
openIssuesCount,
|
||||
revalidateIssueCount,
|
||||
revalidateRequestsCount,
|
||||
}: MobileMenuProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -139,6 +152,21 @@ const MobileMenu = () => {
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (openIssuesCount) {
|
||||
revalidateIssueCount();
|
||||
}
|
||||
|
||||
if (pendingRequestsCount) {
|
||||
revalidateRequestsCount();
|
||||
}
|
||||
}, [
|
||||
revalidateIssueCount,
|
||||
revalidateRequestsCount,
|
||||
pendingRequestsCount,
|
||||
openIssuesCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50">
|
||||
<Transition
|
||||
@@ -159,7 +187,7 @@ const MobileMenu = () => {
|
||||
<Link
|
||||
key={`mobile-menu-link-${link.href}`}
|
||||
href={link.href}
|
||||
className={`flex items-center space-x-2 ${
|
||||
className={`flex items-center ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
onKeyDown={(e) => {
|
||||
@@ -174,7 +202,25 @@ const MobileMenu = () => {
|
||||
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
|
||||
className: 'h-5 w-5',
|
||||
})}
|
||||
<span>{link.content}</span>
|
||||
<span className="ml-2">{link.content}</span>
|
||||
{link.href === '/requests' &&
|
||||
pendingRequestsCount > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="ml-auto flex">
|
||||
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
|
||||
{pendingRequestsCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{link.href === '/issues' &&
|
||||
openIssuesCount > 0 &&
|
||||
hasPermission(Permission.MANAGE_ISSUES) && (
|
||||
<div className="ml-auto flex">
|
||||
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
|
||||
{openIssuesCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -190,7 +236,7 @@ const MobileMenu = () => {
|
||||
<Link
|
||||
key={`mobile-menu-link-${link.href}`}
|
||||
href={link.href}
|
||||
className={`flex flex-col items-center space-y-1 ${
|
||||
className={`relative flex flex-col items-center space-y-1 ${
|
||||
isActive ? 'text-indigo-500' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -200,6 +246,25 @@ const MobileMenu = () => {
|
||||
className: 'h-6 w-6',
|
||||
}
|
||||
)}
|
||||
{link.href === '/requests' &&
|
||||
pendingRequestsCount > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="absolute left-3 bottom-3">
|
||||
<Badge
|
||||
className={`bg-gradient-to-br ${
|
||||
router.pathname.match(link.activeRegExp)
|
||||
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||
} flex ${
|
||||
pendingRequestsCount > 99 ? 'w-6' : 'w-4'
|
||||
} h-4 items-center justify-center !px-[5px] !py-[7px] text-[8px]`}
|
||||
>
|
||||
{pendingRequestsCount > 99
|
||||
? '99+'
|
||||
: pendingRequestsCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import UserWarnings from '@app/components/Layout/UserWarnings';
|
||||
import VersionStatus from '@app/components/Layout/VersionStatus';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Fragment, useRef } from 'react';
|
||||
import { Fragment, useEffect, useRef } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
||||
@@ -35,6 +36,10 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
||||
interface SidebarProps {
|
||||
open?: boolean;
|
||||
setClosed: () => void;
|
||||
pendingRequestsCount: number;
|
||||
openIssuesCount: number;
|
||||
revalidateIssueCount: () => void;
|
||||
revalidateRequestsCount: () => void;
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
@@ -114,13 +119,35 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
const Sidebar = ({
|
||||
open,
|
||||
setClosed,
|
||||
pendingRequestsCount,
|
||||
openIssuesCount,
|
||||
revalidateIssueCount,
|
||||
revalidateRequestsCount,
|
||||
}: SidebarProps) => {
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
useClickOutside(navRef, () => setClosed());
|
||||
|
||||
useEffect(() => {
|
||||
if (openIssuesCount) {
|
||||
revalidateIssueCount();
|
||||
}
|
||||
|
||||
if (pendingRequestsCount) {
|
||||
revalidateRequestsCount();
|
||||
}
|
||||
}, [
|
||||
revalidateIssueCount,
|
||||
revalidateRequestsCount,
|
||||
pendingRequestsCount,
|
||||
openIssuesCount,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">
|
||||
@@ -253,18 +280,48 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
|
||||
href={sidebarLink.href}
|
||||
as={sidebarLink.as}
|
||||
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
|
||||
${
|
||||
router.pathname.match(sidebarLink.activeRegExp)
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
||||
: 'hover:bg-gray-700 focus:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
${
|
||||
router.pathname.match(sidebarLink.activeRegExp)
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
||||
: 'hover:bg-gray-700 focus:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
data-testid={sidebarLink.dataTestId}
|
||||
>
|
||||
{sidebarLink.svgIcon}
|
||||
{intl.formatMessage(
|
||||
menuMessages[sidebarLink.messagesKey]
|
||||
)}
|
||||
{sidebarLink.messagesKey === 'requests' &&
|
||||
pendingRequestsCount > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="ml-auto flex">
|
||||
<Badge
|
||||
className={`rounded-md bg-gradient-to-br ${
|
||||
router.pathname.match(sidebarLink.activeRegExp)
|
||||
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||
}`}
|
||||
>
|
||||
{pendingRequestsCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{sidebarLink.messagesKey === 'issues' &&
|
||||
openIssuesCount > 0 &&
|
||||
hasPermission(Permission.MANAGE_ISSUES) && (
|
||||
<div className="ml-auto flex">
|
||||
<Badge
|
||||
className={`rounded-md bg-gradient-to-br ${
|
||||
router.pathname.match(sidebarLink.activeRegExp)
|
||||
? 'border-indigo-600 from-indigo-700 to-purple-700'
|
||||
: 'border-indigo-500 from-indigo-600 to-purple-600'
|
||||
}`}
|
||||
>
|
||||
{openIssuesCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -22,6 +23,18 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
const router = useRouter();
|
||||
const { currentSettings } = useSettings();
|
||||
const { setLocale } = useLocale();
|
||||
const { data: requestResponse, mutate: revalidateRequestsCount } = useSWR(
|
||||
'/api/v1/request/count',
|
||||
{
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
const { data: issueResponse, mutate: revalidateIssueCount } = useSWR(
|
||||
'/api/v1/issue/count',
|
||||
{
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (setLocale && user) {
|
||||
@@ -55,10 +68,21 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
|
||||
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
|
||||
</div>
|
||||
|
||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
||||
<Sidebar
|
||||
open={isSidebarOpen}
|
||||
setClosed={() => setSidebarOpen(false)}
|
||||
pendingRequestsCount={requestResponse?.pending ?? 0}
|
||||
openIssuesCount={issueResponse?.open ?? 0}
|
||||
revalidateIssueCount={() => revalidateIssueCount()}
|
||||
revalidateRequestsCount={() => revalidateRequestsCount()}
|
||||
/>
|
||||
<div className="sm:hidden">
|
||||
<MobileMenu />
|
||||
<MobileMenu
|
||||
pendingRequestsCount={requestResponse?.pending ?? 0}
|
||||
openIssuesCount={issueResponse?.open ?? 0}
|
||||
revalidateIssueCount={() => revalidateIssueCount()}
|
||||
revalidateRequestsCount={() => revalidateRequestsCount()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
hostname: '{mediaServerName} URL',
|
||||
port: 'Port',
|
||||
enablessl: 'Use SSL',
|
||||
urlBase: 'URL Base',
|
||||
email: 'Email',
|
||||
emailtooltip:
|
||||
'Address does not need to be associated with your {mediaServerName} instance.',
|
||||
validationhostrequired: '{mediaServerName} URL required',
|
||||
validationhostformat: 'Valid URL required',
|
||||
validationemailrequired: 'Email required',
|
||||
validationemailformat: 'Valid email required',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'Password required',
|
||||
validationservertyperequired: 'Please select a server type',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
|
||||
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
|
||||
loginerror: 'Something went wrong while trying to sign in.',
|
||||
adminerror: 'You must use an admin account to sign in.',
|
||||
noadminerror: 'No admin user found on the server.',
|
||||
credentialerror: 'The username or password is incorrect.',
|
||||
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
|
||||
signingin: 'Signing in…',
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
initialsigningin: 'Connecting…',
|
||||
initialsignin: 'Connect',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
servertype: 'Server Type',
|
||||
back: 'Go back',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
revalidate: () => void;
|
||||
initial?: boolean;
|
||||
serverType?: MediaServerType;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
initial,
|
||||
serverType,
|
||||
onCancel,
|
||||
}) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
@@ -72,56 +48,29 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
if (initial) {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(
|
||||
messages.validationhostrequired,
|
||||
mediaServerFormatValues
|
||||
)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'trailing-slash',
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||
? settings.currentSettings.jellyfinExternalHost
|
||||
: settings.currentSettings.jellyfinHost;
|
||||
const jellyfinForgotPasswordUrl =
|
||||
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||
|
||||
return (
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
hostname: '',
|
||||
port: 8096,
|
||||
useSsl: false,
|
||||
urlBase: '',
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
||||
// }
|
||||
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -130,12 +79,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
serverType: serverType,
|
||||
email: values.username,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
@@ -165,7 +109,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
}
|
||||
|
||||
toasts.addToast(
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
@@ -178,303 +121,55 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
values,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div className="flex flex-col sm:flex-row sm:gap-4">
|
||||
<div className="w-full">
|
||||
<label htmlFor="hostname" className="text-label">
|
||||
{intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mb-0 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
{values.useSsl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form data-form-type="login">
|
||||
<div>
|
||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.loginwithapp, {
|
||||
appName: mediaServerFormatValues.mediaServerName,
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<div className="mt-1 mb-4">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
className="rounded-r-only flex-1"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.hostname,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
data-form-type="username"
|
||||
/>
|
||||
</div>
|
||||
{errors.hostname && touched.hostname && (
|
||||
<div className="error">{errors.hostname}</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="port" className="text-label">
|
||||
{intl.formatMessage(messages.port)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0">
|
||||
<Field
|
||||
id="port"
|
||||
name="port"
|
||||
inputMode="numeric"
|
||||
type="text"
|
||||
className="short flex-1"
|
||||
placeholder={intl.formatMessage(messages.port)}
|
||||
/>
|
||||
{errors.port && touched.port && (
|
||||
<div className="error">{errors.port}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="useSsl" className="text-label mt-2">
|
||||
{intl.formatMessage(messages.enablessl)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="useSsl"
|
||||
name="useSsl"
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
setFieldValue('useSsl', !values.useSsl);
|
||||
setFieldValue('port', values.useSsl ? 8096 : 443);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="urlBase" className="text-label mt-1">
|
||||
{intl.formatMessage(messages.urlBase)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
type="text"
|
||||
inputMode="url"
|
||||
id="urlBase"
|
||||
name="urlBase"
|
||||
placeholder={intl.formatMessage(messages.urlBase)}
|
||||
/>
|
||||
</div>
|
||||
{errors.urlBase && touched.urlBase && (
|
||||
<div className="error">{errors.urlBase}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-label inline-flex gap-1 align-middle"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
<span className="label-tip">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(
|
||||
messages.emailtooltip,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
>
|
||||
<span className="tooltip-trigger">
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 sm:col-span-2 sm:mb-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.email)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="error">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flexrounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
{onCancel && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
} else {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string(),
|
||||
});
|
||||
const baseUrl = settings.currentSettings.jellyfinExternalHost
|
||||
? settings.currentSettings.jellyfinExternalHost
|
||||
: settings.currentSettings.jellyfinHost;
|
||||
const jellyfinForgotPasswordUrl =
|
||||
settings.currentSettings.jellyfinForgotPasswordUrl;
|
||||
return (
|
||||
<div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.username,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
let errorMessage = null;
|
||||
switch (errorData?.message) {
|
||||
case ApiErrorCode.InvalidUrl:
|
||||
errorMessage = messages.invalidurlerror;
|
||||
break;
|
||||
case ApiErrorCode.InvalidCredentials:
|
||||
errorMessage = messages.credentialerror;
|
||||
break;
|
||||
case ApiErrorCode.NotAdmin:
|
||||
errorMessage = messages.adminerror;
|
||||
break;
|
||||
case ApiErrorCode.NoAdminUser:
|
||||
errorMessage = messages.noadminerror;
|
||||
break;
|
||||
default:
|
||||
errorMessage = messages.loginerror;
|
||||
break;
|
||||
}
|
||||
toasts.addToast(
|
||||
intl.formatMessage(errorMessage, mediaServerFormatValues),
|
||||
{
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 mb-2">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
data-form-type="password"
|
||||
data-1pignore="false"
|
||||
data-lpignore="false"
|
||||
/>
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
as="a"
|
||||
buttonType="ghost"
|
||||
<div className="flex-grow"></div>
|
||||
{baseUrl && (
|
||||
<a
|
||||
href={
|
||||
jellyfinForgotPasswordUrl
|
||||
? `${jellyfinForgotPasswordUrl}`
|
||||
@@ -485,31 +180,35 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: ''
|
||||
}forgotpassword.html`
|
||||
}
|
||||
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
className="mt-2 w-full shadow-sm"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinLogin;
|
||||
|
||||
@@ -2,10 +2,7 @@ import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
LifebuoyIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
@@ -13,6 +10,7 @@ import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
username: 'Username',
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
@@ -53,6 +51,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
validateOnBlur={false}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/local', {
|
||||
@@ -76,21 +75,27 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<Form data-form-type="login">
|
||||
<div>
|
||||
<label htmlFor="email" className="text-label">
|
||||
{intl.formatMessage(messages.email) +
|
||||
' / ' +
|
||||
intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<h2 className="mb-6 -mt-1 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.loginwithapp, {
|
||||
appName: settings.currentSettings.applicationTitle,
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<div className="mt-1 mb-4">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder={`${intl.formatMessage(
|
||||
messages.email
|
||||
)} / ${intl.formatMessage(messages.username)}`}
|
||||
type="text"
|
||||
inputMode="email"
|
||||
data-testid="email"
|
||||
data-form-type="username,email"
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
{errors.email &&
|
||||
@@ -99,25 +104,38 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
<div className="error">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="mt-1 mb-2">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete="current-password"
|
||||
data-testid="password"
|
||||
data-form-type="password"
|
||||
className="!bg-gray-700/80 placeholder:text-gray-400"
|
||||
data-1pignore="false"
|
||||
data-lpignore="false"
|
||||
/>
|
||||
</div>
|
||||
{errors.password &&
|
||||
touched.password &&
|
||||
typeof errors.password === 'string' && (
|
||||
<div className="error">{errors.password}</div>
|
||||
<div className="flex">
|
||||
{errors.password &&
|
||||
touched.password &&
|
||||
typeof errors.password === 'string' && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
<div className="flex-grow"></div>
|
||||
{passwordResetEnabled && (
|
||||
<Link
|
||||
href="/resetpassword"
|
||||
className="pt-2 text-sm text-indigo-500 hover:text-indigo-400"
|
||||
>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loginError && (
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
@@ -125,37 +143,21 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
data-testid="local-signin-button"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
{passwordResetEnabled && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Link href="/resetpassword" passHref legacyBehavior>
|
||||
<Button as="a" buttonType="ghost">
|
||||
<LifebuoyIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
data-testid="local-signin-button"
|
||||
className="mt-2 w-full shadow-sm"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
|
||||
62
src/components/Login/PlexLoginButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import PlexIcon from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import usePlexLogin from '@app/hooks/usePlexLogin';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
});
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
large?: boolean;
|
||||
}
|
||||
|
||||
const PlexLoginButton = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
large,
|
||||
}: PlexLoginButtonProps) => {
|
||||
const { loading, login } = usePlexLogin({ onAuthToken, onError });
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
|
||||
onClick={login}
|
||||
disabled={loading || isProcessing}
|
||||
data-testid="plex-login-button"
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute right-0 mr-4 h-4 w-4">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{large ? (
|
||||
<FormattedMessage
|
||||
{...messages.loginwithapp}
|
||||
values={{
|
||||
appName: <PlexIcon className="mt-[2px] ml-[0.35em] w-8" />,
|
||||
}}
|
||||
>
|
||||
{(chunks) => (
|
||||
<>
|
||||
{chunks.map((c) =>
|
||||
typeof c === 'string' ? <span>{c}</span> : c
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormattedMessage>
|
||||
) : (
|
||||
<PlexIcon className="w-8" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlexLoginButton;
|
||||
@@ -1,9 +1,13 @@
|
||||
import Accordion from '@app/components/Common/Accordion';
|
||||
import EmbyLogo from '@app/assets/services/emby-icon-only.svg';
|
||||
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import ImageFader from '@app/components/Common/ImageFader';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LanguagePicker from '@app/components/Layout/LanguagePicker';
|
||||
import JellyfinLogin from '@app/components/Login/JellyfinLogin';
|
||||
import LocalLogin from '@app/components/Login/LocalLogin';
|
||||
import PlexLoginButton from '@app/components/PlexLoginButton';
|
||||
import PlexLoginButton from '@app/components/Login/PlexLoginButton';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -12,10 +16,10 @@ import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||
import useSWR from 'swr';
|
||||
import JellyfinLogin from './JellyfinLogin';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
signin: 'Sign In',
|
||||
@@ -23,16 +27,21 @@ const messages = defineMessages('components.Login', {
|
||||
signinwithplex: 'Use your Plex account',
|
||||
signinwithjellyfin: 'Use your {mediaServerName} account',
|
||||
signinwithoverseerr: 'Use your {applicationTitle} account',
|
||||
orsigninwith: 'Or sign in with',
|
||||
});
|
||||
|
||||
const Login = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const [mediaServerLogin, setMediaServerLogin] = useState(
|
||||
settings.currentSettings.mediaServerLogin
|
||||
);
|
||||
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to sign in. If we get a success message, we will
|
||||
@@ -86,14 +95,73 @@ const Login = () => {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? 'Plex'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined;
|
||||
|
||||
const MediaServerLogo =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX
|
||||
? PlexLogo
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? JellyfinLogo
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? EmbyLogo
|
||||
: undefined;
|
||||
|
||||
const isJellyfin =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN ||
|
||||
settings.currentSettings.mediaServerType === MediaServerType.EMBY;
|
||||
const mediaServerLoginRef = useRef<HTMLDivElement>(null);
|
||||
const localLoginRef = useRef<HTMLDivElement>(null);
|
||||
const loginRef = mediaServerLogin ? mediaServerLoginRef : localLoginRef;
|
||||
|
||||
const loginFormVisible =
|
||||
(isJellyfin && settings.currentSettings.mediaServerLogin) ||
|
||||
settings.currentSettings.localLogin;
|
||||
const additionalLoginOptions = [
|
||||
settings.currentSettings.mediaServerLogin &&
|
||||
(settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLoginButton
|
||||
key="plex"
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
large={!isJellyfin && !settings.currentSettings.localLogin}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin &&
|
||||
(mediaServerLogin ? (
|
||||
<Button
|
||||
key="jellyseerr"
|
||||
data-testid="jellyseerr-login-button"
|
||||
className="flex-1 bg-transparent"
|
||||
onClick={() => setMediaServerLogin(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/os_icon.svg"
|
||||
alt={settings.currentSettings.applicationTitle}
|
||||
className="mr-2 h-5"
|
||||
/>
|
||||
<span>{settings.currentSettings.applicationTitle}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="mediaserver"
|
||||
data-testid="mediaserver-login-button"
|
||||
className="flex-1 bg-transparent"
|
||||
onClick={() => setMediaServerLogin(true)}
|
||||
>
|
||||
<MediaServerLogo />
|
||||
<span>{mediaServerName}</span>
|
||||
</Button>
|
||||
))
|
||||
)),
|
||||
].filter((o): o is JSX.Element => !!o);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||
@@ -112,9 +180,6 @@ const Login = () => {
|
||||
<div className="relative h-48 w-full max-w-full">
|
||||
<Image src="/logo_stacked.svg" alt="Logo" fill />
|
||||
</div>
|
||||
<h2 className="mt-12 text-center text-3xl font-extrabold leading-9 text-gray-100">
|
||||
{intl.formatMessage(messages.signinheader)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div
|
||||
@@ -145,65 +210,71 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Accordion single atLeastOne>
|
||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||
<>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
|
||||
openIndexes.includes(0) && 'text-indigo-500'
|
||||
} ${
|
||||
settings.currentSettings.localLogin &&
|
||||
'hover:cursor-pointer hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={() => handleClick(0)}
|
||||
disabled={!settings.currentSettings.localLogin}
|
||||
>
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.signinwithplex)
|
||||
: intl.formatMessage(
|
||||
messages.signinwithjellyfin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||
<div className="px-10 py-8">
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX ? (
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinLogin revalidate={revalidate} />
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
{settings.currentSettings.localLogin && (
|
||||
<div>
|
||||
<button
|
||||
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
|
||||
openIndexes.includes(1)
|
||||
? 'text-indigo-500'
|
||||
: 'sm:rounded-b-lg'
|
||||
}`}
|
||||
onClick={() => handleClick(1)}
|
||||
>
|
||||
{intl.formatMessage(messages.signinwithoverseerr, {
|
||||
applicationTitle:
|
||||
settings.currentSettings.applicationTitle,
|
||||
})}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(1)}>
|
||||
<div className="px-10 py-8">
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
<div className="px-10 py-8">
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
key={mediaServerLogin ? 'ms' : 'local'}
|
||||
nodeRef={loginRef}
|
||||
addEndListener={(done) => {
|
||||
loginRef.current?.addEventListener(
|
||||
'transitionend',
|
||||
done,
|
||||
false
|
||||
);
|
||||
}}
|
||||
onEntered={() => {
|
||||
document
|
||||
.querySelector<HTMLInputElement>('#email, #username')
|
||||
?.focus();
|
||||
}}
|
||||
classNames={{
|
||||
appear: 'opacity-0',
|
||||
appearActive: 'transition-opacity duration-500 opacity-100',
|
||||
enter: 'opacity-0',
|
||||
enterActive: 'transition-opacity duration-500 opacity-100',
|
||||
exitActive: 'transition-opacity duration-0 opacity-0',
|
||||
}}
|
||||
>
|
||||
<div ref={loginRef} className="button-container">
|
||||
{isJellyfin &&
|
||||
(mediaServerLogin ||
|
||||
!settings.currentSettings.localLogin) ? (
|
||||
<JellyfinLogin
|
||||
serverType={settings.currentSettings.mediaServerType}
|
||||
revalidate={revalidate}
|
||||
/>
|
||||
) : (
|
||||
settings.currentSettings.localLogin && (
|
||||
<LocalLogin revalidate={revalidate} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
|
||||
{additionalLoginOptions.length > 0 &&
|
||||
(loginFormVisible ? (
|
||||
<div className="flex items-center py-5">
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
<span className="mx-2 flex-shrink text-sm text-gray-400">
|
||||
{intl.formatMessage(messages.orsigninwith)}
|
||||
</span>
|
||||
<div className="flex-grow border-t border-gray-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="mb-6 text-center text-lg font-bold text-neutral-200">
|
||||
{intl.formatMessage(messages.signinheader)}
|
||||
</h2>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`flex w-full flex-wrap gap-2 ${
|
||||
!loginFormVisible ? 'flex-col' : ''
|
||||
}`}
|
||||
>
|
||||
{additionalLoginOptions}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,15 +122,13 @@ const ManageSlideOver = ({
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (data.mediaInfo) {
|
||||
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
||||
// we don't check if the response is ok here because there may be no file to delete
|
||||
await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res1.ok) throw new Error();
|
||||
|
||||
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||
await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res2.ok) throw new Error();
|
||||
|
||||
revalidate();
|
||||
onClose();
|
||||
|
||||