Compare commits

..

96 Commits

Author SHA1 Message Date
semantic-release-bot
4a5421a9ba chore(release): 1.30.0 2022-09-19 07:17:15 +00:00
Ryan Cohen
24b9c00b2a Merge branch 'develop' 2022-09-19 16:12:44 +09:00
semantic-release-bot
5125abdbf0 chore(release): 1.29.1 2022-04-06 23:34:24 +00:00
Ryan Cohen
724f8bb538 Merge branch 'develop' 2022-04-07 08:33:04 +09:00
semantic-release-bot
512f6e51e4 chore(release): 1.29.0 2022-04-01 09:03:58 +00:00
Ryan Cohen
1b36dded3f Merge branch 'develop' 2022-04-01 18:02:32 +09:00
Ryan Cohen
77511bf199 ci: disable husky for release ci 2022-04-01 18:01:22 +09:00
Ryan Cohen
26d77af3a4 Merge branch 'develop' 2022-04-01 17:45:51 +09:00
semantic-release-bot
2b111f1c34 chore(release): 1.28.0 2022-01-01 04:20:46 +00:00
Ryan Cohen
a6392cb2db Merge branch 'develop' 2022-01-01 13:11:57 +09:00
semantic-release-bot
6d55b50e5e chore(release): 1.27.0 2021-10-19 14:01:17 +00:00
sct
f7dc6256ba Merge branch 'develop' 2021-10-19 22:54:07 +09:00
semantic-release-bot
f1df1caea5 chore(release): 1.26.1 2021-09-20 00:22:06 +00:00
sct
d23c120dc0 Merge branch 'develop' 2021-09-20 09:14:29 +09:00
semantic-release-bot
353efa39d0 chore(release): 1.26.0 2021-09-19 12:15:52 +00:00
sct
21cdf7d113 Merge branch 'develop' 2021-09-19 21:08:16 +09:00
semantic-release-bot
f858f025fe chore(release): 1.25.0 2021-06-10 10:25:49 +00:00
sct
c92bb134d4 Merge branch 'develop' 2021-06-10 19:18:12 +09:00
semantic-release-bot
7e0e2a92e3 chore(release): 1.24.0 2021-05-05 08:57:48 +00:00
sct
e840489e57 Merge branch 'develop' 2021-05-05 17:50:27 +09:00
sct
800f4668ec chore: fix gitattributes for images [skip ci] 2021-05-05 17:49:55 +09:00
semantic-release-bot
f2c37babeb chore(release): 1.23.2 2021-04-21 12:28:28 +00:00
sct
24095eac03 Merge branch 'develop' 2021-04-21 21:20:07 +09:00
semantic-release-bot
8c782e0c93 chore(release): 1.23.1 2021-04-16 14:19:53 +00:00
sct
08ce7f24b8 Merge branch 'develop' 2021-04-16 23:11:54 +09:00
semantic-release-bot
67e794aca4 chore(release): 1.23.0 2021-04-16 12:38:05 +00:00
sct
c9c429d027 Merge branch 'develop' 2021-04-16 21:30:30 +09:00
semantic-release-bot
31ac11f4eb chore(release): 1.22.0 2021-04-01 06:33:50 +00:00
sct
f072a4e11d Merge branch 'develop' 2021-04-01 15:27:10 +09:00
semantic-release-bot
b24c23e549 chore(release): 1.21.1 2021-03-15 08:04:26 +00:00
sct
e602e9f758 Merge branch 'develop' 2021-03-15 07:56:33 +00:00
semantic-release-bot
afd46ebee8 chore(release): 1.21.0 2021-03-15 02:00:59 +00:00
sct
be47d1e56c Merge branch 'develop' 2021-03-15 01:52:10 +00:00
semantic-release-bot
0e612f01f1 chore(release): 1.20.1 2021-02-28 04:10:48 +00:00
sct
cd9d9ae8d2 Merge branch 'develop' 2021-02-28 04:03:43 +00:00
semantic-release-bot
ea2765f8c7 chore(release): 1.20.0 2021-02-23 12:33:56 +00:00
sct
448e7c3a7d Merge branch 'develop' 2021-02-23 12:26:58 +00:00
sct
6ab3cd77a7 Merge branch 'develop' 2021-02-23 12:09:08 +00:00
semantic-release-bot
3dfbb0e0a5 chore(release): 1.19.1 2021-02-06 05:14:36 +00:00
TheCatLady
726f62b9b6 fix(ui): Fix webhook URL validation regex (#864) 2021-02-06 14:08:24 +09:00
sct
81c30347f6 Merge branch 'develop' 2021-02-05 14:15:02 +00:00
semantic-release-bot
a397893217 chore(release): 1.19.0 2021-02-05 10:32:05 +00:00
sct
12c5bbd778 Merge branch 'develop' 2021-02-05 10:25:43 +00:00
sct
06261c1118 ci(snapcraft): remove --unshallow argument from fetch in Prepare step 2021-01-30 06:31:34 +00:00
semantic-release-bot
686fe6fdf8 chore(release): 1.18.0 2021-01-30 06:13:16 +00:00
sct
c80a1fa261 Merge branch 'develop' 2021-01-30 06:06:49 +00:00
semantic-release-bot
6167011ee5 chore(release): 1.17.2 2021-01-20 07:27:33 +00:00
sct
bc399d95a0 Merge branch 'develop' 2021-01-20 07:21:48 +00:00
semantic-release-bot
519dd17d37 chore(release): 1.17.1 2021-01-19 10:24:57 +00:00
sct
0761593411 Merge branch 'develop' 2021-01-19 10:18:51 +00:00
semantic-release-bot
caa45415b1 chore(release): 1.17.0 2021-01-19 07:58:37 +00:00
sct
ed297d9d6c Merge branch 'develop' 2021-01-19 07:52:47 +00:00
semantic-release-bot
c6486c3643 chore(release): 1.16.0 2021-01-07 09:58:09 +00:00
sct
f9695068a8 Merge branch 'develop' 2021-01-07 09:53:04 +00:00
semantic-release-bot
fd90adba4d chore(release): 1.15.0 2021-01-04 12:01:25 +00:00
sct
970aa612fe Merge pull request #570 from sct/release/release-20210104
Release
2021-01-04 20:56:34 +09:00
sct
71169944dc Merge branch 'develop' into release/release-20210104 2021-01-04 11:54:45 +00:00
semantic-release-bot
d77f9eb44e chore(release): 1.14.1 2021-01-02 08:57:24 +00:00
sct
22f2037ea6 fix(holiday): remove special holiday slider 2021-01-02 08:33:24 +00:00
semantic-release-bot
2cdf755c20 chore(release): 1.14.0 2020-12-25 11:52:44 +00:00
sct
f99ab47c01 Merge branch 'develop' 2020-12-25 20:47:19 +09:00
semantic-release-bot
7e4175ff21 chore(release): 1.13.0 2020-12-23 12:44:01 +00:00
sct
4157432aa7 Merge branch 'develop' 2020-12-23 21:39:00 +09:00
semantic-release-bot
741b2c4e9f chore(release): 1.12.1 2020-12-22 03:17:58 +00:00
sct
6dbe9ea814 Merge branch 'develop' 2020-12-22 12:12:25 +09:00
semantic-release-bot
96328ab5c6 chore(release): 1.12.0 2020-12-22 02:26:31 +00:00
sct
c31709681a Merge branch 'develop' 2020-12-22 11:21:19 +09:00
semantic-release-bot
0a3d7c5915 chore(release): 1.11.0 2020-12-20 01:18:39 +00:00
sct
912c63e562 Merge branch 'develop' 2020-12-20 10:12:59 +09:00
semantic-release-bot
077513151e chore(release): 1.10.0 2020-12-19 04:54:22 +00:00
sct
191f39e8bd Merge branch 'develop' 2020-12-19 13:49:05 +09:00
semantic-release-bot
048b6c1d90 chore(release): 1.9.1 2020-12-18 16:08:58 +00:00
sct
a6a065ac0b Merge branch 'develop' 2020-12-19 01:03:48 +09:00
semantic-release-bot
abb0b2d763 chore(release): 1.9.0 2020-12-18 12:05:35 +00:00
sct
d2668d3f49 Merge branch 'develop' 2020-12-18 21:00:48 +09:00
semantic-release-bot
bee6eb288f chore(release): 1.8.0 2020-12-17 15:54:40 +00:00
sct
3473aed27e Merge branch 'develop' 2020-12-17 15:49:18 +00:00
semantic-release-bot
a9144a2167 chore(release): 1.7.0 2020-12-17 14:09:58 +00:00
sct
59822d6e42 Merge branch 'develop' 2020-12-17 14:05:24 +00:00
semantic-release-bot
0bd1bee1fa chore(release): 1.6.0 2020-12-16 05:56:57 +00:00
sct
46326f9d16 Merge branch 'develop' 2020-12-16 05:52:26 +00:00
semantic-release-bot
54b96cc451 chore(release): 1.5.0 2020-12-15 09:05:37 +00:00
sct
7d2a187865 Merge branch 'develop' 2020-12-15 09:00:44 +00:00
semantic-release-bot
17df5dd072 chore(release): 1.4.0 2020-12-15 00:53:05 +00:00
sct
4b48a36ad3 Merge branch 'develop' 2020-12-15 00:48:28 +00:00
semantic-release-bot
16bd3e9fd5 chore(release): 1.3.2 2020-12-14 13:11:15 +00:00
sct
6360c82898 Merge branch 'develop' 2020-12-14 13:06:22 +00:00
semantic-release-bot
f1df9eeb2f chore(release): 1.3.1 2020-12-14 08:03:51 +00:00
sct
a3973791fb Merge branch 'develop' 2020-12-14 07:59:18 +00:00
semantic-release-bot
157ab80be1 chore(release): 1.3.0 2020-12-14 05:32:31 +00:00
sct
ac0f95b773 Merge branch 'develop' 2020-12-14 05:27:01 +00:00
semantic-release-bot
45ebfb937e chore(release): 1.2.0 2020-12-11 22:55:12 +00:00
sct
85325cda7f Merge branch 'develop' 2020-12-11 22:50:33 +00:00
semantic-release-bot
5fc02d0d94 chore(release): 1.1.0 2020-12-08 08:21:58 +00:00
sct
16902f85fb Merge branch 'develop' 2020-12-08 08:16:24 +00:00
semantic-release-bot
17b5a0bf9c chore(release): 1.0.0 2020-12-06 01:58:28 +00:00
265 changed files with 7966 additions and 22243 deletions

View File

@@ -737,177 +737,6 @@
"contributions": [
"translation"
]
},
{
"login": "Eclipseop",
"name": "Mackenzie",
"avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4",
"profile": "https://github.com/Eclipseop",
"contributions": [
"code"
]
},
{
"login": "s0up4200",
"name": "soup",
"avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4",
"profile": "https://github.com/s0up4200",
"contributions": [
"doc"
]
},
{
"login": "ceptonit",
"name": "ceptonit",
"avatar_url": "https://avatars.githubusercontent.com/u/12678743?v=4",
"profile": "https://github.com/ceptonit",
"contributions": [
"doc"
]
},
{
"login": "aedelbro",
"name": "aedelbro",
"avatar_url": "https://avatars.githubusercontent.com/u/36162221?v=4",
"profile": "https://github.com/aedelbro",
"contributions": [
"code"
]
},
{
"login": "lunks",
"name": "Pedro Nascimento",
"avatar_url": "https://avatars.githubusercontent.com/u/91118?v=4",
"profile": "http://twitter.com/lunks/",
"contributions": [
"code"
]
},
{
"login": "owenvoke",
"name": "Owen Voke",
"avatar_url": "https://avatars.githubusercontent.com/u/1899334?v=4",
"profile": "https://voke.dev",
"contributions": [
"code"
]
},
{
"login": "Nimelrian",
"name": "Sebastian K",
"avatar_url": "https://avatars.githubusercontent.com/u/8960836?v=4",
"profile": "https://github.com/Nimelrian",
"contributions": [
"code"
]
},
{
"login": "jariz",
"name": "jariz",
"avatar_url": "https://avatars.githubusercontent.com/u/1415847?v=4",
"profile": "https://github.com/jariz",
"contributions": [
"code"
]
},
{
"login": "Alexays",
"name": "Alex",
"avatar_url": "https://avatars.githubusercontent.com/u/13947260?v=4",
"profile": "https://arouillard.fr",
"contributions": [
"code"
]
},
{
"login": "Zebebles",
"name": "Zeb Muller",
"avatar_url": "https://avatars.githubusercontent.com/u/11425451?v=4",
"profile": "https://github.com/Zebebles",
"contributions": [
"code"
]
},
{
"login": "SMores",
"name": "Shane Friedman",
"avatar_url": "https://avatars.githubusercontent.com/u/5354254?v=4",
"profile": "http://smoores.dev",
"contributions": [
"code"
]
},
{
"login": "IzaacJ",
"name": "Izaac Brånn",
"avatar_url": "https://avatars.githubusercontent.com/u/711323?v=4",
"profile": "https://izaacj.me",
"contributions": [
"code"
]
},
{
"login": "SalmanTariq",
"name": "Salman Tariq",
"avatar_url": "https://avatars.githubusercontent.com/u/13284494?v=4",
"profile": "https://github.com/SalmanTariq",
"contributions": [
"code"
]
},
{
"login": "andrew-kennedy",
"name": "Andrew Kennedy",
"avatar_url": "https://avatars.githubusercontent.com/u/2387159?v=4",
"profile": "https://github.com/andrew-kennedy",
"contributions": [
"code"
]
},
{
"login": "Fallenbagel",
"name": "Fallenbagel",
"avatar_url": "https://avatars.githubusercontent.com/u/98979876?v=4",
"profile": "https://github.com/Fallenbagel",
"contributions": [
"code"
]
},
{
"login": "scorp200",
"name": "Anton K. (ai Doge)",
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
"profile": "http://aidoge.xyz",
"contributions": [
"code"
]
},
{
"login": "marcofaggian",
"name": "Marco Faggian",
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
"profile": "https://marcofaggian.com",
"contributions": [
"code"
]
},
{
"login": "nemchik",
"name": "Eric Nemchik",
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
"profile": "http://nemchik.com/",
"contributions": [
"code"
]
},
{
"login": "RemiRigal",
"name": "RemiRigal",
"avatar_url": "https://avatars.githubusercontent.com/u/19256051?v=4",
"profile": "https://github.com/RemiRigal",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -916,7 +745,5 @@
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false,
"commitConvention": "angular",
"commitType": "docs"
"skipCi": false
}

21
.gitattributes vendored
View File

@@ -24,24 +24,3 @@
*.woff binary
*.pyc binary
*.pdf binary
#
## Theses files/directories should be excluded from git archives
#
.husky export-ignore
.vscode export-ignore
docs export-ignore
.git* export-ignore
*ignore export-ignore
*.md export-ignore
.all-contributorsrc export-ignore
.editorconfig export-ignore
Dockerfile.local export-ignore
docker-compose.yml export-ignore
stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore
public/preview.jpg export-ignore

10
.github/CODEOWNERS vendored
View File

@@ -1,10 +1,10 @@
# Global code ownership
* @sct @TheCatLady @danshilm @OwsleyJr
* @sct @TheCatLady @danshilm
# Documentation
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/*.md @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/docs/ @TheCatLady @samwiseg0 @danshilm @OwsleyJr
/.all-contributorsrc @TheCatLady @samwiseg0 @danshilm
/*.md @TheCatLady @samwiseg0 @danshilm
/docs/ @TheCatLady @samwiseg0 @danshilm
# Snap-related files
/.github/workflows/snap.yaml @samwiseg0
@@ -12,4 +12,4 @@
# i18n locale files
/src/i18n/locale/ @sct @TheCatLady
/src/i18n/locale/en.json @sct @TheCatLady @danshilm @OwsleyJr
/src/i18n/locale/en.json @sct @TheCatLady @danshilm

5
.github/holopin.yml vendored
View File

@@ -1,5 +0,0 @@
organization: overseerr
defaultSticker: clcyagj1j329008l468ya8pu2
stickers:
- id: clcyagj1j329008l468ya8pu2
alias: overseerr-contributor

View File

@@ -12,8 +12,8 @@ jobs:
test:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
container: node:18.18-alpine
runs-on: ubuntu-20.04
container: node:16.17-alpine
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -31,7 +31,7 @@ jobs:
build_and_push:
name: Build & Publish Docker Images
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -39,6 +39,13 @@ jobs:
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
@@ -64,12 +71,21 @@ jobs:
sctx/overseerr:${{ github.sha }}
ghcr.io/sct/overseerr:develop
ghcr.io/sct/overseerr:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
discord:
name: Send Discord Notification
needs: build_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-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -78,9 +94,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
echo ::set-output name=status::failure
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -1,41 +0,0 @@
name: 'CodeQL'
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
schedule:
- cron: '50 7 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [javascript]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: '/language:${{ matrix.language }}'

View File

@@ -8,13 +8,13 @@ on:
jobs:
build_and_push:
name: Build & Publish Docker Preview Images
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get the version
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx

View File

@@ -8,7 +8,9 @@ on:
jobs:
semantic-release:
name: Tag and release latest version
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
env:
HUSKY: 0
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -45,7 +47,7 @@ jobs:
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: semantic-release
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
@@ -67,9 +69,9 @@ jobs:
run: |
git fetch --prune --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo "RELEASE=stable" >> $GITHUB_OUTPUT
echo ::set-output name=RELEASE::stable
else
echo "RELEASE=edge" >> $GITHUB_OUTPUT
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -91,9 +93,8 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -101,7 +102,7 @@ jobs:
name: Send Discord Notification
needs: semantic-release
if: always()
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -110,9 +111,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
echo ::set-output name=status::failure
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

View File

@@ -8,7 +8,7 @@ on:
jobs:
jobs:
name: Job Check
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Cancel Previous Runs
@@ -19,7 +19,7 @@ jobs:
build-snap:
name: Build Snap Package (${{ matrix.architecture }})
needs: jobs
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
@@ -35,9 +35,9 @@ jobs:
run: |
git fetch --prune --unshallow --tags
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
echo "RELEASE=stable" >> $GITHUB_OUTPUT
echo ::set-output name=RELEASE::stable
else
echo "RELEASE=edge" >> $GITHUB_OUTPUT
echo ::set-output name=RELEASE::edge
fi
- name: Set Up QEMU
uses: docker/setup-qemu-action@v2
@@ -57,9 +57,8 @@ jobs:
snap: ${{ steps.build.outputs.snap }}
- name: Publish Snap Package
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_LOGIN }}
with:
store_login: ${{ secrets.SNAP_LOGIN }}
snap: ${{ steps.build.outputs.snap }}
release: ${{ steps.prepare.outputs.RELEASE }}
@@ -67,7 +66,7 @@ jobs:
name: Send Discord Notification
needs: build-snap
if: always() && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v3
@@ -76,9 +75,9 @@ jobs:
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
echo ::set-output name=status::failure
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
echo ::set-output name=status::$WORKFLOW_CONCLUSION
fi
- name: Post Status to Discord
uses: sarisia/actions-status-discord@v1

3
.gitignore vendored
View File

@@ -64,6 +64,3 @@ cypress/screenshots
# TS Build Info
tsconfig.tsbuildinfo
# Config Cache Directory
config/cache

View File

@@ -16,8 +16,5 @@
}
],
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"globals.css": "tailwindcss"
}
"typescript.preferences.importModuleSpecifier": "non-relative"
}

1352
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
FROM node:18.18-alpine AS BUILD_IMAGE
FROM node:16.17-alpine AS BUILD_IMAGE
WORKDIR /app
@@ -7,11 +7,10 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
RUN \
case "${TARGETPLATFORM}" in \
'linux/arm64' | 'linux/arm/v7') \
apk update && \
apk add --no-cache python3 make g++ gcc libc6-compat bash && \
yarn global add node-gyp \
;; \
'linux/arm64' | 'linux/arm/v7') \
apk add --no-cache python3 make g++ && \
ln -s /usr/bin/python3 /usr/bin/python \
;; \
esac
COPY package.json yarn.lock ./
@@ -34,7 +33,7 @@ RUN touch config/DOCKER
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:18.18-alpine
FROM node:16.17-alpine
WORKDIR /app

View File

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

238
README.md
View File

@@ -9,9 +9,10 @@
<a href="https://discord.gg/overseerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/sctx/overseerr"><img src="https://img.shields.io/docker/pulls/sctx/overseerr" alt="Docker pulls"></a>
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></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-99-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-80-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@@ -73,137 +74,110 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt="sct"/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt="Alex Zoitos"/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt="Brandon Cohen"/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt="Ahreluth"/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt="KovalevArtem"/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt="GiyomuWeb"/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt="Angry Cuban"/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt="jvennik"/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt="darknessgp"/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt="salty"/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt="Shutruk"/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt="Krystian Charubin"/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt="Kieron Boswell"/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt="samwiseg0"/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt="ecelebi29"/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt="Mārtiņš Možeiko"/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt="mazzetta86"/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt="Paul Hagedorn"/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt="Shagon94"/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt="sebstrgg"/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt="Danshil Mungur"/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt="doob187"/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt="johnpyp"/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt="Jakob Ankarhem"/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt="Jayesh"/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt="flying-sausages"/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt="hirenshah"/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt="TheCatLady"/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt="Chris Pritchard"/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt="Tamberlox"/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt="David"/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt="Douglas Parker"/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt="Daniel Carter"/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt="nuro"/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt="ᗪєνιη ᗷυнʟ"/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt="JonnyWong16"/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt="Roxedus"/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt="WoisWoi"/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt="HubDuck"/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt="costaht"/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt="Shjosan"/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt="kobaubarr"/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt="Ricardo González"/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt="Torkil"/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt="Jagandeep Brar"/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt="dtalens"/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt="Alex Cortelyou"/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt="Jono Cairns"/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt="DJScias"/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt="Dabu-dot"/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt="Jabster28"/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt="littlerooster"/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt="Dustin Hildebrandt"/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt="Bruno Guerreiro"/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt="Alexander Neuhäuser"/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt="Livio"/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt="tangentThought"/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt="Nicolás Espinoza"/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt="sootylunatic"/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt="JoKerIsCraZy"/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt="Daddie0"/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt="Simone"/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt="Seohyun Joo"/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt="Sergey"/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt="Shaaft"/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt="sr093906"/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt="Nackophilz"/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt="Sean Chambers"/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt="deniscerri"/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt="tomgacz"/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt="Andersborrits"/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt="Maxent"/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</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/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt="Chun Yeung Wong"/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt="TheMeanCanEHdian"/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt="Gylesie"/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt="Fhd-pro"/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt="PovilasID"/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
<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/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>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://twitter.com/lunks/"><img src="https://avatars.githubusercontent.com/u/91118?v=4?s=100" width="100px;" alt="Pedro Nascimento"/><br /><sub><b>Pedro Nascimento</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lunks" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://voke.dev"><img src="https://avatars.githubusercontent.com/u/1899334?v=4?s=100" width="100px;" alt="Owen Voke"/><br /><sub><b>Owen Voke</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=owenvoke" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Nimelrian"><img src="https://avatars.githubusercontent.com/u/8960836?v=4?s=100" width="100px;" alt="Sebastian K"/><br /><sub><b>Sebastian K</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Nimelrian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jariz"><img src="https://avatars.githubusercontent.com/u/1415847?v=4?s=100" width="100px;" alt="jariz"/><br /><sub><b>jariz</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jariz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://arouillard.fr"><img src="https://avatars.githubusercontent.com/u/13947260?v=4?s=100" width="100px;" alt="Alex"/><br /><sub><b>Alex</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Alexays" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zebebles"><img src="https://avatars.githubusercontent.com/u/11425451?v=4?s=100" width="100px;" alt="Zeb Muller"/><br /><sub><b>Zeb Muller</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Zebebles" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://smoores.dev"><img src="https://avatars.githubusercontent.com/u/5354254?v=4?s=100" width="100px;" alt="Shane Friedman"/><br /><sub><b>Shane Friedman</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SMores" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RemiRigal"><img src="https://avatars.githubusercontent.com/u/19256051?v=4?s=100" width="100px;" alt="RemiRigal"/><br /><sub><b>RemiRigal</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=RemiRigal" title="Code">💻</a></td>
</tr>
</tbody>
<tr>
<td align="center"><a href="https://sct.dev"><img src="https://avatars1.githubusercontent.com/u/234213?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sct</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sct" title="Code">💻</a> <a href="#design-sct" title="Design">🎨</a> <a href="#ideas-sct" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/azoitos"><img src="https://avatars2.githubusercontent.com/u/26529049?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Zoitos</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=azoitos" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/OwsleyJr"><img src="https://avatars3.githubusercontent.com/u/8635678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Brandon Cohen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=OwsleyJr" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Ahreluth"><img src="https://avatars2.githubusercontent.com/u/75682440?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ahreluth</b></sub></a><br /><a href="#translation-Ahreluth" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/KovalevArtem"><img src="https://avatars0.githubusercontent.com/u/36500228?v=4?s=100" width="100px;" alt=""/><br /><sub><b>KovalevArtem</b></sub></a><br /><a href="#translation-KovalevArtem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/GiyomuWeb"><img src="https://avatars0.githubusercontent.com/u/62489209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>GiyomuWeb</b></sub></a><br /><a href="#translation-GiyomuWeb" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/angrycuban13"><img src="https://avatars3.githubusercontent.com/u/39564898?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Angry Cuban</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=angrycuban13" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4?s=100" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4?s=100" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/mmozeiko"><img src="https://avatars3.githubusercontent.com/u/1665010?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mārtiņš Možeiko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=mmozeiko" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mazzetta86"><img src="https://avatars2.githubusercontent.com/u/45591560?v=4?s=100" width="100px;" alt=""/><br /><sub><b>mazzetta86</b></sub></a><br /><a href="#translation-mazzetta86" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4?s=100" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4?s=100" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a> <a href="#translation-ankarhem" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=flying-sausages" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
<td align="center"><a href="https://www.douglas-parker.com"><img src="https://avatars.githubusercontent.com/u/18235822?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Douglas Parker</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=douglasparker" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dancarter"><img src="https://avatars.githubusercontent.com/u/4387516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Carter</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dancarter" title="Code">💻</a></td>
<td align="center"><a href="https://nuro.dev"><img src="https://avatars.githubusercontent.com/u/4991309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>nuro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=NuroDev" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/onedr0p"><img src="https://avatars.githubusercontent.com/u/213795?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ᗪєνιη ᗷυнʟ</b></sub></a><br /><a href="#infra-onedr0p" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
<td align="center"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
<td align="center"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
<td align="center"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/acortelyou"><img src="https://avatars.githubusercontent.com/u/1689668?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Cortelyou</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=acortelyou" title="Code">💻</a></td>
<td align="center"><a href="https://nz.linkedin.com/in/jonocairns"><img src="https://avatars.githubusercontent.com/u/182836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jono Cairns</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jonocairns" title="Code">💻</a></td>
<td align="center"><a href="https://scias.net/"><img src="https://avatars.githubusercontent.com/u/439655?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DJScias</b></sub></a><br /><a href="#translation-DJScias" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Dabu-dot"><img src="https://avatars.githubusercontent.com/u/52525576?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dabu-dot</b></sub></a><br /><a href="#translation-Dabu-dot" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Jabster28"><img src="https://avatars.githubusercontent.com/u/29015942?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jabster28</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Jabster28" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/littlerooster"><img src="https://avatars.githubusercontent.com/u/83890654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>littlerooster</b></sub></a><br /><a href="#translation-littlerooster" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/dphildebrandt"><img src="https://avatars.githubusercontent.com/u/154459?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dustin Hildebrandt</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=dphildebrandt" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Generator"><img src="https://avatars.githubusercontent.com/u/44146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bruno Guerreiro</b></sub></a><br /><a href="#translation-Generator" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/iceHtwoO"><img src="https://avatars.githubusercontent.com/u/27020492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexander Neuhäuser</b></sub></a><br /><a href="#translation-iceHtwoO" title="Translation">🌍</a></td>
<td align="center"><a href="http://www.unext.co.jp"><img src="https://avatars.githubusercontent.com/u/37431541?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Livio</b></sub></a><br /><a href="#design-liviokanone" title="Design">🎨</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/tangentThought"><img src="https://avatars.githubusercontent.com/u/25516090?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tangentThought</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=tangentThought" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/nicospz"><img src="https://avatars.githubusercontent.com/u/31373060?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nicolás Espinoza</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nicospz" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/sootylunatic"><img src="https://avatars.githubusercontent.com/u/36486087?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sootylunatic</b></sub></a><br /><a href="#translation-sootylunatic" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/JoKerIsCraZy"><img src="https://avatars.githubusercontent.com/u/47474211?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JoKerIsCraZy</b></sub></a><br /><a href="#translation-JoKerIsCraZy" title="Translation">🌍</a></td>
<td align="center"><a href="https://daddie.dev"><img src="https://avatars.githubusercontent.com/u/33762262?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daddie0</b></sub></a><br /><a href="#translation-GoByeBye" title="Translation">🌍</a></td>
<td align="center"><a href="http://ungaro.me"><img src="https://avatars.githubusercontent.com/u/43807696?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simone</b></sub></a><br /><a href="#translation-Simoneu01" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/adan89lion"><img src="https://avatars.githubusercontent.com/u/6585644?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Seohyun Joo</b></sub></a><br /><a href="#translation-adan89lion" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/ty4ko"><img src="https://avatars.githubusercontent.com/u/21213535?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sergey</b></sub></a><br /><a href="#translation-ty4ko" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/skafte1990"><img src="https://avatars.githubusercontent.com/u/31465453?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shaaft</b></sub></a><br /><a href="#translation-skafte1990" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sr093906"><img src="https://avatars.githubusercontent.com/u/8369201?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sr093906</b></sub></a><br /><a href="#translation-sr093906" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Nackophilz"><img src="https://avatars.githubusercontent.com/u/61667226?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nackophilz</b></sub></a><br /><a href="#translation-Nackophilz" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/schambers"><img src="https://avatars.githubusercontent.com/u/31563?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sean Chambers</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=schambers" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/deniscerri"><img src="https://avatars.githubusercontent.com/u/64997243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>deniscerri</b></sub></a><br /><a href="#translation-deniscerri" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/tomgacz"><img src="https://avatars.githubusercontent.com/u/14138209?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tomgacz</b></sub></a><br /><a href="#translation-tomgacz" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Andersborrits"><img src="https://avatars.githubusercontent.com/u/29452218?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andersborrits</b></sub></a><br /><a href="#translation-Andersborrits" title="Translation">🌍</a></td>
<td align="center"><a href="http://maxentrouault.fr"><img src="https://avatars.githubusercontent.com/u/67283154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maxent</b></sub></a><br /><a href="#translation-Maxentr" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=sambartik" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/frank-cywong"><img src="https://avatars.githubusercontent.com/u/90653148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chun Yeung Wong</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=frank-cywong" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/TheMeanCanEHdian"><img src="https://avatars.githubusercontent.com/u/16025103?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheMeanCanEHdian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheMeanCanEHdian" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Gylesie"><img src="https://avatars.githubusercontent.com/u/86306812?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gylesie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Gylesie" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Fhd-pro"><img src="https://avatars.githubusercontent.com/u/82862079?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Fhd-pro</b></sub></a><br /><a href="#translation-Fhd-pro" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/PovilasID"><img src="https://avatars.githubusercontent.com/u/396243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>PovilasID</b></sub></a><br /><a href="#translation-PovilasID" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt=""/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt=""/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
</tr>
</table>
<!-- markdownlint-restore -->

View File

@@ -3,147 +3,147 @@
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
"main": {
"apiKey": "testkey",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
"movie": {},
"tv": {}
},
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"originalLanguage": "",
"trustProxy": false,
"partialRequestsEnabled": true,
"locale": "en"
"apiKey": "testkey",
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {
"movie": {},
"tv": {}
},
"hideAvailable": false,
"localLogin": true,
"newPlexLogin": true,
"region": "",
"originalLanguage": "",
"trustProxy": false,
"partialRequestsEnabled": true,
"locale": "en"
},
"plex": {
"name": "Seerr",
"ip": "192.168.1.1",
"port": 32400,
"useSsl": false,
"libraries": [
{
"id": "1",
"name": "Movies",
"enabled": true,
"type": "movie"
}
],
"machineId": "test"
"name": "Seerr",
"ip": "192.168.1.1",
"port": 32400,
"useSsl": false,
"libraries": [
{
"id": "1",
"name": "Movies",
"enabled": true,
"type": "movie"
}
],
"machineId": "test"
},
"tautulli": {},
"radarr": [],
"sonarr": [],
"public": {
"initialized": true
"initialized": true
},
"notifications": {
"agents": {
"email": {
"enabled": false,
"options": {
"emailFrom": "",
"smtpHost": "",
"smtpPort": 587,
"secure": false,
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Overseerr"
}
},
"discord": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"telegram": {
"enabled": false,
"types": 0,
"options": {
"botAPI": "",
"chatId": "",
"sendSilently": false
}
},
"pushbullet": {
"enabled": false,
"types": 0,
"options": {
"accessToken": ""
}
},
"pushover": {
"enabled": false,
"types": 0,
"options": {
"accessToken": "",
"userToken": ""
}
},
"webhook": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
}
},
"webpush": {
"enabled": false,
"options": {}
},
"gotify": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"token": ""
}
}
"agents": {
"email": {
"enabled": false,
"options": {
"emailFrom": "",
"smtpHost": "",
"smtpPort": 587,
"secure": false,
"ignoreTls": false,
"requireTls": false,
"allowSelfSigned": false,
"senderName": "Overseerr"
}
},
"discord": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"enableMentions": true
}
},
"lunasea": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"slack": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": ""
}
},
"telegram": {
"enabled": false,
"types": 0,
"options": {
"botAPI": "",
"chatId": "",
"sendSilently": false
}
},
"pushbullet": {
"enabled": false,
"types": 0,
"options": {
"accessToken": ""
}
},
"pushover": {
"enabled": false,
"types": 0,
"options": {
"accessToken": "",
"userToken": ""
}
},
"webhook": {
"enabled": false,
"types": 0,
"options": {
"webhookUrl": "",
"jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i"
}
},
"webpush": {
"enabled": false,
"options": {}
},
"gotify": {
"enabled": false,
"types": 0,
"options": {
"url": "",
"token": ""
}
}
}
},
"jobs": {
"plex-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"plex-full-scan": {
"schedule": "0 0 3 * * *"
},
"radarr-scan": {
"schedule": "0 0 4 * * *"
},
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
}
"plex-recently-added-scan": {
"schedule": "0 */5 * * * *"
},
"plex-full-scan": {
"schedule": "0 0 3 * * *"
},
"radarr-scan": {
"schedule": "0 0 4 * * *"
},
"sonarr-scan": {
"schedule": "0 30 4 * * *"
},
"download-sync": {
"schedule": "0 * * * * *"
},
"download-sync-reset": {
"schedule": "0 0 1 * * *"
}
}
}
}

View File

@@ -36,9 +36,7 @@ describe('Discover', () => {
});
it('loads upcoming movies', () => {
cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as(
'getUpcomingMovies'
);
cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies');
cy.visit('/');
cy.wait('@getUpcomingMovies');
clickFirstTitleCardInSlider('Upcoming Movies');
@@ -52,9 +50,7 @@ describe('Discover', () => {
});
it('loads upcoming series', () => {
cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as(
'getUpcomingSeries'
);
cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries');
cy.visit('/');
cy.wait('@getUpcomingSeries');
clickFirstTitleCardInSlider('Upcoming Series');

View File

@@ -13,7 +13,7 @@ describe('Pull To Refresh', () => {
url: '/api/v1/*',
}).as('apiCall');
cy.get('.searchbar').swipe('bottom', [190, 500]);
cy.get('.searchbar').swipe('bottom', [190, 400]);
cy.wait('@apiCall').then((interception) => {
assert.isNotNull(

View File

@@ -1,163 +0,0 @@
describe('Discover Customization', () => {
beforeEach(() => {
cy.loginAsAdmin();
cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders');
});
it('show the discover customization settings', () => {
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=create-slider-header')
.should('contain', 'Create New Slider')
.scrollIntoView();
// There should be some built in options
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recently Added'
);
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recent Requests'
);
});
it('can drag to re-order elements and save to persist the changes', () => {
let dataTransfer = new DataTransfer();
cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
dataTransfer = new DataTransfer();
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recent Requests');
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
});
it('can create a new discover option and remove it', () => {
cy.visit('/');
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
cy.get('[data-testid=discover-start-editing]').click();
const sliderTitle = 'Custom Keyword Slider';
cy.get('#sliderType').select('TMDB Movie Keyword');
cy.get('#title').type(sliderTitle);
// First confirm that an invalid keyword doesn't allow us to submit anything
cy.get('#data').type('invalidkeyword{enter}', { delay: 100 });
cy.wait('@searchKeyword');
cy.get('[data-testid=create-discover-option-form]')
.find('button')
.should('be.disabled');
cy.get('#data').clear();
cy.get('#data').type('christmas{enter}', { delay: 100 });
// Confirming we have some results
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=create-discover-option-form]').submit();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Make sure its still there even if we reload
cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Verify it's not rendering on our discover page (its still disabled!)
cy.visit('/');
cy.get('.slider-header').should('not.contain', sliderTitle);
cy.get('[data-testid=discover-start-editing]').click();
// Enable it, and check again
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[role="checkbox"]')
.click();
cy.get('[data-testid=discover-customize-submit').click();
cy.wait('@getDiscoverSliders');
cy.visit('/');
cy.contains('.slider-header', sliderTitle)
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
cy.get('[data-testid=discover-start-editing]').click();
// let's delete it and confirm its deleted.
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[data-testid=discover-slider-remove-button]')
.click();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('not.contain', sliderTitle);
});
});

View File

@@ -16,7 +16,7 @@ describe('General Settings', () => {
cy.visit('/settings');
cy.get('#trustProxy').click();
cy.get('[data-testid=settings-main-form]').submit();
cy.get('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('form').submit();
cy.get('[data-testid=modal-title]').should('not.exist');
});
});

View File

@@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you
failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"<HOST>"
```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

View File

@@ -138,7 +138,6 @@ location ^~ /overseerr {
sub_filter 'href="/"' 'href="/$app"';
sub_filter 'href="/login"' 'href="/$app/login"';
sub_filter 'href:"/"' 'href:"/$app"';
sub_filter '\/_next' '\/$app\/_next';
sub_filter '/_next' '/$app/_next';
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';

View File

@@ -28,7 +28,6 @@ docker run -d \
--name overseerr \
-e LOG_LEVEL=debug \
-e TZ=Asia/Tokyo \
-e PORT=5055 `#optional` \
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
@@ -82,7 +81,6 @@ services:
environment:
- LOG_LEVEL=debug
- TZ=Asia/Tokyo
- PORT=5055 #optional
ports:
- 5055:5055
volumes:
@@ -90,7 +88,7 @@ services:
restart: unless-stopped
```
Then, start all services defined in the Compose file:
Then, start all services defined in the your Compose file:
```bash
docker-compose up -d
@@ -148,6 +146,8 @@ Then, create and start the Overseerr container:
docker run -d --name overseerr -e LOG_LEVEL=debug -e TZ=Asia/Tokyo -p 5055:5055 -v "overseerr-data:/app/config" --restart unless-stopped sctx/overseerr:latest
```
If using a named volume like above, you can safely ignore the warning about the `/app/config` folder being incorrectly mounted on the setup page.
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\overseerr-data\_data` using File Explorer.
{% hint style="info" %}
@@ -155,7 +155,7 @@ Docker on Windows works differently than it does on Linux; it runs Docker inside
**If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.)
Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored.
Named volumes, like in the example commands above, are automatically mounted inside the VM.
{% endhint %}
## Linux

View File

@@ -40,14 +40,6 @@ If you enable this setting and find yourself unable to access Overseerr, you can
This setting is **disabled** by default.
### Enable Image Caching
When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space.
Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours.
You should enable this if you are having issues with loading images directly from TMDB in your browser.
### Display Language
Set the default display language for Overseerr. Users can override this setting in their user settings.

View File

@@ -19,6 +19,5 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 256000,
},
};

View File

@@ -26,8 +26,6 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: other
description: Endpoints related to other TMDB data
- name: person
description: Endpoints related to retrieving person details.
- name: media
@@ -602,17 +600,6 @@ components:
name:
type: string
example: Adventure
Company:
type: object
properties:
id:
type: number
example: 1
logo_path:
type: string
nullable: true
name:
type: string
ProductionCompany:
type: object
properties:
@@ -1052,8 +1039,6 @@ components:
nullable: true
status:
type: number
example: 0
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
requests:
type: array
readOnly: true
@@ -1273,8 +1258,6 @@ components:
type: string
userToken:
type: string
sound:
type: string
GotifySettings:
type: object
properties:
@@ -1710,9 +1693,6 @@ components:
pushoverUserKey:
type: string
nullable: true
pushoverSound:
type: string
nullable: true
telegramEnabled:
type: boolean
telegramBotUsername:
@@ -1800,40 +1780,6 @@ components:
message:
type: string
example: A comment
DiscoverSlider:
type: object
properties:
id:
type: number
example: 1
type:
type: number
example: 1
title:
type: string
nullable: true
isBuiltIn:
type: boolean
enabled:
type: boolean
data:
type: string
example: '1234'
nullable: true
required:
- type
- enabled
- title
- data
WatchProviderRegion:
type: object
properties:
iso_3166_1:
type: string
english_name:
type: string
native_name:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -2529,44 +2475,29 @@ paths:
content:
application/json:
schema:
type: object
properties:
imageCache:
type: object
properties:
tmdb:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:
type: array
items:
type: object
properties:
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
id:
type: string
example: cache-id
name:
type: string
example: cache name
stats:
type: object
properties:
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
hits:
type: number
misses:
type: number
keys:
type: number
ksize:
type: number
vsize:
type: number
/settings/cache/{cacheId}/flush:
post:
summary: Flush a specific cache
@@ -2866,33 +2797,6 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/pushover/sounds:
get:
summary: Get Pushover sounds
description: Returns valid Pushover sound options in a JSON array.
tags:
- settings
parameters:
- in: query
name: token
required: true
schema:
type: string
nullable: false
responses:
'200':
description: Returned Pushover settings
content:
application/json:
schema:
type: array
items:
type: object
properties:
name:
type: string
description:
type: string
/settings/notifications/gotify:
get:
summary: Get Gotify notification settings
@@ -3123,133 +3027,6 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/discover:
get:
summary: Get all discover sliders
description: Returns all discovery sliders. Built-in and custom made.
tags:
- settings
responses:
'200':
description: Returned all discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
post:
summary: Batch update all sliders.
description: |
Batch update all sliders at once. Should also be used for creation. Will only update sliders provided
and will not delete any sliders not present in the request. If a slider is missing a required field,
it will be ignored. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
responses:
'200':
description: Returned all newly updated discovery sliders
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/{sliderId}:
put:
summary: Update a single slider
description: |
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'Slider Title'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
delete:
summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
tags:
- settings
parameters:
- in: path
name: sliderId
required: true
schema:
type: number
responses:
'200':
description: Slider successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/add:
post:
summary: Add a new slider
description: |
Add a single slider and return the newly created slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'New Slider'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
/settings/discover/reset:
get:
summary: Reset all discover sliders
description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission.
tags:
- settings
responses:
'204':
description: All sliders reset to defaults
/settings/about:
get:
summary: Get server stats
@@ -3647,7 +3424,7 @@ paths:
$ref: '#/components/schemas/User'
/user/{userId}/requests:
get:
summary: Get requests for a specific user
summary: Get user by ID
description: |
Retrieves a user's requests in a JSON object.
tags:
@@ -3743,7 +3520,7 @@ paths:
example: false
/user/{userId}/watchlist:
get:
summary: Get the Plex watchlist for a specific user
summary: Get user by ID
description: |
Retrieves a user's Plex Watchlist in a JSON object.
tags:
@@ -4070,86 +3847,6 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
/search/keyword:
get:
summary: Search for keywords
description: Returns a list of TMDB keywords matching the search query
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'christmas'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Keyword'
/search/company:
get:
summary: Search for companies
description: Returns a list of TMDB companies matching the search query. (Will not return origin country)
tags:
- search
parameters:
- in: query
name: query
required: true
schema:
type: string
example: 'Disney'
- in: query
name: page
schema:
type: number
example: 1
default: 1
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
$ref: '#/components/schemas/Company'
/discover/movies:
get:
summary: Discover movies
@@ -4171,73 +3868,13 @@ paths:
- in: query
name: genre
schema:
type: string
type: number
example: 18
- in: query
name: studio
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: primaryReleaseDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: primaryReleaseDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4460,73 +4097,13 @@ paths:
- in: query
name: genre
schema:
type: string
type: number
example: 18
- in: query
name: network
schema:
type: number
example: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
- in: query
name: sortBy
schema:
type: string
example: popularity.desc
- in: query
name: firstAirDateGte
schema:
type: string
example: 2022-01-01
- in: query
name: firstAirDateLte
schema:
type: string
example: 2023-01-01
- in: query
name: withRuntimeGte
schema:
type: number
example: 60
- in: query
name: withRuntimeLte
schema:
type: number
example: 120
- in: query
name: voteAverageGte
schema:
type: number
example: 7
- in: query
name: voteAverageLte
schema:
type: number
example: 10
- in: query
name: voteCountGte
schema:
type: number
example: 7
- in: query
name: voteCountLte
schema:
type: number
example: 10
- in: query
name: watchRegion
schema:
type: string
example: US
- in: query
name: watchProviders
schema:
type: string
example: 8|9
responses:
'200':
description: Results
@@ -4955,7 +4532,6 @@ paths:
processing,
unavailable,
failed,
deleted,
]
- in: query
name: sort
@@ -5009,13 +4585,9 @@ paths:
type: number
example: 123
seasons:
oneOf:
- type: array
items:
type: number
minimum: 1
- type: string
enum: [all]
type: array
items:
type: number
is4k:
type: boolean
example: false
@@ -5094,7 +4666,7 @@ paths:
$ref: '#/components/schemas/MediaRequest'
put:
summary: Update MediaRequest
description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission.
description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission.
tags:
- request
parameters:
@@ -5105,37 +4677,6 @@ paths:
example: '1'
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
mediaType:
type: string
enum: [movie, tv]
seasons:
type: array
items:
type: number
minimum: 1
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
languageProfileId:
type: number
userId:
type: number
nullable: true
required:
- mediaType
responses:
'200':
description: Succesfully updated request
@@ -5206,7 +4747,7 @@ paths:
required: true
schema:
type: string
enum: [approve, decline]
enum: [pending, approve, decline, available]
responses:
'200':
description: Request status changed
@@ -5371,63 +4912,6 @@ paths:
audienceRating:
type: string
enum: ['Spilled', 'Upright']
/movie/{movieId}/ratingscombined:
get:
summary: Get RT and IMDB movie ratings combined
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
tags:
- movies
parameters:
- in: path
name: movieId
required: true
schema:
type: number
example: 337401
responses:
'200':
description: Ratings returned
content:
application/json:
schema:
type: object
properties:
rt:
type: object
properties:
title:
type: string
example: Mulan
year:
type: number
example: 2020
url:
type: string
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
criticsScore:
type: number
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
audienceScore:
type: number
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
imdb:
type: object
properties:
title:
type: string
example: I am Legend
url:
type: string
example: 'https://www.imdb.com/title/tt0480249'
criticsScore:
type: number
example: 6.5
/tv/{tvId}:
get:
summary: Get TV details
@@ -5695,16 +5179,7 @@ paths:
schema:
type: string
nullable: true
enum:
[
all,
available,
partial,
allavailable,
processing,
pending,
deleted,
]
enum: [all, available, partial, allavailable, processing, pending]
- in: query
name: sort
schema:
@@ -5763,7 +5238,7 @@ paths:
example: available
schema:
type: string
enum: [available, partial, processing, pending, unknown, deleted]
enum: [available, partial, processing, pending, unknown]
requestBody:
content:
application/json:
@@ -6392,89 +5867,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Issue'
/keyword/{keywordId}:
get:
summary: Get keyword
description: |
Returns a single keyword in JSON format.
tags:
- other
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 1
responses:
'200':
description: Keyword returned
content:
application/json:
schema:
$ref: '#/components/schemas/Keyword'
/watchproviders/regions:
get:
summary: Get watch provider regions
description: |
Returns a list of all available watch provider regions.
tags:
- other
responses:
'200':
description: Watch provider regions returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderRegion'
/watchproviders/movies:
get:
summary: Get watch provider movies
description: |
Returns a list of all available watch providers for movies.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for movies returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
/watchproviders/tv:
get:
summary: Get watch provider series
description: |
Returns a list of all available watch providers for series.
tags:
- other
parameters:
- in: query
name: watchRegion
required: true
schema:
type: string
example: US
responses:
'200':
description: Watch providers for series returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/WatchProviderDetails'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -1,6 +1,6 @@
{
"name": "overseerr",
"version": "0.1.0",
"version": "1.30.0",
"private": true,
"scripts": {
"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",
@@ -29,147 +29,144 @@
},
"license": "MIT",
"dependencies": {
"@formatjs/intl-displaynames": "6.2.6",
"@formatjs/intl-locale": "3.1.1",
"@formatjs/intl-pluralrules": "5.1.10",
"@formatjs/intl-displaynames": "6.0.3",
"@formatjs/intl-locale": "3.0.3",
"@formatjs/intl-pluralrules": "5.0.3",
"@formatjs/intl-utils": "3.8.4",
"@headlessui/react": "1.7.12",
"@heroicons/react": "2.0.16",
"@headlessui/react": "0.0.0-insiders.b301f04",
"@heroicons/react": "1.0.6",
"@supercharge/request-ip": "1.2.0",
"@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2",
"axios": "1.3.4",
"@svgr/webpack": "6.3.1",
"@tanem/react-nprogress": "5.0.11",
"ace-builds": "1.9.6",
"axios": "0.27.2",
"axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0",
"bcrypt": "5.0.1",
"bowser": "2.11.0",
"connect-typeorm": "1.1.4",
"cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3",
"copy-to-clipboard": "3.3.2",
"country-flag-icons": "1.5.5",
"cronstrue": "2.23.0",
"cronstrue": "2.11.0",
"csurf": "1.11.0",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"date-fns": "2.29.1",
"email-templates": "9.0.0",
"express": "4.18.2",
"express": "4.18.1",
"express-openapi-validator": "4.13.8",
"express-rate-limit": "6.7.0",
"express-rate-limit": "6.5.1",
"express-session": "1.17.3",
"formik": "2.2.9",
"gravatar-url": "3.1.0",
"intl": "1.2.5",
"lodash": "4.17.21",
"next": "12.3.4",
"next": "12.2.5",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
"node-schedule": "2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"node-gyp": "9.1.0",
"node-schedule": "2.1.0",
"nodemailer": "6.7.8",
"openpgp": "5.4.0",
"plex-api": "5.3.2",
"pug": "3.0.2",
"pulltorefreshjs": "0.1.22",
"react": "18.2.0",
"react-ace": "10.1.0",
"react-animate-height": "2.1.2",
"react-aria": "3.23.0",
"react-dom": "18.2.0",
"react-intersection-observer": "9.4.3",
"react-intl": "6.2.10",
"react-markdown": "8.0.5",
"react-intersection-observer": "9.4.0",
"react-intl": "6.0.5",
"react-markdown": "8.0.3",
"react-popper-tooltip": "4.4.2",
"react-select": "5.7.0",
"react-spring": "9.7.1",
"react-tailwindcss-datepicker-sct": "1.3.4",
"react-select": "5.4.0",
"react-spring": "9.5.2",
"react-toast-notifications": "2.5.1",
"react-truncate-markup": "5.1.2",
"react-use-clipboard": "1.0.9",
"react-use-clipboard": "1.0.8",
"reflect-metadata": "0.1.13",
"secure-random-password": "0.2.3",
"semver": "7.3.8",
"sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2",
"swr": "2.0.4",
"typeorm": "0.3.12",
"semver": "7.3.7",
"sqlite3": "5.0.11",
"swagger-ui-express": "4.5.0",
"swr": "1.3.0",
"typeorm": "0.3.7",
"web-push": "3.5.0",
"winston": "3.8.2",
"winston": "3.8.1",
"winston-daily-rotate-file": "4.7.1",
"xml2js": "0.4.23",
"yamljs": "0.3.0",
"yup": "0.32.11",
"zod": "3.20.6"
"yup": "0.32.11"
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2",
"@babel/cli": "7.18.10",
"@commitlint/cli": "17.0.3",
"@commitlint/config-conventional": "17.0.3",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/commit-analyzer": "9.0.2",
"@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/aspect-ratio": "0.4.0",
"@tailwindcss/forms": "0.5.2",
"@tailwindcss/typography": "0.5.4",
"@types/bcrypt": "5.0.0",
"@types/cookie-parser": "1.4.3",
"@types/country-flag-icons": "1.2.0",
"@types/csurf": "1.11.2",
"@types/email-templates": "8.0.4",
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/express": "4.17.13",
"@types/express-session": "1.17.4",
"@types/lodash": "4.14.183",
"@types/node": "17.0.36",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/nodemailer": "6.4.5",
"@types/pulltorefreshjs": "0.1.5",
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6",
"@types/react-transition-group": "4.4.5",
"@types/secure-random-password": "0.2.1",
"@types/semver": "7.3.13",
"@types/semver": "7.3.12",
"@types/swagger-ui-express": "4.1.3",
"@types/web-push": "3.3.2",
"@types/xml2js": "0.4.11",
"@types/yamljs": "0.2.31",
"@types/yup": "0.29.14",
"@typescript-eslint/eslint-plugin": "5.54.0",
"@typescript-eslint/parser": "5.54.0",
"autoprefixer": "10.4.13",
"@typescript-eslint/eslint-plugin": "5.33.1",
"@typescript-eslint/parser": "5.33.1",
"autoprefixer": "10.4.8",
"babel-plugin-react-intl": "8.2.25",
"babel-plugin-react-intl-auto": "3.3.0",
"commitizen": "4.3.0",
"commitizen": "4.2.5",
"copyfiles": "2.4.1",
"cy-mobile-commands": "0.3.0",
"cypress": "12.7.0",
"cypress": "10.6.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.35.0",
"eslint-config-next": "12.3.4",
"eslint-config-prettier": "8.6.0",
"eslint-plugin-formatjs": "4.9.0",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-no-relative-import-paths": "1.5.2",
"eslint": "8.22.0",
"eslint-config-next": "12.2.5",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-formatjs": "4.1.0",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-no-relative-import-paths": "1.4.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react": "7.30.1",
"eslint-plugin-react-hooks": "4.6.0",
"extract-react-intl-messages": "4.1.1",
"husky": "8.0.3",
"lint-staged": "13.1.2",
"nodemon": "2.0.20",
"postcss": "8.4.21",
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "19.0.5",
"husky": "8.0.1",
"lint-staged": "12.4.3",
"nodemon": "2.0.19",
"postcss": "8.4.16",
"prettier": "2.7.1",
"prettier-plugin-organize-imports": "3.1.0",
"prettier-plugin-tailwindcss": "0.1.13",
"semantic-release": "19.0.3",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.7",
"tailwindcss": "3.1.8",
"ts-node": "10.9.1",
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.1.2",
"typescript": "4.9.5"
"tsc-alias": "1.7.0",
"tsconfig-paths": "4.1.0",
"typescript": "4.7.4"
},
"resolutions": {
"sqlite3/node-gyp": "8.4.1",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/express-session": "1.17.6"
"@types/react": "18.0.17",
"@types/react-dom": "18.0.6"
},
"config": {
"commitizen": {
@@ -227,7 +224,7 @@
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GIT_SHA"
"COMMIT_TAG": "$GITHUB_SHA"
},
"imageNames": [
"sctx/overseerr",

View File

@@ -17,7 +17,7 @@
}
h1 {
color: #6366f1;
color: #6366F1;
}
p {
@@ -37,7 +37,7 @@
<!-- Inline the page's JavaScript file. -->
<script>
// Manual reload feature.
document.querySelector('button').addEventListener('click', () => {
document.querySelector("button").addEventListener("click", () => {
window.location.reload();
});

View File

@@ -4,30 +4,30 @@
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 3;
const CACHE_NAME = 'offline';
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = '/offline.html';
const OFFLINE_URL = "/offline.html";
self.addEventListener('install', (event) => {
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the
// response isn't fulfilled from the HTTP cache; i.e., it will be from
// the network.
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
})()
);
// Force the waiting service worker to become the active service worker.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Enable navigation preload if it's supported.
// See https://developers.google.com/web/updates/2017/02/navigation-preload
if ('navigationPreload' in self.registration) {
if ("navigationPreload" in self.registration) {
await self.registration.navigationPreload.enable();
}
})()
@@ -37,10 +37,10 @@ self.addEventListener('activate', (event) => {
clients.claim();
});
self.addEventListener('fetch', (event) => {
self.addEventListener("fetch", (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
if (event.request.mode === 'navigate') {
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
try {
@@ -59,7 +59,7 @@ self.addEventListener('fetch', (event) => {
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
// eslint-disable-next-line no-console
console.log('Fetch failed; returning offline page instead.', error);
console.log("Fetch failed; returning offline page instead.", error);
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
@@ -85,13 +85,15 @@ self.addEventListener('push', (event) => {
requestId: payload.requestId,
},
actions: [],
};
}
if (payload.actionUrl) {
options.actions.push({
action: 'view',
title: payload.actionUrlTitle ?? 'View',
});
if (payload.actionUrl){
options.actions.push(
{
action: 'view',
title: payload.actionUrlTitle ?? 'View',
}
);
}
if (payload.notificationType === 'MEDIA_PENDING') {
@@ -107,29 +109,27 @@ self.addEventListener('push', (event) => {
);
}
event.waitUntil(self.registration.showNotification(payload.subject, options));
event.waitUntil(
self.registration.showNotification(payload.subject, options)
);
});
self.addEventListener(
'notificationclick',
(event) => {
const notificationData = event.notification.data;
self.addEventListener('notificationclick', (event) => {
const notificationData = event.notification.data;
event.notification.close();
event.notification.close();
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
},
false
);
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
}, false);

View File

@@ -69,30 +69,6 @@ class ExternalAPI {
return response.data;
}
protected async post<T>(
endpoint: string,
data: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,

View File

@@ -226,13 +226,12 @@ class PlexAPI {
id: string,
options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60,
},
mediaType: 'movie' | 'show'
}
): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
options.addedAt / 1000
)}`,
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,

View File

@@ -82,6 +82,21 @@ interface ServerResponse {
};
}
interface FriendResponse {
MediaContainer: {
User: {
$: {
id: string;
title: string;
username: string;
email: string;
thumb: string;
};
Server?: ServerResponse[];
}[];
};
}
interface UsersResponse {
MediaContainer: {
User: {
@@ -219,6 +234,19 @@ class PlexTvAPI extends ExternalAPI {
}
}
public async getFriends(): Promise<FriendResponse> {
const response = await this.axios.get('/pms/friends/all', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as FriendResponse;
return parsedXml;
}
public async checkUserAccess(userId: number): Promise<boolean> {
const settings = getSettings();
@@ -227,9 +255,9 @@ class PlexTvAPI extends ExternalAPI {
throw new Error('Plex is not configured!');
}
const usersResponse = await this.getUsers();
const friends = await this.getFriends();
const users = usersResponse.MediaContainer.User;
const users = friends.MediaContainer.User;
const user = users.find((u) => parseInt(u.$.id) === userId);

View File

@@ -1,56 +0,0 @@
import ExternalAPI from './externalapi';
interface PushoverSoundsResponse {
sounds: {
[name: string]: string;
};
status: number;
request: string;
}
export interface PushoverSound {
name: string;
description: string;
}
export const mapSounds = (sounds: {
[name: string]: string;
}): PushoverSound[] =>
Object.entries(sounds).map(
([name, description]) =>
({
name,
description,
} as PushoverSound)
);
class PushoverAPI extends ExternalAPI {
constructor() {
super(
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
}
public async getSounds(appToken: string): Promise<PushoverSound[]> {
try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: {
token: appToken,
},
});
return mapSounds(data.sounds);
} catch (e) {
throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`);
}
}
}
export default PushoverAPI;

View File

@@ -1,195 +0,0 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
type IMDBRadarrProxyResponse = IMDBMovie[];
interface IMDBMovie {
ImdbId: string;
Overview: string;
Title: string;
OriginalTitle: string;
TitleSlug: string;
Ratings: Rating[];
MovieRatings: MovieRatings;
Runtime: number;
Images: Image[];
Genres: string[];
Popularity: number;
Premier: string;
InCinema: string;
PhysicalRelease: any;
DigitalRelease: string;
Year: number;
AlternativeTitles: AlternativeTitle[];
Translations: Translation[];
Recommendations: Recommendation[];
Credits: Credits;
Studio: string;
YoutubeTrailerId: string;
Certifications: Certification[];
Status: any;
Collection: Collection;
OriginalLanguage: string;
Homepage: string;
TmdbId: number;
}
interface Rating {
Count: number;
Value: number;
Origin: string;
Type: string;
}
interface MovieRatings {
Tmdb: Tmdb;
Imdb: Imdb;
Metacritic: Metacritic;
RottenTomatoes: RottenTomatoes;
}
interface Tmdb {
Count: number;
Value: number;
Type: string;
}
interface Imdb {
Count: number;
Value: number;
Type: string;
}
interface Metacritic {
Count: number;
Value: number;
Type: string;
}
interface RottenTomatoes {
Count: number;
Value: number;
Type: string;
}
interface Image {
CoverType: string;
Url: string;
}
interface AlternativeTitle {
Title: string;
Type: string;
Language: string;
}
interface Translation {
Title: string;
Overview: string;
Language: string;
}
interface Recommendation {
TmdbId: number;
Title: string;
}
interface Credits {
Cast: Cast[];
Crew: Crew[];
}
interface Cast {
Name: string;
Order: number;
Character: string;
TmdbId: number;
CreditId: string;
Images: Image2[];
}
interface Image2 {
CoverType: string;
Url: string;
}
interface Crew {
Name: string;
Job: string;
Department: string;
TmdbId: number;
CreditId: string;
Images: Image3[];
}
interface Image3 {
CoverType: string;
Url: string;
}
interface Certification {
Country: string;
Certification: string;
}
interface Collection {
Name: string;
Images: any;
Overview: any;
Translations: any;
Parts: any;
TmdbId: number;
}
export interface IMDBRating {
title: string;
url: string;
criticsScore: number;
}
/**
* This is a best-effort API. The IMDB API is technically
* private and getting access costs money/requires approval.
*
* Radarr hosts a public proxy that's in use by all Radarr instances.
*/
class IMDBRadarrProxy extends ExternalAPI {
constructor() {
super('https://api.radarr.video/v1', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
}
/**
* Ask the Radarr IMDB Proxy for the movie
*
* @param IMDBid Id of IMDB movie
*/
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
try {
const data = await this.get<IMDBRadarrProxyResponse>(
`/movie/imdb/${IMDBid}`
);
if (!data?.length || data[0].ImdbId !== IMDBid) {
return null;
}
return {
title: data[0].Title,
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
criticsScore: data[0].MovieRatings.Imdb.Value,
};
} catch (e) {
throw new Error(
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
}
export default IMDBRadarrProxy;

View File

@@ -1,209 +0,0 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
interface RTAlgoliaSearchResponse {
results: {
hits: RTAlgoliaHit[];
index: 'content_rt' | 'people_rt';
}[];
}
interface RTAlgoliaHit {
emsId: string;
emsVersionId: string;
tmsId: string;
type: string;
title: string;
titles: string[];
description: string;
releaseYear: number;
rating: string;
genres: string[];
updateDate: string;
isEmsSearchable: boolean;
rtId: number;
vanity: string;
aka: string[];
posterImageUrl: string;
rottenTomatoes: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
audienceIconUrl: string;
scoreSentiment: string;
certifiedFresh: boolean;
criticsScore: number;
};
}
export interface RTRating {
title: string;
year: number;
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
criticsScore: number;
audienceRating?: 'Upright' | 'Spilled';
audienceScore?: number;
url: string;
}
/**
* This is a best-effort API. The Rotten Tomatoes API is technically
* private and getting access costs money/requires approval.
*
* They do, however, have a "public" api that they use to request the
* data on their own site. We use this to get ratings for movies/tv shows.
*
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes extends ExternalAPI {
constructor() {
const settings = getSettings();
super(
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
{
'x-algolia-agent':
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
'x-algolia-application-id': '79FRDP12PN',
},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-algolia-usertoken': settings.clientId,
},
nodeCache: cacheManager.getCache('rt').data,
}
);
}
/**
* Search the RT algolia api for the movie title
*
* We compare the release date to make sure its the correct
* match. But it's not guaranteed to have results.
*
* @param name Movie name
* @param year Release Year
*/
public async getMovieRatings(
name: string,
year: number
): Promise<RTRating | null> {
try {
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
// First, attempt to match exact name and year
let movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = contentResults.hits.find(
(movie) => movie.releaseYear === year && movie.title.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = contentResults.hits.find((movie) => movie.releaseYear === year);
}
// One last try, try exact name match only
if (!movie) {
movie = contentResults.hits.find((movie) => movie.title === name);
}
if (!movie) {
return null;
}
return {
title: movie.title,
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
criticsRating: movie.rottenTomatoes.certifiedFresh
? 'Certified Fresh'
: movie.rottenTomatoes.criticsScore >= 60
? 'Fresh'
: 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore,
audienceRating:
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: movie.rottenTomatoes.audienceScore,
year: Number(movie.releaseYear),
};
} catch (e) {
throw new Error(
`[RT API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
public async getTVRatings(
name: string,
year?: number
): Promise<RTRating | null> {
try {
const data = await this.post<RTAlgoliaSearchResponse>('/queries', {
requests: [
{
indexName: 'content_rt',
query: name,
params: 'filters=isEmsSearchable%20%3D%201&hitsPerPage=20',
},
],
});
const contentResults = data.results.find((r) => r.index === 'content_rt');
if (!contentResults) {
return null;
}
let tvshow: RTAlgoliaHit | undefined = contentResults.hits[0];
if (year) {
tvshow = contentResults.hits.find(
(series) => series.releaseYear === year
);
}
if (!tvshow) {
return null;
}
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,
criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore,
audienceRating:
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: tvshow.rottenTomatoes.audienceScore,
year: Number(tvshow.releaseYear),
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
}
}
}
export default RottenTomatoes;

View File

@@ -1,7 +0,0 @@
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
import { type RTRating } from '@server/api/rating/rottentomatoes';
export interface RatingResponse {
rt?: RTRating;
imdb?: IMDBRating;
}

View File

@@ -0,0 +1,161 @@
import cacheManager from '@server/lib/cache';
import ExternalAPI from './externalapi';
interface RTSearchResult {
meterClass: 'certified_fresh' | 'fresh' | 'rotten';
meterScore: number;
url: string;
}
interface RTTvSearchResult extends RTSearchResult {
title: string;
startYear: number;
endYear: number;
}
interface RTMovieSearchResult extends RTSearchResult {
name: string;
url: string;
year: number;
}
interface RTMultiSearchResponse {
tvCount: number;
tvSeries: RTTvSearchResult[];
movieCount: number;
movies: RTMovieSearchResult[];
}
export interface RTRating {
title: string;
year: number;
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten';
criticsScore: number;
audienceRating?: 'Upright' | 'Spilled';
audienceScore?: number;
url: string;
}
/**
* This is a best-effort API. The Rotten Tomatoes API is technically
* private and getting access costs money/requires approval.
*
* They do, however, have a "public" api that they use to request the
* data on their own site. We use this to get ratings for movies/tv shows.
*
* Unfortunately, we need to do it by searching for the movie name, so it's
* not always accurate.
*/
class RottenTomatoes extends ExternalAPI {
constructor() {
super(
'https://www.rottentomatoes.com/api/private',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('rt').data,
}
);
}
/**
* Search the 1.0 api for the movie title
*
* We compare the release date to make sure its the correct
* match. But it's not guaranteed to have results.
*
* We use the 1.0 API here because the 2.0 search api does
* not return audience ratings.
*
* @param name Movie name
* @param year Release Year
*/
public async getMovieRatings(
name: string,
year: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
});
// First, attempt to match exact name and year
let movie = data.movies.find(
(movie) => movie.year === year && movie.name === name
);
// If we don't find a movie, try to match partial name and year
if (!movie) {
movie = data.movies.find(
(movie) => movie.year === year && movie.name.includes(name)
);
}
// If we still dont find a movie, try to match just on year
if (!movie) {
movie = data.movies.find((movie) => movie.year === year);
}
// One last try, try exact name match only
if (!movie) {
movie = data.movies.find((movie) => movie.name === name);
}
if (!movie) {
return null;
}
return {
title: movie.name,
url: `https://www.rottentomatoes.com${movie.url}`,
criticsRating:
movie.meterClass === 'certified_fresh'
? 'Certified Fresh'
: movie.meterClass === 'fresh'
? 'Fresh'
: 'Rotten',
criticsScore: movie.meterScore,
year: movie.year,
};
} catch (e) {
throw new Error(
`[RT API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
public async getTVRatings(
name: string,
year?: number
): Promise<RTRating | null> {
try {
const data = await this.get<RTMultiSearchResponse>('/v2.0/search/', {
params: { q: name, limit: 10 },
});
let tvshow: RTTvSearchResult | undefined = data.tvSeries[0];
if (year) {
tvshow = data.tvSeries.find((series) => series.startYear === year);
}
if (!tvshow) {
return null;
}
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com${tvshow.url}`,
criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten',
criticsScore: tvshow.meterScore,
year: tvshow.startYear,
};
} catch (e) {
throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`);
}
}
}
export default RottenTomatoes;

View File

@@ -158,12 +158,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
params: {
includeEpisode: true,
},
}
`/queue`
);
return response.data.records;

View File

@@ -1,7 +1,7 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface SonarrSeason {
interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
@@ -13,21 +13,6 @@ export interface SonarrSeason {
percentOfEpisodes: number;
};
}
interface EpisodeResult {
seriesId: number;
episodeFileId: number;
seasonNumber: number;
episodeNumber: number;
title: string;
airDate: string;
airDateUtc: string;
overview: string;
hasFile: boolean;
monitored: boolean;
absoluteEpisodeNumber: number;
unverifiedSceneNumbering: boolean;
id: number;
}
export interface SonarrSeries {
title: string;
@@ -76,15 +61,6 @@ export interface SonarrSeries {
ignoreEpisodesWithoutFiles?: boolean;
searchForMissingEpisodes?: boolean;
};
statistics: {
seasonCount: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
releaseGroups: string[];
percentOfEpisodes: number;
};
}
export interface AddSeriesOptions {
@@ -106,11 +82,7 @@ export interface LanguageProfile {
name: string;
}
class SonarrAPI extends ServarrBase<{
seriesId: number;
episodeId: number;
episode: EpisodeResult;
}> {
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
@@ -125,16 +97,6 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getSeriesById(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {

View File

@@ -3,12 +3,9 @@ import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
TmdbCollection,
TmdbCompanySearchResponse,
TmdbExternalIdResponse,
TmdbGenre,
TmdbGenresResult,
TmdbKeyword,
TmdbKeywordSearchResponse,
TmdbLanguage,
TmdbMovieDetails,
TmdbNetwork,
@@ -22,8 +19,6 @@ import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
TmdbUpcomingMoviesResponse,
TmdbWatchProviderDetails,
TmdbWatchProviderRegion,
} from './interfaces';
interface SearchOptions {
@@ -37,43 +32,30 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}
export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
interface DiscoverMovieOptions {
page?: number;
includeAdult?: boolean;
language?: string;
primaryReleaseDateGte?: string;
primaryReleaseDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
originalLanguage?: string;
genre?: string;
studio?: string;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
genre?: number;
studio?: number;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc';
}
interface DiscoverTvOptions {
@@ -81,20 +63,19 @@ interface DiscoverTvOptions {
language?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
withRuntimeGte?: string;
withRuntimeLte?: string;
voteAverageGte?: string;
voteAverageLte?: string;
voteCountGte?: string;
voteCountLte?: string;
includeEmptyReleaseDate?: boolean;
originalLanguage?: string;
genre?: string;
genre?: number;
network?: number;
keywords?: string;
sortBy?: SortOptions;
watchRegion?: string;
watchProviders?: string;
sortBy?:
| 'popularity.asc'
| 'popularity.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
}
class TheMovieDb extends ExternalAPI {
@@ -256,7 +237,7 @@ class TheMovieDb extends ExternalAPI {
params: {
language,
append_to_response:
'credits,external_ids,videos,keywords,release_dates,watch/providers',
'credits,external_ids,videos,release_dates,watch/providers',
},
},
43200
@@ -459,27 +440,8 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
studio,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: {
sort_by: sortBy,
@@ -487,33 +449,11 @@ class TheMovieDb extends ExternalAPI {
include_adult: includeAdult,
language,
region: this.region,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'primary_release_date.gte':
!primaryReleaseDateGte && primaryReleaseDateLte
? defaultPastDate
: primaryReleaseDateGte,
'primary_release_date.lte':
!primaryReleaseDateLte && primaryReleaseDateGte
? defaultFutureDate
: primaryReleaseDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
'primary_release_date.gte': primaryReleaseDateGte,
'primary_release_date.lte': primaryReleaseDateLte,
with_genres: genre,
with_companies: studio,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
watch_region: watchRegion,
with_watch_providers: watchProviders,
},
});
@@ -533,61 +473,20 @@ class TheMovieDb extends ExternalAPI {
originalLanguage,
genre,
network,
keywords,
withRuntimeGte,
withRuntimeLte,
voteAverageGte,
voteAverageLte,
voteCountGte,
voteCountLte,
watchProviders,
watchRegion,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5)
)
.toISOString()
.split('T')[0];
const defaultPastDate = new Date('1900-01-01')
.toISOString()
.split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: {
sort_by: sortBy,
page,
language,
region: this.region,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
'first_air_date.gte':
!firstAirDateGte && firstAirDateLte
? defaultPastDate
: firstAirDateGte,
'first_air_date.lte':
!firstAirDateLte && firstAirDateGte
? defaultFutureDate
: firstAirDateLte,
with_original_language:
originalLanguage && originalLanguage !== 'all'
? originalLanguage
: originalLanguage === 'all'
? undefined
: this.originalLanguage,
'first_air_date.gte': firstAirDateGte,
'first_air_date.lte': firstAirDateLte,
with_original_language: originalLanguage ?? this.originalLanguage,
include_null_first_air_dates: includeEmptyReleaseDate,
with_genres: genre,
with_networks: network,
with_keywords: keywords,
'with_runtime.gte': withRuntimeGte,
'with_runtime.lte': withRuntimeLte,
'vote_average.gte': voteAverageGte,
'vote_average.lte': voteAverageLte,
'vote_count.gte': voteCountGte,
'vote_count.lte': voteCountLte,
with_watch_providers: watchProviders,
watch_region: watchRegion,
},
});
@@ -975,152 +874,6 @@ class TheMovieDb extends ExternalAPI {
throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`);
}
}
public async getKeywordDetails({
keywordId,
}: {
keywordId: number;
}): Promise<TmdbKeyword> {
try {
const data = await this.get<TmdbKeyword>(
`/keyword/${keywordId}`,
undefined,
604800 // 7 days
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
}
}
public async searchKeyword({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbKeywordSearchResponse> {
try {
const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search keyword: ${e.message}`);
}
}
public async searchCompany({
query,
page = 1,
}: {
query: string;
page?: number;
}): Promise<TmdbCompanySearchResponse> {
try {
const data = await this.get<TmdbCompanySearchResponse>(
'/search/company',
{
params: {
query,
page,
},
},
86400 // 24 hours
);
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to search companies: ${e.message}`);
}
}
public async getAvailableWatchProviderRegions({
language,
}: {
language?: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions',
{
params: {
language: language ?? this.originalLanguage,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch available watch regions: ${e.message}`
);
}
}
public async getMovieWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch movie watch providers: ${e.message}`
);
}
}
public async getTvWatchProviders({
language,
watchRegion,
}: {
language?: string;
watchRegion: string;
}) {
try {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv',
{
params: {
language: language ?? this.originalLanguage,
watch_region: watchRegion,
},
},
86400 // 24 hours
);
return data.results;
} catch (e) {
throw new Error(
`[TMDB] Failed to fetch TV watch providers: ${e.message}`
);
}
}
}
export default TheMovieDb;

View File

@@ -28,18 +28,6 @@ export interface TmdbTvResult extends TmdbMediaResult {
first_air_date: string;
}
export interface TmdbCollectionResult {
id: number;
media_type: 'collection';
title: string;
original_title: string;
adult: boolean;
poster_path?: string;
backdrop_path?: string;
overview: string;
original_language: string;
}
export interface TmdbPersonResult {
id: number;
name: string;
@@ -57,12 +45,7 @@ interface TmdbPaginatedResponse {
}
export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse {
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[];
}
export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse {
@@ -188,9 +171,6 @@ export interface TmdbMovieDetails {
id: number;
results?: { [iso_3166_1: string]: TmdbWatchProviders };
};
keywords: {
keywords: TmdbKeyword[];
};
}
export interface TmdbVideo {
@@ -211,7 +191,7 @@ export interface TmdbVideo {
export interface TmdbTvEpisodeResult {
id: number;
air_date: string | null;
air_date: string;
episode_number: number;
name: string;
overview: string;
@@ -392,8 +372,7 @@ export interface TmdbPersonCombinedCredits {
crew: TmdbPersonCreditCrew[];
}
export interface TmdbSeasonWithEpisodes
extends Omit<TmdbTvSeasonResult, 'episode_count'> {
export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
episodes: TmdbTvEpisodeResult[];
external_ids: TmdbExternalIds;
}
@@ -448,24 +427,3 @@ export interface TmdbWatchProviderDetails {
provider_id: number;
provider_name: string;
}
export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse {
results: TmdbKeyword[];
}
// We have production companies, but the company search results return less data
export interface TmdbCompany {
id: number;
logo_path?: string;
name: string;
}
export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse {
results: TmdbCompany[];
}
export interface TmdbWatchProviderRegion {
iso_3166_1: string;
english_name: string;
native_name: string;
}

View File

@@ -1,100 +0,0 @@
import type DiscoverSlider from '@server/entity/DiscoverSlider';
export enum DiscoverSliderType {
RECENTLY_ADDED = 1,
RECENT_REQUESTS,
PLEX_WATCHLIST,
TRENDING,
POPULAR_MOVIES,
MOVIE_GENRES,
UPCOMING_MOVIES,
STUDIOS,
POPULAR_TV,
TV_GENRES,
UPCOMING_TV,
NETWORKS,
TMDB_MOVIE_KEYWORD,
TMDB_MOVIE_GENRE,
TMDB_TV_KEYWORD,
TMDB_TV_GENRE,
TMDB_SEARCH,
TMDB_STUDIO,
TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
}
export const defaultSliders: Partial<DiscoverSlider>[] = [
{
type: DiscoverSliderType.RECENTLY_ADDED,
enabled: true,
isBuiltIn: true,
order: 0,
},
{
type: DiscoverSliderType.RECENT_REQUESTS,
enabled: true,
isBuiltIn: true,
order: 1,
},
{
type: DiscoverSliderType.PLEX_WATCHLIST,
enabled: true,
isBuiltIn: true,
order: 2,
},
{
type: DiscoverSliderType.TRENDING,
enabled: true,
isBuiltIn: true,
order: 3,
},
{
type: DiscoverSliderType.POPULAR_MOVIES,
enabled: true,
isBuiltIn: true,
order: 4,
},
{
type: DiscoverSliderType.MOVIE_GENRES,
enabled: true,
isBuiltIn: true,
order: 5,
},
{
type: DiscoverSliderType.UPCOMING_MOVIES,
enabled: true,
isBuiltIn: true,
order: 6,
},
{
type: DiscoverSliderType.STUDIOS,
enabled: true,
isBuiltIn: true,
order: 7,
},
{
type: DiscoverSliderType.POPULAR_TV,
enabled: true,
isBuiltIn: true,
order: 8,
},
{
type: DiscoverSliderType.TV_GENRES,
enabled: true,
isBuiltIn: true,
order: 9,
},
{
type: DiscoverSliderType.UPCOMING_TV,
enabled: true,
isBuiltIn: true,
order: 10,
},
{
type: DiscoverSliderType.NETWORKS,
enabled: true,
isBuiltIn: true,
order: 11,
},
];

View File

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

View File

@@ -34,7 +34,7 @@ const dataSource = new DataSource(
process.env.NODE_ENV !== 'production' ? devConfig : prodConfig
);
export const getRepository = <Entity extends object>(
export const getRepository = <Entity>(
target: EntityTarget<Entity>
): Repository<Entity> => {
return dataSource.getRepository(target);

View File

@@ -1,69 +0,0 @@
import type { DiscoverSliderType } from '@server/constants/discover';
import { defaultSliders } from '@server/constants/discover';
import { getRepository } from '@server/datasource';
import logger from '@server/logger';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
class DiscoverSlider {
public static async bootstrapSliders(): Promise<void> {
const sliderRepository = getRepository(DiscoverSlider);
for (const slider of defaultSliders) {
const existingSlider = await sliderRepository.findOne({
where: {
type: slider.type,
},
});
if (!existingSlider) {
logger.info('Creating built-in discovery slider', {
label: 'Discover Slider',
slider,
});
await sliderRepository.save(new DiscoverSlider(slider));
}
}
}
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'int' })
public type: DiscoverSliderType;
@Column({ type: 'int' })
public order: number;
@Column({ default: false })
public isBuiltIn: boolean;
@Column({ default: true })
public enabled: boolean;
@Column({ nullable: true })
// Title is not required for built in sliders because we will
// use translations for them.
public title?: string;
@Column({ nullable: true })
public data?: string;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
constructor(init?: Partial<DiscoverSlider>) {
Object.assign(this, init);
}
}
export default DiscoverSlider;

View File

@@ -1,10 +1,6 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import downloadTracker from '@server/lib/downloadtracker';
@@ -12,7 +8,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import {
AfterLoad,
AfterUpdate,
Column,
CreateDateColumn,
Entity,
@@ -119,29 +114,29 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true, type: 'int' })
public serviceId?: number | null;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true, type: 'int' })
public serviceId4k?: number | null;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId?: number | null;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true, type: 'int' })
public externalServiceId4k?: number | null;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug?: string | null;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true, type: 'varchar' })
public externalServiceSlug4k?: string | null;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey?: string | null;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
@@ -265,9 +260,7 @@ class Media {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
@@ -277,9 +270,7 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
@@ -291,9 +282,7 @@ class Media {
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
@@ -303,9 +292,7 @@ class Media {
if (
this.externalServiceId4k !== undefined &&
this.externalServiceId4k !== null &&
this.serviceId4k !== undefined &&
this.serviceId4k !== null
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,
@@ -314,37 +301,6 @@ class Media {
}
}
}
@AfterUpdate()
public async updateRelatedMediaRequest(): Promise<void> {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
relations: {
media: true,
},
where: {
media: { id: this.id },
status: MediaRequestStatus.APPROVED,
},
});
// Check the media entity status and if
// available or deleted, set the related request
// to completed
if (relatedRequests.length > 0) {
relatedRequests.forEach((request) => {
if (
this[request.is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE ||
this[request.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
) {
request.status = MediaRequestStatus.COMPLETED;
}
});
requestRepository.save(relatedRequests);
}
}
}
export default Media;

View File

@@ -167,8 +167,7 @@ export class MediaRequest {
// If there is an existing movie request that isn't declined, don't allow a new one.
if (
requestBody.mediaType === MediaType.MOVIE &&
existing[0].status !== MediaRequestStatus.DECLINED &&
existing[0].status !== MediaRequestStatus.COMPLETED
existing[0].status !== MediaRequestStatus.DECLINED
) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
@@ -261,8 +260,7 @@ export class MediaRequest {
.filter(
(request) =>
request.is4k === requestBody.is4k &&
request.status !== MediaRequestStatus.DECLINED &&
request.status !== MediaRequestStatus.COMPLETED
request.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
@@ -281,9 +279,7 @@ export class MediaRequest {
.filter(
(season) =>
season[requestBody.is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN &&
season[requestBody.is4k ? 'status4k' : 'status'] !==
MediaStatus.DELETED
MediaStatus.UNKNOWN
)
.map((season) => season.seasonNumber),
];
@@ -587,8 +583,7 @@ export class MediaRequest {
if (
media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
this.status === MediaRequestStatus.DECLINED
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
@@ -606,8 +601,7 @@ export class MediaRequest {
media.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING
).length === 0 &&
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
) {
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
mediaRepository.save(media);
@@ -710,7 +704,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
let tags = radarrSettings.tags;
if (
this.rootFolder &&
@@ -770,51 +764,10 @@ export class MediaRequest {
return;
}
if (radarrSettings.tagRequests) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await radarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
});
}
}
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
throw new Error('Media already available');
}
const radarrMovieOptions: RadarrMovieOptions = {
@@ -955,16 +908,7 @@ export class MediaRequest {
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
throw new Error('Media already available');
}
const tmdb = new TheMovieDb();
@@ -990,7 +934,7 @@ export class MediaRequest {
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
seriesType = 'anime';
}
let rootFolder =
@@ -1008,11 +952,7 @@ export class MediaRequest {
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
: sonarrSettings.tags;
if (
this.rootFolder &&
@@ -1064,38 +1004,6 @@ export class MediaRequest {
});
}
if (sonarrSettings.tagRequests) {
let userTag = (await sonarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
});
}
}
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
@@ -1261,5 +1169,3 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

View File

@@ -1,8 +1,5 @@
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import SeasonRequest from '@server/entity/SeasonRequest';
import { MediaStatus } from '@server/constants/media';
import {
AfterUpdate,
Column,
CreateDateColumn,
Entity,
@@ -38,37 +35,6 @@ class Season {
constructor(init?: Partial<Season>) {
Object.assign(this, init);
}
@AfterUpdate()
public async updateSeasonRequests(): Promise<void> {
const seasonRequestRepository = getRepository(SeasonRequest);
const relatedSeasonRequests = await seasonRequestRepository.find({
relations: {
request: true,
},
where: {
request: { media: { id: (await this.media).id } },
seasonNumber: this.seasonNumber,
},
});
// Check seasons when/if they become available or deleted,
// then set the related season request to completed
relatedSeasonRequests.forEach((seasonRequest) => {
if (
this.seasonNumber === seasonRequest.seasonNumber &&
((!seasonRequest.request.is4k &&
(this.status === MediaStatus.AVAILABLE ||
this.status === MediaStatus.DELETED)) ||
(seasonRequest.request.is4k &&
this.status4k === MediaStatus.AVAILABLE) ||
this.status4k === MediaStatus.DELETED)
)
seasonRequest.status = MediaRequestStatus.COMPLETED;
});
seasonRequestRepository.save(relatedSeasonRequests);
}
}
export default Season;

View File

@@ -1,7 +1,5 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterUpdate,
Column,
CreateDateColumn,
Entity,
@@ -36,28 +34,6 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterUpdate()
public async updateMediaRequests(): Promise<void> {
const requestRepository = getRepository(MediaRequest);
const relatedRequest = await requestRepository.findOne({
where: { id: this.request.id },
});
// Check the parent of the season request and
// if every season request is complete
// set the parent request to complete as well
const isRequestComplete = relatedRequest?.seasons.every(
(seasonRequest) => seasonRequest.status === MediaRequestStatus.COMPLETED
);
if (isRequestComplete && relatedRequest) {
relatedRequest.status = MediaRequestStatus.COMPLETED;
requestRepository.save(relatedRequest);
}
}
}
export default SeasonRequest;

View File

@@ -39,7 +39,7 @@ export class User {
return users.map((u) => u.filter(showFiltered));
}
static readonly filteredFields: string[] = ['email', 'plexId'];
static readonly filteredFields: string[] = ['email'];
public displayName: string;
@@ -73,7 +73,7 @@ export class User {
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true, select: true })
@Column({ nullable: true, select: false })
public plexId?: number;
@Column({ nullable: true, select: false })

View File

@@ -51,9 +51,6 @@ export class UserSettings {
@Column({ nullable: true })
public pushoverUserKey?: string;
@Column({ nullable: true })
public pushoverSound?: string;
@Column({ nullable: true })
public telegramChatId?: string;

View File

@@ -1,6 +1,5 @@
import PlexAPI from '@server/api/plexapi';
import dataSource, { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { Session } from '@server/entity/Session';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
@@ -17,9 +16,7 @@ import WebhookAgent from '@server/lib/notifications/agents/webhook';
import WebPushAgent from '@server/lib/notifications/agents/webpush';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip';
@@ -97,9 +94,6 @@ app
// Start Jobs
startJobs();
// Bootstrap Discovery Sliders
await DiscoverSlider.bootstrapSliders();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
@@ -152,7 +146,7 @@ app
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
sameSite: true,
secure: 'auto',
},
store: new TypeormStore({
@@ -182,10 +176,6 @@ app
next();
});
server.use('/api/v1', routes);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.get('*', (req, res) => handle(req, res));
server.use(
(

View File

@@ -51,11 +51,6 @@ export interface CacheItem {
};
}
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse {
version: string;
commitTag: string;

View File

@@ -28,7 +28,6 @@ export interface UserSettingsNotificationsResponse {
pushbulletAccessToken?: string;
pushoverApplicationToken?: string;
pushoverUserKey?: string;
pushoverSound?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: string;

View File

@@ -1,6 +1,4 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@@ -8,7 +6,6 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -16,7 +13,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
interval: 'short' | 'long' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
@@ -32,7 +29,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'minutes',
interval: 'short',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
@@ -49,7 +46,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'hours',
interval: 'long',
cronSchedule: jobs['plex-full-scan'].schedule,
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
@@ -61,37 +58,27 @@ export const startJobs = (): void => {
cancelFn: () => plexFullScanner.cancel(),
});
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
// Run watchlist sync every 5 minutes
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'fixed',
interval: 'long',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
name: 'Radarr Scan',
type: 'process',
interval: 'hours',
interval: 'long',
cronSchedule: jobs['radarr-scan'].schedule,
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
@@ -106,7 +93,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'hours',
interval: 'long',
cronSchedule: jobs['sonarr-scan'].schedule,
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
@@ -116,29 +103,12 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',
type: 'process',
interval: 'hours',
cronSchedule: jobs['availability-sync'].schedule,
job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => {
logger.info('Starting scheduled job: Media Availability Sync', {
label: 'Jobs',
});
availabilitySync.run();
}),
running: () => availabilitySync.running,
cancelFn: () => availabilitySync.cancel(),
});
// Run download sync every minute
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
interval: 'seconds',
interval: 'fixed',
cronSchedule: jobs['download-sync'].schedule,
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
@@ -153,7 +123,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'hours',
interval: 'long',
cronSchedule: jobs['download-sync-reset'].schedule,
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
@@ -163,21 +133,5 @@ export const startJobs = (): void => {
}),
});
// Run image cache cleanup every 24 hours
scheduledJobs.push({
id: 'image-cache-cleanup',
name: 'Image Cache Cleanup',
type: 'process',
interval: 'hours',
cronSchedule: jobs['image-cache-cleanup'].schedule,
job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => {
logger.info('Starting scheduled job: Image Cache Cleanup', {
label: 'Jobs',
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -1,673 +0,0 @@
import type { PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import type Season from '@server/entity/Season';
import { User } from '@server/entity/User';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
class AvailabilitySync {
public running = false;
private plexClient: PlexAPI;
private plexSeasonsCache: Record<string, PlexMetadata[]>;
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
private radarrServers: RadarrSettings[];
private sonarrServers: SonarrSettings[];
async run() {
const settings = getSettings();
this.running = true;
this.plexSeasonsCache = {};
this.sonarrSeasonsCache = {};
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
try {
logger.info(`Starting availability sync...`, {
label: 'AvailabilitySync',
});
const pageSize = 50;
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (admin) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
} else {
logger.error('An admin is not configured.');
}
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
if (!this.running) {
throw new Error('Job aborted');
}
// Check plex, radarr, and sonarr for that specific media and
// if unavailable, then we change the status accordingly.
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
if (media.mediaType === 'movie') {
let movieExists = false;
let movieExists4k = false;
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(
media,
true
);
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
if (existsInPlex || existsInRadarr) {
movieExists = true;
logger.info(
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInRadarr4k) {
movieExists4k = true;
logger.info(
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, false);
}
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
await this.mediaUpdater(media, true);
}
}
// If both versions still exist in plex, we still need
// to check through sonarr to verify season availability
if (media.mediaType === 'tv') {
let showExists = false;
let showExists4k = false;
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
await this.mediaExistsInPlex(media, false);
const {
existsInPlex: existsInPlex4k,
seasonsMap: plexSeasonsMap4k = new Map(),
} = await this.mediaExistsInPlex(media, true);
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
await this.mediaExistsInSonarr(media, false);
const {
existsInSonarr: existsInSonarr4k,
seasonsMap: sonarrSeasonsMap4k,
} = await this.mediaExistsInSonarr(media, true);
if (existsInPlex || existsInSonarr) {
showExists = true;
logger.info(
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
if (existsInPlex4k || existsInSonarr4k) {
showExists4k = true;
logger.info(
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
{
label: 'AvailabilitySync',
}
);
}
// Here we will create a final map that will cross compare
// with plex and sonarr. Filtered seasons will go through
// each season and assume the season does not exist. If Plex or
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE
)
.forEach((season) =>
filteredSeasonsMap.set(season.seasonNumber, false)
);
const finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
season.status4k === MediaStatus.AVAILABLE ||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
)
.forEach((season) =>
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
const finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
if ([...finalSeasons.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons, false);
}
if ([...finalSeasons4k.values()].includes(false)) {
await this.seasonUpdater(media, finalSeasons4k, true);
}
if (
!showExists &&
(media.status === MediaStatus.AVAILABLE ||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
) {
await this.mediaUpdater(media, false);
}
if (
!showExists4k &&
(media.status4k === MediaStatus.AVAILABLE ||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
) {
await this.mediaUpdater(media, true);
}
}
}
} catch (ex) {
logger.error('Failed to complete availability sync.', {
errorMessage: ex.message,
label: 'AvailabilitySync',
});
} finally {
logger.info(`Availability sync complete.`, {
label: 'AvailabilitySync',
});
this.running = false;
}
}
public cancel() {
this.running = false;
}
private async *loadAvailableMediaPaginated(pageSize: number) {
let offset = 0;
const mediaRepository = getRepository(Media);
const whereOptions = [
{ status: MediaStatus.AVAILABLE },
{ status: MediaStatus.PARTIALLY_AVAILABLE },
{ status4k: MediaStatus.AVAILABLE },
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
];
let mediaPage: Media[];
do {
yield* (mediaPage = await mediaRepository.find({
where: whereOptions,
skip: offset,
take: pageSize,
}));
offset += pageSize;
} while (mediaPage.length > 0);
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
try {
// If media type is tv, check if a season is processing
//to see if we need to keep the external metadata
let isMediaProcessing = false;
if (media.mediaType === 'tv') {
const requestRepository = getRepository(MediaRequest);
const request = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', {
id: media.id,
})
.andWhere(
'(request.is4k = :is4k AND request.status = :requestStatus)',
{
requestStatus: MediaRequestStatus.APPROVED,
is4k: is4k,
}
)
.getOne();
if (request) {
isMediaProcessing = true;
}
}
// Set the non-4K or 4K media to deleted
// and change related columns to null if media
// is not processing
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
? media[is4k ? 'serviceId4k' : 'serviceId']
: null;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
isMediaProcessing
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
: null;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
isMediaProcessing
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
? media[is4k ? 'ratingKey4k' : 'ratingKey']
: null;
logger.info(
`The ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'movie' ? 'movie' : 'show'
} [TMDB ID ${media.tmdbId}] was not found in any ${
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
} and Plex instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.save({ media, ...media });
} catch (ex) {
logger.debug(
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}].`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
private async seasonUpdater(
media: Media,
seasons: Map<number, boolean>,
is4k: boolean
): Promise<void> {
const mediaRepository = getRepository(Media);
// Filter out only the values that are false
// (media that should be deleted)
const seasonsPendingRemoval = new Map(
// Disabled linter as only the value is needed from the filter
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[...seasons].filter(([_, exists]) => !exists)
);
// Retrieve the season keys to pass into our log
const seasonKeys = [...seasonsPendingRemoval.keys()];
try {
for (const mediaSeason of media.seasons) {
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
}
}
if (media.status === MediaStatus.AVAILABLE) {
media.status = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
}
if (media.status4k === MediaStatus.AVAILABLE) {
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
logger.info(
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
{ label: 'AvailabilitySync' }
);
}
await mediaRepository.save({ media, ...media });
logger.info(
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
media.tmdbId
}] was not found in any ${
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
} and Plex instance. Status will be changed to deleted.`,
{ label: 'AvailabilitySync' }
);
} catch (ex) {
logger.debug(
`Failure updating the ${
is4k ? '4K' : 'non-4K'
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
private async mediaExistsInRadarr(
media: Media,
is4k: boolean
): Promise<boolean> {
let existsInRadarr = false;
// Check for availability in all of the available radarr servers
// If any find the media, we will assume the media exists
for (const server of this.radarrServers) {
if (server.is4k === is4k) {
const radarrAPI = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
let radarr: RadarrMovie | undefined;
if (media.externalServiceId && !is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId,
});
}
if (media.externalServiceId4k && is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId4k,
});
}
if (radarr && radarr.hasFile) {
existsInRadarr = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInRadarr = true;
logger.debug(
`Failure retrieving the ${
is4k ? '4K' : 'non-4K'
} movie [TMDB ID ${media.tmdbId}] from Radarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
}
}
return existsInRadarr;
}
private async mediaExistsInSonarr(
media: Media,
is4k: boolean
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
let existsInSonarr = false;
let preventSeasonSearch = false;
// Check for availability in all of the available sonarr servers
// If any find the media, we will assume the media exists
for (const server of this.sonarrServers) {
if (server.is4k === is4k) {
const sonarrAPI = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
let sonarr: SonarrSeries | undefined;
if (media.externalServiceId && !is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
sonarr.seasons;
}
if (media.externalServiceId4k && is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
this.sonarrSeasonsCache[
`${server.id}-${media.externalServiceId4k}`
] = sonarr.seasons;
}
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
existsInSonarr = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInSonarr = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
media.tmdbId
}] from Sonarr.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
}
}
// Here we check each season for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInSonarr(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInSonarr, seasonsMap };
}
private async seasonExistsInSonarr(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
let seasonExists = false;
// Check each sonarr instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
for (const server of this.sonarrServers) {
let sonarrSeasons: SonarrSeason[] | undefined;
if (media.externalServiceId && !is4k) {
sonarrSeasons =
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
}
if (media.externalServiceId4k && is4k) {
sonarrSeasons =
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
}
const seasonIsAvailable = sonarrSeasons?.find(
({ seasonNumber, statistics }) =>
season.seasonNumber === seasonNumber &&
statistics?.episodeFileCount &&
statistics?.episodeFileCount > 0
);
if (seasonIsAvailable && sonarrSeasons) {
seasonExists = true;
}
}
return seasonExists;
}
private async mediaExistsInPlex(
media: Media,
is4k: boolean
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let existsInPlex = false;
let preventSeasonSearch = false;
// Check each plex instance to see if the media still exists
// If found, we will assume the media exists and prevent removal
// We can use the cache we built when we fetched the series with mediaExistsInPlex
try {
let plexMedia: PlexMetadata | undefined;
if (ratingKey && !is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey] =
await this.plexClient?.getChildrenMetadata(ratingKey);
}
}
if (ratingKey4k && is4k) {
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
if (media.mediaType === 'tv') {
this.plexSeasonsCache[ratingKey4k] =
await this.plexClient?.getChildrenMetadata(ratingKey4k);
}
}
if (plexMedia) {
existsInPlex = true;
}
} catch (ex) {
if (!ex.message.includes('404')) {
existsInPlex = true;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
media.mediaType === 'tv' ? 'show' : 'movie'
} [TMDB ID ${media.tmdbId}] from Plex.`,
{
errorMessage: ex.message,
label: 'AvailabilitySync',
}
);
}
}
// Here we check each season in plex for availability
// If the API returns an error other than a 404,
// we will have to prevent the season check from happening
if (media.mediaType === 'tv') {
const seasonsMap: Map<number, boolean> = new Map();
if (!preventSeasonSearch) {
const filteredSeasons = media.seasons.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE
);
for (const season of filteredSeasons) {
const seasonExists = await this.seasonExistsInPlex(
media,
season,
is4k
);
if (seasonExists) {
seasonsMap.set(season.seasonNumber, true);
}
}
}
return { existsInPlex, seasonsMap };
}
return { existsInPlex };
}
private async seasonExistsInPlex(
media: Media,
season: Season,
is4k: boolean
): Promise<boolean> {
const ratingKey = media.ratingKey;
const ratingKey4k = media.ratingKey4k;
let seasonExistsInPlex = false;
// Check each plex instance to see if the season exists
let plexSeasons: PlexMetadata[] | undefined;
if (ratingKey && !is4k) {
plexSeasons = this.plexSeasonsCache[ratingKey];
}
if (ratingKey4k && is4k) {
plexSeasons = this.plexSeasonsCache[ratingKey4k];
}
const seasonIsAvailable = plexSeasons?.find(
(plexSeason) => plexSeason.index === season.seasonNumber
);
if (seasonIsAvailable) {
seasonExistsInPlex = true;
}
return seasonExistsInPlex;
}
}
const availabilitySync = new AvailabilitySync();
export default availabilitySync;

View File

@@ -5,7 +5,6 @@ export type AvailableCacheIds =
| 'radarr'
| 'sonarr'
| 'rt'
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
@@ -52,10 +51,6 @@ class CacheManager {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
github: new Cache('github', 'GitHub API', {
stdTtl: 21600,
checkPeriod: 60 * 30,

View File

@@ -5,12 +5,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { uniqWith } from 'lodash';
interface EpisodeNumberResult {
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
id: number;
}
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
@@ -20,7 +14,6 @@ export interface DownloadingItem {
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
episode?: EpisodeNumberResult;
}
class DownloadTracker {
@@ -171,7 +164,6 @@ class DownloadTracker {
status: item.status,
timeLeft: item.timeleft,
title: item.title,
episode: item.episode,
}));
if (queueItems.length > 0) {

View File

@@ -1,266 +0,0 @@
import logger from '@server/logger';
import axios from 'axios';
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import path, { join } from 'path';
type ImageResponse = {
meta: {
revalidateAfter: number;
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
imageBuffer: Buffer;
};
const baseCacheDirectory = process.env.CONFIG_DIRECTORY
? `${process.env.CONFIG_DIRECTORY}/cache/images`
: path.join(__dirname, '../../config/cache/images');
class ImageProxy {
public static async clearCache(key: string) {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
public static async getImageStats(
key: string
): Promise<{ size: number; imageCount: number }> {
const cacheDirectory = path.join(baseCacheDirectory, key);
const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory);
const imageCount = await ImageProxy.getImageCount(cacheDirectory);
return {
size: imageTotalSize,
imageCount,
};
}
private static async getDirectorySize(dir: string): Promise<number> {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
const files = await promises.readdir(dir);
return files.length;
}
private axios;
private cacheVersion;
private key;
constructor(
key: string,
baseUrl: string,
options: {
cacheVersion?: number;
rateLimitOptions?: rateLimitOptions;
} = {}
) {
this.cacheVersion = options.cacheVersion ?? 1;
this.key = key;
this.axios = axios.create({
baseURL: baseUrl,
});
if (options.rateLimitOptions) {
this.axios = rateLimit(this.axios, options.rateLimitOptions);
}
}
public async getImage(path: string): Promise<ImageResponse> {
const cacheKey = this.getCacheKey(path);
const imageResponse = await this.get(cacheKey);
if (!imageResponse) {
const newImage = await this.set(path, cacheKey);
if (!newImage) {
throw new Error('Failed to load image');
}
return newImage;
}
// If the image is stale, we will revalidate it in the background.
if (imageResponse.meta.isStale) {
this.set(path, cacheKey);
}
return imageResponse;
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
const now = Date.now();
for (const file of files) {
const [maxAgeSt, expireAtSt, etag, extension] = file.split('.');
const buffer = await promises.readFile(join(directory, file));
const expireAt = Number(expireAtSt);
const maxAge = Number(maxAgeSt);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: maxAge * 1000 + now,
isStale: now > expireAt,
etag,
extension,
cacheKey,
cacheMiss: false,
},
imageBuffer: buffer,
};
}
} catch (e) {
// No files. Treat as empty cache.
}
return null;
}
private async set(
path: string,
cacheKey: string
): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const response = await this.axios.get(path, {
responseType: 'arraybuffer',
});
const buffer = Buffer.from(response.data, 'binary');
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers['cache-control'] ?? '0').split('=')[1]
);
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.etag ?? '').replace(/"/g, '');
await this.writeToCacheDir(
directory,
extension,
maxAge,
expireAt,
buffer,
etag
);
return {
meta: {
curRevalidate: maxAge,
revalidateAfter: expireAt,
isStale: false,
etag,
extension,
cacheKey,
cacheMiss: true,
},
imageBuffer: buffer,
};
} catch (e) {
logger.debug('Something went wrong caching image.', {
label: 'Image Cache',
errorMessage: e.message,
});
return null;
}
}
private async writeToCacheDir(
dir: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string
) {
const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`);
await promises.rm(dir, { force: true, recursive: true }).catch(() => {
// do nothing
});
await promises.mkdir(dir, { recursive: true });
await promises.writeFile(filename, buffer);
}
private getCacheKey(path: string) {
return this.getHash([this.key, this.cacheVersion, path]);
}
private getHash(items: (string | number | Buffer)[]) {
const hash = createHash('sha256');
for (const item of items) {
if (typeof item === 'number') hash.update(String(item));
else {
hash.update(item);
}
}
// See https://en.wikipedia.org/wiki/Base64#Filenames
return hash.digest('base64').replace(/\//g, '-');
}
private getCacheDirectory() {
return path.join(baseCacheDirectory, this.key);
}
}
export default ImageProxy;

View File

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

View File

@@ -281,9 +281,7 @@ class BaseScanner<T> {
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride &&
season.processing &&
existingSeason.status !== MediaStatus.DELETED
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status;
@@ -296,9 +294,7 @@ class BaseScanner<T> {
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride &&
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
@@ -405,18 +401,13 @@ class BaseScanner<T> {
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) =>
season.status !== MediaStatus.UNKNOWN &&
season.status !== MediaStatus.DELETED
).length === 0;
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
.length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) =>
season.status4k !== MediaStatus.UNKNOWN &&
season.status4k !== MediaStatus.DELETED
).length === 0;
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
.length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
@@ -426,13 +417,11 @@ class BaseScanner<T> {
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
: !seasons.length ||
media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
@@ -444,13 +433,11 @@ class BaseScanner<T> {
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
: !seasons.length ||
media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: media.status4k === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);

View File

@@ -96,8 +96,7 @@ class PlexScanner
// We remove 10 minutes from the last scan as a buffer
addedAt: library.lastScan - 1000 * 60 * 10,
}
: undefined,
library.type
: undefined
);
// Bundle items up by rating keys

View File

@@ -61,7 +61,6 @@ export interface DVRSettings {
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
}
export interface RadarrSettings extends DVRSettings {
@@ -69,8 +68,6 @@ export interface RadarrSettings extends DVRSettings {
}
export interface SonarrSettings extends DVRSettings {
seriesType: 'standard' | 'daily' | 'anime';
animeSeriesType: 'standard' | 'daily' | 'anime';
activeAnimeProfileId?: number;
activeAnimeProfileName?: string;
activeAnimeDirectory?: string;
@@ -192,7 +189,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
sound: string;
};
}
@@ -251,9 +247,7 @@ export type JobId =
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup'
| 'availability-sync';
| 'download-sync-reset';
interface AllSettings {
clientId: string;
@@ -373,7 +367,6 @@ class Settings {
options: {
accessToken: '',
userToken: '',
sound: '',
},
},
webhook: {
@@ -382,7 +375,7 @@ class Settings {
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {
@@ -415,18 +408,12 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
'download-sync': {
schedule: '0 * * * * *',
},
'download-sync-reset': {
schedule: '0 0 1 * * *',
},
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
},
};
if (initialSettings) {
@@ -555,7 +542,7 @@ class Settings {
}
private generateApiKey(): string {
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
return Buffer.from(`${Date.now()}${randomUUID()})`).toString('base64');
}
private generateVapidKeys(force = false): void {

View File

@@ -79,80 +79,82 @@ class WatchlistSync {
)
);
for (const mediaItem of unavailableItems) {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
await Promise.all(
unavailableItems.map(async (mediaItem) => {
try {
logger.info("Creating media request from user's Plex Watchlist", {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
});
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
if (mediaItem.type === 'show' && !mediaItem.tvdbId) {
throw new Error('Missing TVDB ID from Plex Metadata');
}
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
continue;
}
// Check if they have auto-request permissons and watchlist sync
// enabled for the media type
if (
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncMovies) &&
mediaItem.type === 'movie') ||
((!user.hasPermission(
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) ||
!user.settings?.watchlistSyncTv) &&
mediaItem.type === 'show')
) {
return;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
continue;
}
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
user,
{ isAutoRequest: true }
);
} catch (e) {
if (!(e instanceof Error)) {
return;
}
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. 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:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
switch (e.constructor) {
// During watchlist sync, these errors aren't necessarily
// a problem with Overseerr. 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:
case DuplicateMediaRequestError:
case QuotaRestrictedError:
case NoSeasonsAvailableError:
logger.debug('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
break;
default:
logger.error('Failed to create media request from watchlist', {
label: 'Watchlist Sync',
userId: user.id,
mediaTitle: mediaItem.title,
errorMessage: e.message,
});
}
}
}
}
})
);
}
}

View File

@@ -1,6 +0,0 @@
const clearCookies: Middleware = (_req, res, next) => {
res.removeHeader('Set-Cookie');
next();
};
export default clearCookies;

View File

@@ -1,15 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiscoverSlider1672041273674 implements MigrationInterface {
name = 'AddDiscoverSlider1672041273674';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "discover_slider"`);
}
}

View File

@@ -1,31 +0,0 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserPushoverSound1697393491630 implements MigrationInterface {
name = 'AddUserPushoverSound1697393491630';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

View File

@@ -9,7 +9,6 @@ import type {
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
WatchProviders,
} from './common';
@@ -84,7 +83,6 @@ export interface MovieDetails {
externalIds: ExternalIds;
plexUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
}
export const mapProductionCompany = (
@@ -144,8 +142,4 @@ export const mapMovieDetails = (
externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media,
watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}),
keywords: movie.keywords.keywords.map((keyword) => ({
id: keyword.id,
name: keyword.name,
})),
});

View File

@@ -1,5 +1,4 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@@ -10,7 +9,7 @@ import type {
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
export type MediaType = 'tv' | 'movie' | 'person';
interface SearchResult {
id: number;
@@ -44,18 +43,6 @@ export interface TvResult extends SearchResult {
firstAirDate: string;
}
export interface CollectionResult {
id: number;
mediaType: 'collection';
title: string;
originalTitle: string;
adult: boolean;
posterPath?: string;
backdropPath?: string;
overview: string;
originalLanguage: string;
}
export interface PersonResult {
id: number;
name: string;
@@ -66,7 +53,7 @@ export interface PersonResult {
knownFor: (MovieResult | TvResult)[];
}
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export type Results = MovieResult | TvResult | PersonResult;
export const mapMovieResult = (
movieResult: TmdbMovieResult,
@@ -112,20 +99,6 @@ export const mapTvResult = (
mediaInfo: media,
});
export const mapCollectionResult = (
collectionResult: TmdbCollectionResult
): CollectionResult => ({
id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult,
originalLanguage: collectionResult.original_language,
originalTitle: collectionResult.original_title,
title: collectionResult.title,
overview: collectionResult.overview,
backdropPath: collectionResult.backdrop_path,
posterPath: collectionResult.poster_path,
});
export const mapPersonResult = (
personResult: TmdbPersonResult
): PersonResult => ({
@@ -145,12 +118,7 @@ export const mapPersonResult = (
});
export const mapSearchResults = (
results: (
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[],
results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[],
media?: Media[]
): Results[] =>
results.map((result) => {
@@ -171,8 +139,6 @@ export const mapSearchResults = (
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
case 'collection':
return mapCollectionResult(result);
default:
return mapPersonResult(result);
}

View File

@@ -29,7 +29,7 @@ import type { Video } from './Movie';
interface Episode {
id: number;
name: string;
airDate: string | null;
airDate: string;
episodeNumber: number;
overview: string;
productionCode: string;
@@ -50,7 +50,7 @@ interface Season {
seasonNumber: number;
}
export interface SeasonWithEpisodes extends Omit<Season, 'episodeCount'> {
export interface SeasonWithEpisodes extends Season {
episodes: Episode[];
externalIds: ExternalIds;
}
@@ -141,6 +141,7 @@ export const mapSeasonWithEpisodes = (
season: TmdbSeasonWithEpisodes
): SeasonWithEpisodes => ({
airDate: season.air_date,
episodeCount: season.episode_count,
episodes: season.episodes.map(mapEpisodeResult),
externalIds: mapExternalIds(season.external_ids),
id: season.id,

View File

@@ -64,28 +64,13 @@ authRoutes.post('/plex', async (req, res, next) => {
await userRepository.save(user);
} else {
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true, email: true },
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!account.id) {
logger.error('Plex ID was missing from Plex.tv response', {
label: 'API',
ip: req.ip,
email: account.email,
plexUsername: account.username,
});
return next({
status: 500,
message: 'Something went wrong. Try again.',
});
}
if (
account.id === mainUser.plexId ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id))
) {
if (user) {

View File

@@ -1,7 +1,5 @@
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
@@ -14,16 +12,14 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
import { z } from 'zod';
export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const settings = getSettings();
@@ -50,80 +46,25 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
const discoverRoutes = Router();
const QueryFilterOptions = z.object({
page: z.coerce.string().optional(),
sortBy: z.coerce.string().optional(),
primaryReleaseDateGte: z.coerce.string().optional(),
primaryReleaseDateLte: z.coerce.string().optional(),
firstAirDateGte: z.coerce.string().optional(),
firstAirDateLte: z.coerce.string().optional(),
studio: z.coerce.string().optional(),
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
voteAverageLte: z.coerce.string().optional(),
voteCountGte: z.coerce.string().optional(),
voteCountLte: z.coerce.string().optional(),
network: z.coerce.string().optional(),
watchProviders: z.coerce.string().optional(),
watchRegion: z.coerce.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
discoverRoutes.get('/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverMovies({
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
originalLanguage: query.language,
genre: query.genre,
studio: query.studio,
primaryReleaseDateLte: query.primaryReleaseDateLte
? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0]
: undefined,
primaryReleaseDateGte: query.primaryReleaseDateGte
? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0]
: undefined,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
studio: req.query.studio ? Number(req.query.studio) : undefined,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapMovieResult(
result,
@@ -222,7 +163,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.params.genreId as string,
genre: Number(req.params.genreId),
});
const media = await Media.getRelatedMedia(
@@ -269,7 +210,7 @@ discoverRoutes.get<{ studioId: string }>(
const data = await tmdb.getDiscoverMovies({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
studio: req.params.studioId as string,
studio: Number(req.params.studioId),
});
const media = await Media.getRelatedMedia(
@@ -355,52 +296,21 @@ discoverRoutes.get('/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage(req.user);
try {
const query = QueryFilterOptions.parse(req.query);
const keywords = query.keywords;
const data = await tmdb.getDiscoverTv({
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
? new Date(query.firstAirDateLte).toISOString().split('T')[0]
: undefined,
firstAirDateGte: query.firstAirDateGte
? new Date(query.firstAirDateGte).toISOString().split('T')[0]
: undefined,
originalLanguage: query.language,
keywords,
withRuntimeGte: query.withRuntimeGte,
withRuntimeLte: query.withRuntimeLte,
voteAverageGte: query.voteAverageGte,
voteAverageLte: query.voteAverageLte,
voteCountGte: query.voteCountGte,
voteCountLte: query.voteCountLte,
watchProviders: query.watchProviders,
watchRegion: query.watchRegion,
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.query.genre ? Number(req.query.genre) : undefined,
network: req.query.network ? Number(req.query.network) : undefined,
});
const media = await Media.getRelatedMedia(
data.results.map((result) => result.id)
);
let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
keywordData = await Promise.all(
splitKeywords.map(async (keywordId) => {
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
})
);
}
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
keywords: keywordData,
results: data.results.map((result) =>
mapTvResult(
result,
@@ -498,7 +408,7 @@ discoverRoutes.get<{ genreId: string }>(
const data = await tmdb.getDiscoverTv({
page: Number(req.query.page),
language: req.locale ?? (req.query.language as string),
genre: req.params.genreId,
genre: Number(req.params.genreId),
});
const media = await Media.getRelatedMedia(
@@ -654,8 +564,6 @@ discoverRoutes.get('/trending', async (req, res, next) => {
)
: isPerson(result)
? mapPersonResult(result)
: isCollection(result)
? mapCollectionResult(result)
: mapTvResult(
result,
media.find(
@@ -735,9 +643,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverMovies({
genre: genre.id.toString(),
});
const genreData = await tmdb.getDiscoverMovies({ genre: genre.id });
mappedGenres.push({
id: genre.id,
@@ -779,9 +685,7 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
await Promise.all(
genres.map(async (genre) => {
const genreData = await tmdb.getDiscoverTv({
genre: genre.id.toString(),
});
const genreData = await tmdb.getDiscoverTv({ genre: genre.id });
mappedGenres.push({
id: genre.id,
@@ -809,12 +713,12 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);
discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
discoverRoutes.get<{ page?: number }, WatchlistResponse>(
'/watchlist',
async (req, res) => {
const userRepository = getRepository(User);
const itemsPerPage = 20;
const page = Number(req.query.page) ?? 1;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const activeUser = await userRepository.findOne({
@@ -838,8 +742,8 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -1,39 +0,0 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
rateLimitOptions: {
maxRequests: 20,
maxRPS: 50,
},
});
/**
* Image Proxy
*/
router.get('/*', async (req, res) => {
const imagePath = req.path.replace('/image', '');
try {
const imageData = await tmdbImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy image', {
imagePath,
errorMessage: e.message,
});
res.status(500).send();
}
});
export default router;

View File

@@ -1,18 +1,14 @@
import GithubAPI from '@server/api/github';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import settingsRoutes from '@server/routes/settings';
@@ -106,38 +102,6 @@ router.get('/settings/public', async (req, res) => {
return res.status(200).json(settings.fullPublicSettings);
}
});
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = await sliderRepository.find({ order: { order: 'ASC' } });
return res.json(sliders);
});
router.get(
'/settings/notifications/pushover/sounds',
isAuthenticated(),
async (req, res, next) => {
const pushoverApi = new PushoverAPI();
try {
if (!req.query.token) {
throw new Error('Pushover application token missing from request');
}
const sounds = await pushoverApi.getSounds(req.query.token as string);
res.status(200).json(sounds);
} catch (e) {
logger.debug('Something went wrong retrieving Pushover sounds', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve Pushover sounds.',
});
}
}
);
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
@@ -305,87 +269,6 @@ router.get('/backdrops', async (req, res, next) => {
}
});
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
keywordId: Number(req.params.keywordId),
});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving keyword data', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve keyword data.',
});
}
});
router.get('/watchproviders/regions', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getAvailableWatchProviderRegions({});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving watch provider regions', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve watch provider regions.',
});
}
});
router.get('/watchproviders/movies', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getMovieWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving movie watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve movie watch providers.',
});
}
});
router.get('/watchproviders/tv', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getTvWatchProviders({
watchRegion: req.query.watchRegion as string,
});
return res.status(200).json(mapWatchProviderDetails(result));
} catch (e) {
logger.debug('Something went wrong retrieving tv watch providers', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve tv watch providers.',
});
}
});
router.get('/', (_req, res) => {
return res.status(200).json({
api: 'Overseerr API',

View File

@@ -308,9 +308,7 @@ issueRoutes.post<{ issueId: string }, Issue, { message: string }>(
issueRoutes.post<{ issueId: string; status: string }, Issue>(
'/:issueId/:status',
isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
isAuthenticated(Permission.MANAGE_ISSUES),
async (req, res, next) => {
const issueRepository = getRepository(Issue);
// Satisfy typescript here. User is set, we assure you!
@@ -323,16 +321,6 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
where: { id: Number(req.params.issueId) },
});
if (
!req.user?.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== req.user?.id
) {
return next({
status: 401,
message: 'You do not have permission to modify this issue.',
});
}
let newStatus: IssueStatus | undefined;
switch (req.params.status) {

View File

@@ -1,13 +1,7 @@
import TautulliAPI from '@server/api/tautulli';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import MediaRequest from '@server/entity/MediaRequest';
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type {
MediaResultsResponse,
@@ -104,8 +98,6 @@ mediaRoutes.post<
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const seasonRequestRepository = getRepository(SeasonRequest);
const media = await mediaRepository.findOne({
where: { id: Number(req.params.id) },
@@ -146,35 +138,6 @@ mediaRoutes.post<
media.status = MediaStatus.UNKNOWN;
}
if (req.params.status === 'available') {
// Here we check all related media requests and
// then set to completed if the media is marked
// as available
const requests = await requestRepository.find({
relations: {
media: true,
},
where: { media: { id: media.id }, is4k: is4k },
});
const requestIds = requests.map((request) => request.id);
if (requestIds.length > 0) {
await requestRepository.update(
{ id: In(requestIds) },
{ status: MediaRequestStatus.COMPLETED }
);
}
requests
.flatMap((request) => request.seasons)
.forEach(async (season) => {
await seasonRequestRepository.update(season.id, {
status: MediaRequestStatus.COMPLETED,
});
});
}
await mediaRepository.save(media);
return res.status(200).json(media);

View File

@@ -1,6 +1,4 @@
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import RottenTomatoes from '@server/api/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';
@@ -118,9 +116,6 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
}
});
/**
* Endpoint backed by RottenTomatoes
*/
movieRoutes.get('/:id/ratings', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
@@ -156,53 +151,4 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
}
});
/**
* Endpoint combining RottenTomatoes and IMDB
*/
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
const imdbApi = new IMDBRadarrProxy();
try {
const movie = await tmdb.getMovie({
movieId: Number(req.params.id),
});
const rtratings = await rtapi.getMovieRatings(
movie.title,
Number(movie.release_date.slice(0, 4))
);
let imdbRatings;
if (movie.imdb_id) {
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
}
if (!rtratings && !imdbRatings) {
return next({
status: 404,
message: 'No ratings found.',
});
}
const ratings: RatingResponse = {
...(rtratings ? { rt: rtratings } : {}),
...(imdbRatings ? { imdb: imdbRatings } : {}),
};
return res.status(200).json(ratings);
} catch (e) {
logger.debug('Something went wrong retrieving movie ratings', {
label: 'API',
errorMessage: e.message,
movieId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve movie ratings.',
});
}
});
export default movieRoutes;

View File

@@ -40,6 +40,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
switch (req.query.filter) {
case 'approved':
case 'processing':
case 'available':
statusFilter = [MediaRequestStatus.APPROVED];
break;
case 'pending':
@@ -54,18 +55,12 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
case 'failed':
statusFilter = [MediaRequestStatus.FAILED];
break;
case 'completed':
case 'available':
case 'deleted':
statusFilter = [MediaRequestStatus.COMPLETED];
break;
default:
statusFilter = [
MediaRequestStatus.PENDING,
MediaRequestStatus.APPROVED,
MediaRequestStatus.DECLINED,
MediaRequestStatus.FAILED,
MediaRequestStatus.COMPLETED,
];
}
@@ -84,9 +79,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
MediaStatus.PARTIALLY_AVAILABLE,
];
break;
case 'deleted':
mediaStatusFilter = [MediaStatus.DELETED];
break;
default:
mediaStatusFilter = [
MediaStatus.UNKNOWN,
@@ -94,7 +86,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
MediaStatus.PROCESSING,
MediaStatus.PARTIALLY_AVAILABLE,
MediaStatus.AVAILABLE,
MediaStatus.DELETED,
];
}
@@ -400,8 +391,7 @@ requestRoutes.put<{ requestId: string }>(
(r) =>
r.is4k === request.is4k &&
r.id !== request.id &&
r.status !== MediaRequestStatus.DECLINED &&
r.status !== MediaRequestStatus.COMPLETED
r.status !== MediaRequestStatus.DECLINED
)
.reduce((seasons, r) => {
const combinedSeasons = r.seasons.map(
@@ -502,10 +492,8 @@ requestRoutes.post<{
relations: { requestedBy: true, modifiedBy: true },
});
// this also triggers updating the parent media's status & sending to *arr
request.status = MediaRequestStatus.APPROVED;
await requestRepository.save(request);
await request.updateParentStatus();
await request.sendMedia();
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {

View File

@@ -56,50 +56,4 @@ searchRoutes.get('/', async (req, res, next) => {
}
});
searchRoutes.get('/keyword', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving keyword search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve keyword search results.',
});
}
});
searchRoutes.get('/company', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchCompany({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving company search results', {
label: 'API',
errorMessage: e.message,
query: req.query.query,
});
return next({
status: 500,
message: 'Unable to retrieve company search results.',
});
}
});
export default searchRoutes;

View File

@@ -183,7 +183,9 @@ serviceRoutes.get<{ tmdbId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
try {

View File

@@ -1,131 +0,0 @@
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import logger from '@server/logger';
import { Router } from 'express';
const discoverSettingRoutes = Router();
discoverSettingRoutes.post('/', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const sliders = req.body as DiscoverSlider[];
if (!Array.isArray(sliders)) {
return res.status(400).json({ message: 'Invalid request body.' });
}
for (let x = 0; x < sliders.length; x++) {
const slider = sliders[x];
const existingSlider = await sliderRepository.findOne({
where: {
id: slider.id,
},
});
if (existingSlider && slider.id) {
existingSlider.enabled = slider.enabled;
existingSlider.order = x;
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
} else {
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: slider.enabled,
order: x,
type: slider.type,
});
await sliderRepository.save(newSlider);
}
}
return res.json(sliders);
});
discoverSettingRoutes.post('/add', async (req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
const newSlider = new DiscoverSlider({
isBuiltIn: false,
data: slider.data,
title: slider.title,
enabled: false,
order: -1,
type: slider.type,
});
await sliderRepository.save(newSlider);
return res.json(newSlider);
});
discoverSettingRoutes.get('/reset', async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);
await sliderRepository.clear();
await DiscoverSlider.bootstrapSliders();
return res.status(204).send();
});
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
try {
const existingSlider = await sliderRepository.findOneOrFail({
where: {
id: Number(req.params.sliderId),
},
});
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
return res.status(200).json(existingSlider);
} catch (e) {
logger.error('Something went wrong updating a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be updated.' });
}
});
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
try {
const slider = await sliderRepository.findOneOrFail({
where: { id: Number(req.params.sliderId), isBuiltIn: false },
});
await sliderRepository.remove(slider);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong deleting a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be deleted.' });
}
});
export default discoverSettingRoutes;

View File

@@ -14,14 +14,12 @@ import type {
import { scheduledJobs } from '@server/job/schedule';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { JobId, MainSettings } from '@server/lib/settings';
import type { MainSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import discoverSettingRoutes from '@server/routes/settings/discover';
import { appDataPath } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { Router } from 'express';
@@ -41,7 +39,6 @@ const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (
user: User,
@@ -254,27 +251,25 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
Object.assign(settings.tautulli, req.body);
if (settings.tautulli.hostname) {
try {
const tautulliClient = new TautulliAPI(settings.tautulli);
try {
const tautulliClient = new TautulliAPI(settings.tautulli);
const result = await tautulliClient.getInfo();
const result = await tautulliClient.getInfo();
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
throw new Error('Tautulli version not supported');
}
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
throw new Error('Tautulli version not supported');
}
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
}
return res.status(200).json(settings.tautulli);
@@ -496,7 +491,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
});
});
settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: string }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -523,7 +518,7 @@ settingsRoutes.post<{ jobId: JobId }>(
}
);
settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: string }>(
'/jobs/:jobId/schedule',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -558,23 +553,16 @@ settingsRoutes.post<{ jobId: JobId }>(
}
);
settingsRoutes.get('/cache', async (_req, res) => {
const cacheManagerCaches = cacheManager.getAllCaches();
settingsRoutes.get('/cache', (req, res) => {
const caches = cacheManager.getAllCaches();
const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
},
});
return res.status(200).json(
Object.values(caches).map((cache) => ({
id: cache.id,
name: cache.name,
stats: cache.getStats(),
}))
);
});
settingsRoutes.post<{ cacheId: AvailableCacheIds }>(

View File

@@ -1,4 +1,4 @@
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import RottenTomatoes from '@server/api/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media';

View File

@@ -381,14 +381,7 @@ router.delete<{ id: string }>(
* we manually remove all requests from the user here so the parent media's
* properly reflect the change.
*/
await requestRepository.remove(user.requests, {
/**
* Break-up into groups of 1000 requests to be removed at a time.
* Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs.
* https://typeorm.io/repository-api#additional-options
*/
chunk: user.requests.length / 1000,
});
await requestRepository.remove(user.requests);
await userRepository.delete(user.id);
return res.status(200).json(user.filter());
@@ -614,7 +607,7 @@ router.get<{ id: string }, UserWatchDataResponse>(
}
);
router.get<{ id: string }, WatchlistResponse>(
router.get<{ id: string; page?: number }, WatchlistResponse>(
'/:id/watchlist',
async (req, res, next) => {
if (
@@ -634,7 +627,7 @@ router.get<{ id: string }, WatchlistResponse>(
}
const itemsPerPage = 20;
const page = Number(req.query.page) ?? 1;
const page = req.params.page ?? 1;
const offset = (page - 1) * itemsPerPage;
const user = await getRepository(User).findOneOrFail({
@@ -658,8 +651,8 @@ router.get<{ id: string }, WatchlistResponse>(
return res.json({
page,
totalPages: Math.ceil(watchlist.totalSize / itemsPerPage),
totalResults: watchlist.totalSize,
totalPages: Math.ceil(watchlist.size / itemsPerPage),
totalResults: watchlist.size,
results: watchlist.items.map((item) => ({
ratingKey: item.ratingKey,
title: item.title,

View File

@@ -262,7 +262,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
emailEnabled: settings.email.enabled,
emailEnabled: settings?.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled:
settings?.discord.enabled && settings.discord.options.enableMentions,
@@ -274,12 +274,11 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
pushoverSound: user.settings?.pushoverSound,
telegramEnabled: settings.telegram.enabled,
telegramBotUsername: settings.telegram.options.botUsername,
telegramEnabled: settings?.telegram.enabled,
telegramBotUsername: settings?.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user.settings?.telegramSendSilently,
webPushEnabled: settings.webpush.enabled,
telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {
@@ -330,7 +329,6 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
user.settings.pushoverApplicationToken =
req.body.pushoverApplicationToken;
user.settings.pushoverUserKey = req.body.pushoverUserKey;
user.settings.pushoverSound = req.body.pushoverSound;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.notificationTypes = Object.assign(
@@ -343,14 +341,13 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
userRepository.save(user);
return res.status(200).json({
pgpKey: user.settings.pgpKey,
discordId: user.settings.discordId,
pushbulletAccessToken: user.settings.pushbulletAccessToken,
pushoverApplicationToken: user.settings.pushoverApplicationToken,
pushoverUserKey: user.settings.pushoverUserKey,
pushoverSound: user.settings.pushoverSound,
telegramChatId: user.settings.telegramChatId,
telegramSendSilently: user.settings.telegramSendSilently,
pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId,
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
notificationTypes: user.settings.notificationTypes,
});
} catch (e) {

View File

@@ -4,7 +4,6 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import IssueComment from '@server/entity/IssueComment';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
@@ -33,10 +32,6 @@ export class IssueCommentSubscriber
})
).issue;
const createdBy = await getRepository(User).findOneOrFail({
where: { id: issue.createdBy.id },
});
const media = await getRepository(Media).findOneOrFail({
where: { id: issue.media.id },
});
@@ -76,9 +71,9 @@ export class IssueCommentSubscriber
notifyAdmin: true,
notifySystem: true,
notifyUser:
!createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
createdBy.id !== entity.user.id
? createdBy
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== entity.user.id
? issue.createdBy
: undefined,
});
}

View File

@@ -87,7 +87,6 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
notifySystem: true,
notifyUser:
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
entity.modifiedBy?.id !== entity.createdBy.id &&
(type === Notification.ISSUE_RESOLVED ||
type === Notification.ISSUE_REOPENED)
? entity.createdBy

View File

@@ -1,128 +0,0 @@
import TheMovieDb from '@server/api/themoviedb';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { MediaRequest } from '@server/entity/MediaRequest';
import notificationManager, { Notification } from '@server/lib/notifications';
import logger from '@server/logger';
import { truncate } from 'lodash';
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
import { EventSubscriber } from 'typeorm';
@EventSubscriber()
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
{
private async notifyAvailableMovie(entity: MediaRequest) {
if (entity.media.status === MediaStatus.AVAILABLE) {
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({
movieId: entity.media.tmdbId,
});
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: entity.media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
private async notifyAvailableSeries(entity: MediaRequest) {
// Find all seasons in the related media entity
// and see if they are available, then we can check
// if the request contains the same seasons
const isMediaAvailable = entity.media.seasons
.filter(
(season) =>
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
)
.every((seasonRequest) =>
entity.seasons.find(
(season) => season.seasonNumber === seasonRequest.seasonNumber
)
);
if (entity.media.status === MediaStatus.AVAILABLE || isMediaAvailable) {
const tmdb = new TheMovieDb();
try {
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: entity.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity.media,
extra: [
{
name: 'Requested Seasons',
value: entity.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: entity,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
if (!event.entity) {
return;
}
if (event.entity.status === MediaRequestStatus.COMPLETED) {
if (event.entity.media.mediaType === MediaType.MOVIE) {
this.notifyAvailableMovie(event.entity as MediaRequest);
}
if (event.entity.media.mediaType === MediaType.TV) {
this.notifyAvailableSeries(event.entity as MediaRequest);
}
}
}
public listenTo(): typeof MediaRequest {
return MediaRequest;
}
}

View File

@@ -1,12 +1,179 @@
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import TheMovieDb from '@server/api/themoviedb';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import Season from '@server/entity/Season';
import notificationManager, { Notification } from '@server/lib/notifications';
import logger from '@server/logger';
import { truncate } from 'lodash';
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
import { EventSubscriber } from 'typeorm';
import { EventSubscriber, In, Not } from 'typeorm';
@EventSubscriber()
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
private async notifyAvailableMovie(
entity: Media,
dbEntity: Media,
is4k: boolean
) {
if (
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
) {
if (entity.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: {
media: {
id: entity.id,
},
is4k,
status: Not(MediaRequestStatus.DECLINED),
},
});
if (relatedRequests.length > 0) {
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
relatedRequests.forEach((request) => {
notificationManager.sendNotification(
Notification.MEDIA_AVAILABLE,
{
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
notifyAdmin: false,
notifySystem: true,
notifyUser: request.requestedBy,
subject: `${movie.title}${
movie.release_date
? ` (${movie.release_date.slice(0, 4)})`
: ''
}`,
message: truncate(movie.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request,
}
);
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
}
}
private async notifyAvailableSeries(
entity: Media,
dbEntity: Media,
is4k: boolean
) {
const seasonRepository = getRepository(Season);
const newAvailableSeasons = entity.seasons
.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
)
.map((season) => season.seasonNumber);
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) });
const oldAvailableSeasons = oldSeasons
.filter(
(season) =>
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
)
.map((season) => season.seasonNumber);
const changedSeasons = newAvailableSeasons.filter(
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
);
if (changedSeasons.length > 0) {
const tmdb = new TheMovieDb();
const requestRepository = getRepository(MediaRequest);
const processedSeasons: number[] = [];
for (const changedSeasonNumber of changedSeasons) {
const requests = await requestRepository.find({
where: {
media: {
id: entity.id,
},
is4k,
status: Not(MediaRequestStatus.DECLINED),
},
});
const request = requests.find(
(request) =>
// Check if the season is complete AND it contains the current season that was just marked available
request.seasons.every((season) =>
newAvailableSeasons.includes(season.seasonNumber)
) &&
request.seasons.some(
(season) => season.seasonNumber === changedSeasonNumber
)
);
if (request && !processedSeasons.includes(changedSeasonNumber)) {
processedSeasons.push(
...request.seasons.map((season) => season.seasonNumber)
);
try {
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
message: truncate(tv.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifySystem: true,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity,
extra: [
{
name: 'Requested Seasons',
value: request.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request,
});
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {
label: 'Notifications',
errorMessage: e.message,
mediaId: entity.id,
});
}
}
}
}
}
private async updateChildRequestStatus(event: Media, is4k: boolean) {
const requestRepository = getRepository(MediaRequest);
@@ -30,6 +197,52 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
return;
}
if (
event.entity.mediaType === MediaType.MOVIE &&
event.entity.status === MediaStatus.AVAILABLE
) {
this.notifyAvailableMovie(
event.entity as Media,
event.databaseEntity,
false
);
}
if (
event.entity.mediaType === MediaType.MOVIE &&
event.entity.status4k === MediaStatus.AVAILABLE
) {
this.notifyAvailableMovie(
event.entity as Media,
event.databaseEntity,
true
);
}
if (
event.entity.mediaType === MediaType.TV &&
(event.entity.status === MediaStatus.AVAILABLE ||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
) {
this.notifyAvailableSeries(
event.entity as Media,
event.databaseEntity,
false
);
}
if (
event.entity.mediaType === MediaType.TV &&
(event.entity.status4k === MediaStatus.AVAILABLE ||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
) {
this.notifyAvailableSeries(
event.entity as Media,
event.databaseEntity,
true
);
}
if (
event.entity.status === MediaStatus.AVAILABLE &&
event.databaseEntity.status === MediaStatus.PENDING

View File

@@ -1,9 +0,0 @@
import 'express-session';
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
interface SessionData {
userId: number;
}
}

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { User } from '@server/entity/User';
import type { NextFunction, Request, Response } from 'express';
import 'express-session';
declare global {
namespace Express {
@@ -17,3 +16,11 @@ declare global {
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
}
// Declaration merging to apply our own types to SessionData
// See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23)
declare module 'express-session' {
export interface SessionData {
userId: number;
}
}

View File

@@ -1,5 +1,4 @@
import type {
TmdbCollectionResult,
TmdbMovieDetails,
TmdbMovieResult,
TmdbPersonDetails,
@@ -9,35 +8,17 @@ import type {
} from '@server/api/themoviedb/interfaces';
export const isMovie = (
movie:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
): movie is TmdbMovieResult => {
return (movie as TmdbMovieResult).title !== undefined;
};
export const isPerson = (
person:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult
): person is TmdbPersonResult => {
return (person as TmdbPersonResult).known_for !== undefined;
};
export const isCollection = (
collection:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
): collection is TmdbCollectionResult => {
return (collection as TmdbCollectionResult).media_type === 'collection';
};
export const isMovieDetails = (
movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails
): movie is TmdbMovieDetails => {

View File

@@ -16,9 +16,9 @@ architectures:
parts:
overseerr:
plugin: nodejs
nodejs-version: '18.18.2'
nodejs-version: '16.17.0'
nodejs-package-manager: 'yarn'
nodejs-yarn-version: v1.22.19
nodejs-yarn-version: v1.22.17
build-packages:
- git
- on arm64:

View File

@@ -37,7 +37,6 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
})}
</Badge>
{showRelative && (

View File

@@ -10,14 +10,13 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { DownloadIcon } from '@heroicons/react/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -40,19 +39,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const [requestModal, setRequestModal] = useState(false);
const [is4k, setIs4k] = useState(false);
const returnCollectionDownloadItems = (data: Collection | undefined) => {
const [downloadStatus, downloadStatus4k] = [
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus ? item.mediaInfo?.downloadStatus : []
),
data?.parts.flatMap((item) =>
item.mediaInfo?.downloadStatus4k ? item.mediaInfo?.downloadStatus4k : []
),
];
return { downloadStatus, downloadStatus4k };
};
const {
data,
error,
@@ -60,31 +46,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection,
revalidateOnMount: true,
refreshInterval: refreshIntervalHelper(
returnCollectionDownloadItems(collection),
15000
),
});
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
const [downloadStatus, downloadStatus4k] = useMemo(() => {
const downloadItems = returnCollectionDownloadItems(data);
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
}, [data]);
const [titles, titles4k] = useMemo(() => {
return [
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus ?? []).length > 0)
.map((title) => title.title),
data?.parts
.filter((media) => (media.mediaInfo?.downloadStatus4k ?? []).length > 0)
.map((title) => title.title),
];
}, [data?.parts]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -239,8 +205,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
<div className="media-status">
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
@@ -254,8 +218,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
) && (
<StatusBadge
status={collectionStatus4k}
downloadItem={downloadStatus4k}
title={titles4k}
is4k
inProgress={data.parts.some(
(part) =>
@@ -288,7 +250,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}}
text={
<>
<ArrowDownTrayIcon />
<DownloadIcon />
<span>
{intl.formatMessage(
hasRequestable
@@ -307,7 +269,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
setIs4k(true);
}}
>
<ArrowDownTrayIcon />
<DownloadIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
@@ -348,7 +310,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
/>
))}
/>
<div className="extra-bottom-space relative" />
<div className="pb-8" />
</div>
);
};

View File

@@ -1,8 +1,8 @@
import {
ExclamationTriangleIcon,
ExclamationIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/solid';
} from '@heroicons/react/solid';
interface AlertProps {
title?: React.ReactNode;
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
svg: <ExclamationIcon className="h-5 w-5" />,
};
switch (type) {

View File

@@ -71,7 +71,7 @@ const Badge = (
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
);
if (href) {
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
}
}

View File

@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
@@ -61,7 +61,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'warning':
buttonStyle.push(
'text-white border border-yellow-500 bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
'text-white border border-yellow-500 backdrop-blur bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
);
break;
case 'success':
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:

View File

@@ -1,7 +1,7 @@
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import { ChevronDownIcon } from '@heroicons/react/solid';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import { Fragment, useRef, useState } from 'react';
@@ -101,12 +101,12 @@ const ButtonWithDropdown = ({
<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"
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75 opacity-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform 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

View File

@@ -1,27 +1,18 @@
import useSettings from '@app/hooks/useSettings';
import type { ImageLoader, ImageProps } from 'next/image';
import type { ImageProps } from 'next/image';
import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/
const CachedImage = ({ src, ...props }: ImageProps) => {
const CachedImage = (props: ImageProps) => {
const { currentSettings } = useSettings();
let imageUrl = src;
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
};
export default CachedImage;

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