Compare commits

..

2 Commits

Author SHA1 Message Date
TheCatLady
30275450cc fix: use new SettingsBadge component 2022-08-28 23:21:59 -07:00
TheCatLady
feffccaca8 feat: add 'Hide Requested Media' setting 2022-08-28 23:21:58 -07:00
249 changed files with 6471 additions and 23047 deletions

View File

@@ -737,168 +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"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
@@ -907,7 +745,5 @@
"projectOwner": "sct",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": false,
"commitConvention": "angular",
"commitType": "docs"
"skipCi": false
}

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

@@ -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,6 +71,15 @@ 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
@@ -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

@@ -14,7 +14,7 @@ jobs:
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

@@ -67,9 +67,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 +91,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 }}
@@ -110,9 +109,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

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

235
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-98-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,134 +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>
</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

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

@@ -1,25 +0,0 @@
describe('Pull To Refresh', () => {
beforeEach(() => {
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
cy.viewport(390, 844);
cy.visitMobile('/');
});
it('reloads the current page', () => {
cy.wait(500);
cy.intercept({
method: 'GET',
url: '/api/v1/*',
}).as('apiCall');
cy.get('.searchbar').swipe('bottom', [190, 500]);
cy.wait('@apiCall').then((interception) => {
assert.isNotNull(
interception.response.body,
'API was called and received data'
);
});
});
});

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

@@ -1,5 +1,4 @@
/// <reference types="cypress" />
import 'cy-mobile-commands';
Cypress.Commands.add('login', (email, password) => {
cy.session(

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

@@ -17,8 +17,4 @@ module.exports = {
return config;
},
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
@@ -123,6 +121,9 @@ components:
hideAvailable:
type: boolean
example: false
hideRequested:
type: boolean
example: false
partialRequestsEnabled:
type: boolean
example: false
@@ -602,17 +603,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 +1042,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`
requests:
type: array
readOnly: true
@@ -1795,40 +1783,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
@@ -2524,44 +2478,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
@@ -2603,12 +2542,6 @@ paths:
nullable: true
enum: [debug, info, warn, error]
default: debug
- in: query
name: search
schema:
type: string
nullable: true
example: plex
responses:
'200':
description: Server log returned
@@ -3091,133 +3024,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
@@ -3615,7 +3421,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:
@@ -3711,7 +3517,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:
@@ -4038,86 +3844,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
@@ -4139,73 +3865,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
@@ -4428,73 +4094,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
@@ -4976,13 +4582,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
@@ -5061,7 +4663,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:
@@ -5072,37 +4674,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
@@ -5173,7 +4744,7 @@ paths:
required: true
schema:
type: string
enum: [approve, decline]
enum: [pending, approve, decline, available]
responses:
'200':
description: Request status changed
@@ -5338,63 +4909,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
@@ -6350,89 +5864,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

@@ -29,147 +29,140 @@
},
"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",
"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",
"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/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 +220,7 @@
{
"path": "semantic-release-docker-buildx",
"buildArgs": {
"COMMIT_TAG": "$GIT_SHA"
"COMMIT_TAG": "$GITHUB_SHA"
},
"imageNames": [
"sctx/overseerr",

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,17 +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)}`,
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,
},
uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor(
options.addedAt / 1000
)}`,
});
return response.MediaContainer.Metadata;

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

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

@@ -114,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;
@@ -260,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,
@@ -272,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,
@@ -286,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,
@@ -298,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,

View File

@@ -704,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 &&
@@ -764,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 = {
@@ -949,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();
@@ -984,7 +934,7 @@ export class MediaRequest {
(keyword) => keyword.id === ANIME_KEYWORD_ID
)
) {
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
seriesType = 'anime';
}
let rootFolder =
@@ -1002,11 +952,7 @@ export class MediaRequest {
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
? [...sonarrSettings.animeTags]
: []
: sonarrSettings.tags
? [...sonarrSettings.tags]
: [];
: sonarrSettings.tags;
if (
this.rootFolder &&
@@ -1058,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,
@@ -1255,5 +1169,3 @@ export class MediaRequest {
}
}
}
export default MediaRequest;

View File

@@ -1,7 +1,5 @@
import { MediaRequestStatus } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import {
AfterRemove,
Column,
CreateDateColumn,
Entity,
@@ -36,18 +34,6 @@ class SeasonRequest {
constructor(init?: Partial<SeasonRequest>) {
Object.assign(this, init);
}
@AfterRemove()
public async handleRemoveParent(): Promise<void> {
const mediaRequestRepository = getRepository(MediaRequest);
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
where: { id: this.request.id },
});
if (requestToBeDeleted.seasons.length === 0) {
await mediaRequestRepository.delete({ id: this.request.id });
}
}
}
export default SeasonRequest;

View File

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

@@ -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');
@@ -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

@@ -25,9 +25,8 @@ export interface PublicSettingsResponse {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideRequested: boolean;
localLogin: boolean;
movieEnabled: boolean;
seriesEnabled: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
@@ -53,11 +52,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

@@ -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,8 +13,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
cronSchedule: string;
interval: 'short' | 'long' | 'fixed';
running?: () => boolean;
cancelFn?: () => void;
}
@@ -32,8 +28,7 @@ export const startJobs = (): void => {
id: 'plex-recently-added-scan',
name: 'Plex Recently Added Scan',
type: 'process',
interval: 'minutes',
cronSchedule: jobs['plex-recently-added-scan'].schedule,
interval: 'short',
job: schedule.scheduleJob(jobs['plex-recently-added-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Recently Added Scan', {
label: 'Jobs',
@@ -49,8 +44,7 @@ export const startJobs = (): void => {
id: 'plex-full-scan',
name: 'Plex Full Library Scan',
type: 'process',
interval: 'hours',
cronSchedule: jobs['plex-full-scan'].schedule,
interval: 'long',
job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => {
logger.info('Starting scheduled job: Plex Full Library Scan', {
label: 'Jobs',
@@ -61,38 +55,26 @@ 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',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
interval: 'long',
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',
cronSchedule: jobs['radarr-scan'].schedule,
interval: 'long',
job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
radarrScanner.run();
@@ -106,8 +88,7 @@ export const startJobs = (): void => {
id: 'sonarr-scan',
name: 'Sonarr Scan',
type: 'process',
interval: 'hours',
cronSchedule: jobs['sonarr-scan'].schedule,
interval: 'long',
job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
sonarrScanner.run();
@@ -116,30 +97,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',
cronSchedule: jobs['download-sync'].schedule,
interval: 'fixed',
job: schedule.scheduleJob(jobs['download-sync'].schedule, () => {
logger.debug('Starting scheduled job: Download Sync', {
label: 'Jobs',
@@ -153,8 +116,7 @@ export const startJobs = (): void => {
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
interval: 'hours',
cronSchedule: jobs['download-sync-reset'].schedule,
interval: 'long',
job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
@@ -163,21 +125,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,725 +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 SeasonRequest from '@server/entity/SeasonRequest';
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 findMediaStatus(
requests: MediaRequest[],
is4k: boolean
): MediaStatus {
const filteredRequests = requests.filter(
(request) => request.is4k === is4k
);
let mediaStatus: MediaStatus;
if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.APPROVED
)
) {
mediaStatus = MediaStatus.PROCESSING;
} else if (
filteredRequests.some(
(request) => request.status === MediaRequestStatus.PENDING
)
) {
mediaStatus = MediaStatus.PENDING;
} else {
mediaStatus = MediaStatus.UNKNOWN;
}
return mediaStatus;
}
private async mediaUpdater(media: Media, is4k: boolean): Promise<void> {
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
try {
// Find all related requests only if
// the related media has an available status
const requests = await requestRepository
.createQueryBuilder('request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', {
id: media.id,
})
.andWhere(
`(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`,
{
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
)
.getMany();
// Check if a season is processing or pending to
// make sure we set the media to the correct status
let mediaStatus = MediaStatus.UNKNOWN;
if (media.mediaType === 'tv') {
mediaStatus = this.findMediaStatus(requests, is4k);
}
media[is4k ? 'status4k' : 'status'] = mediaStatus;
media[is4k ? 'serviceId4k' : 'serviceId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'serviceId4k' : 'serviceId']
: null;
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
: null;
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
mediaStatus === MediaStatus.PROCESSING
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
: null;
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
mediaStatus === MediaStatus.PROCESSING
? 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 unknown.`,
{ label: 'AvailabilitySync' }
);
await mediaRepository.save({ media, ...media });
// Only delete media request if type is movie.
// Type tv request deletion is handled
// in the season request entity
if (requests.length > 0 && media.mediaType === 'movie') {
await requestRepository.remove(requests);
}
} 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);
const seasonRequestRepository = getRepository(SeasonRequest);
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)
);
const seasonKeys = [...seasonsPendingRemoval.keys()];
try {
// Need to check and see if there are any related season
// requests. If they are, we will need to delete them.
const seasonRequests = await seasonRequestRepository
.createQueryBuilder('seasonRequest')
.leftJoinAndSelect('seasonRequest.request', 'request')
.leftJoinAndSelect('request.media', 'media')
.where('(media.id = :id)', { id: media.id })
.andWhere(
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
{
seasonNumbers: seasonKeys,
is4k: is4k,
}
)
.getMany();
for (const mediaSeason of media.seasons) {
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
}
}
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 });
if (seasonRequests.length > 0) {
await seasonRequestRepository.remove(seasonRequests);
}
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 unknown.`,
{ 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) {
const radarrAPI = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
try {
let radarr: RadarrMovie | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
radarr = await radarrAPI.getMovie({
id: media.externalServiceId,
});
}
if (server.is4k && 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) {
const sonarrAPI = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
try {
let sonarr: SonarrSeries | undefined;
if (!server.is4k && media.externalServiceId && !is4k) {
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
sonarr.seasons;
}
if (server.is4k && 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

@@ -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;
@@ -97,6 +94,7 @@ export interface MainSettings {
tv: Quota;
};
hideAvailable: boolean;
hideRequested: boolean;
localLogin: boolean;
newPlexLogin: boolean;
region: string;
@@ -114,9 +112,8 @@ interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideRequested: boolean;
localLogin: boolean;
movieEnabled: boolean;
seriesEnabled: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
region: string;
@@ -252,9 +249,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;
@@ -294,6 +289,7 @@ class Settings {
tv: {},
},
hideAvailable: false,
hideRequested: false,
localLogin: true,
newPlexLogin: true,
region: '',
@@ -382,7 +378,7 @@ class Settings {
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {
@@ -415,18 +411,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) {
@@ -492,13 +482,8 @@ class Settings {
applicationTitle: this.data.main.applicationTitle,
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
hideRequested: this.data.main.hideRequested,
localLogin: this.data.main.localLogin,
movieEnabled: this.data.radarr.some(
(radarr) => !radarr.is4k && radarr.isDefault
),
seriesEnabled: this.data.sonarr.some(
(sonarr) => !sonarr.is4k && sonarr.isDefault
),
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),
@@ -561,7 +546,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

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

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

@@ -4,14 +4,11 @@ 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';
@@ -105,13 +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.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
@@ -279,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,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

@@ -492,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,20 +14,18 @@ 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';
import rateLimit from 'express-rate-limit';
import fs from 'fs';
import { escapeRegExp, merge, omit, set, sortBy } from 'lodash';
import { merge, omit, set, sortBy } from 'lodash';
import { rescheduleJob } from 'node-schedule';
import path from 'path';
import semver from 'semver';
@@ -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);
@@ -349,8 +344,6 @@ settingsRoutes.get(
(req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
const search = (req.query.search as string) ?? '';
const searchRegexp = new RegExp(escapeRegExp(search), 'i');
let filter: string[] = [];
switch (req.query.filter) {
@@ -382,22 +375,6 @@ settingsRoutes.get(
'data',
];
const deepValueStrings = (obj: Record<string, unknown>): string[] => {
const values = [];
for (const val of Object.values(obj)) {
if (typeof val === 'string') {
values.push(val);
} else if (typeof val === 'number') {
values.push(val.toString());
} else if (val !== null && typeof val === 'object') {
values.push(...deepValueStrings(val as Record<string, unknown>));
}
}
return values;
};
try {
fs.readFileSync(logFile, 'utf-8')
.split('\n')
@@ -422,19 +399,6 @@ settingsRoutes.get(
});
}
if (req.query.search) {
if (
// label and data are sometimes undefined
!searchRegexp.test(logMessage.label ?? '') &&
!searchRegexp.test(logMessage.message) &&
!deepValueStrings(logMessage.data ?? {}).some((val) =>
searchRegexp.test(val)
)
) {
return;
}
}
logs.push(logMessage);
});
@@ -469,7 +433,6 @@ settingsRoutes.get('/jobs', (_req, res) => {
name: job.name,
type: job.type,
interval: job.interval,
cronSchedule: job.cronSchedule,
nextExecutionTime: job.job.nextInvocation(),
running: job.running ? job.running() : false,
}))
@@ -490,13 +453,12 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
});
settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: string }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -516,14 +478,13 @@ settingsRoutes.post<{ jobId: JobId }>(
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
}
);
settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: string }>(
'/jobs/:jobId/schedule',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
@@ -541,14 +502,11 @@ settingsRoutes.post<{ jobId: JobId }>(
settings.jobs[scheduledJob.id].schedule = req.body.schedule;
settings.save();
scheduledJob.cronSchedule = req.body.schedule;
return res.status(200).json({
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
interval: scheduledJob.interval,
cronSchedule: scheduledJob.cronSchedule,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
@@ -558,23 +516,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

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

@@ -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 />;
}
@@ -129,7 +95,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}
const hasRequestable =
settings.currentSettings.movieEnabled &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) &&
@@ -238,19 +203,12 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
</div>
<div className="media-title">
<div className="media-status">
{settings.currentSettings.movieEnabled &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) && (
<StatusBadge
status={collectionStatus}
downloadItem={downloadStatus}
title={titles}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -260,8 +218,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
) && (
<StatusBadge
status={collectionStatus4k}
downloadItem={downloadStatus4k}
title={titles4k}
is4k
inProgress={data.parts.some(
(part) =>
@@ -294,7 +250,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}}
text={
<>
<ArrowDownTrayIcon />
<DownloadIcon />
<span>
{intl.formatMessage(
hasRequestable
@@ -313,7 +269,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
setIs4k(true);
}}
>
<ArrowDownTrayIcon />
<DownloadIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
@@ -346,7 +302,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -355,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;

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { forwardRef, useRef, useState } from 'react';
import { useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,51 +9,50 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
({ onClick, children, confirmText, className }, parentRef) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
ref={parentRef}
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
<div
ref={ref}
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? 'translate-y-0 opacity-100'
: 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
}
);
ConfirmButton.displayName = 'ConfirmButton';
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
export default ConfirmButton;

View File

@@ -5,7 +5,6 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
CollectionResult,
MovieResult,
PersonResult,
TvResult,
@@ -13,7 +12,7 @@ import type {
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
items?: (TvResult | MovieResult | PersonResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
@@ -61,7 +60,6 @@ const ListView = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.title}
userScore={title.voteAverage}
@@ -80,7 +78,6 @@ const ListView = ({
id={title.id}
image={title.posterPath}
status={title.mediaInfo?.status}
status4k={title.mediaInfo?.status4k}
summary={title.overview}
title={title.name}
userScore={title.voteAverage}
@@ -93,18 +90,6 @@ const ListView = ({
/>
);
break;
case 'collection':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
summary={title.overview}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'person':
titleCard = (
<PersonCard

View File

@@ -78,10 +78,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition-opacity duration-300"
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
@@ -89,10 +89,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
<Transition
appear
as={Fragment}
enter="transition duration-300"
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition-opacity duration-300"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
@@ -102,7 +102,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -111,10 +111,10 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
}}
appear
as="div"
enter="transition duration-300"
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition-opacity duration-300"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}

View File

@@ -1,113 +0,0 @@
import Tooltip from '@app/components/Common/Tooltip';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useEffect, useRef } from 'react';
type MultiRangeSliderProps = {
min: number;
max: number;
defaultMinValue?: number;
defaultMaxValue?: number;
subText?: string;
onUpdateMin: (min: number) => void;
onUpdateMax: (max: number) => void;
};
const MultiRangeSlider = ({
min,
max,
defaultMinValue,
defaultMaxValue,
subText,
onUpdateMin,
onUpdateMax,
}: MultiRangeSliderProps) => {
const touched = useRef(false);
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
defaultMinValue ?? min
);
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
defaultMaxValue ?? max
);
const minThumb = ((valueMin - min) / (max - min)) * 100;
const maxThumb = ((valueMax - min) / (max - min)) * 100;
useEffect(() => {
if (touched.current) {
onUpdateMin(finalValueMin);
}
}, [finalValueMin, onUpdateMin]);
useEffect(() => {
if (touched.current) {
onUpdateMax(finalValueMax);
}
}, [finalValueMax, onUpdateMax]);
useEffect(() => {
touched.current = false;
setValueMax(defaultMaxValue ?? max);
setValueMin(defaultMinValue ?? min);
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
return (
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
<Tooltip
content={valueMin.toString()}
tooltipConfig={{
placement: 'top',
}}
>
<input
type="range"
min={min}
max={max}
value={valueMin}
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
}`}
onChange={(e) => {
const value = Number(e.target.value);
if (value <= valueMax) {
touched.current = true;
setValueMin(value);
}
}}
/>
</Tooltip>
<Tooltip content={valueMax}>
<input
type="range"
min={min}
max={max}
value={valueMax}
step="1"
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= valueMin) {
touched.current = true;
setValueMax(value);
}
}}
/>
</Tooltip>
<div
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
style={{
left: `${minThumb}%`,
right: `${100 - maxThumb}%`,
}}
/>
{subText && (
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
<span>{subText}</span>
</div>
)}
</div>
);
};
export default MultiRangeSlider;

View File

@@ -1,4 +1,4 @@
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { Field } from 'formik';
import { useState } from 'react';
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
type="button"
className="input-action"
>
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
</button>
</>
);

View File

@@ -1,38 +0,0 @@
type SlideCheckboxProps = {
onClick: () => void;
checked?: boolean;
};
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
return (
<span
role="checkbox"
tabIndex={0}
aria-checked={false}
onClick={() => {
onClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
onClick();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
>
<span
aria-hidden="true"
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);
};
export default SlideCheckbox;

View File

@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import { Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { XIcon } from '@heroicons/react/outline';
import { Fragment, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
@@ -37,10 +37,10 @@ const SlideOver = ({
as={Fragment}
show={show}
appear
enter="transition-opacity ease-in-out duration-300"
enter="opacity-0 transition ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leave="opacity-100 transition ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
@@ -58,20 +58,20 @@ const SlideOver = ({
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
appear
enter="transition-transform ease-in-out duration-500 sm:duration-700"
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform ease-in-out duration-500 sm:duration-700"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover relative h-full w-screen max-w-md p-2 sm:p-3"
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-overseerr text-2xl font-bold leading-7">
@@ -83,7 +83,7 @@ const SlideOver = ({
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XMarkIcon className="h-6 w-6" />
<XIcon className="h-6 w-6" />
</button>
</div>
</div>
@@ -95,10 +95,8 @@ const SlideOver = ({
</div>
)}
</header>
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 px-4 py-6 text-white">
{children}
</div>
<div className="relative flex-1 px-4 py-6 text-white">
{children}
</div>
</div>
</div>

View File

@@ -1,71 +0,0 @@
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
status: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
// Should the badge shrink on mobile to a smaller size? (TitleCard)
shrink?: boolean;
}
const StatusBadgeMini = ({
status,
is4k = false,
inProgress = false,
shrink = false,
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full bg-opacity-80 shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
}`,
];
let indicatorIcon: React.ReactNode;
switch (status) {
case MediaStatus.PROCESSING:
badgeStyle.push(
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
);
indicatorIcon = <ClockIcon />;
break;
case MediaStatus.AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <CheckCircleIcon />;
break;
case MediaStatus.PENDING:
badgeStyle.push(
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <MinusSmallIcon />;
break;
}
if (inProgress) {
indicatorIcon = <Spinner />;
}
return (
<div
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
shrink ? '' : 'ring-1'
}`}
>
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
</div>
);
};
export default StatusBadgeMini;

View File

@@ -1,24 +0,0 @@
import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = {
children: React.ReactNode;
iconSvg?: JSX.Element;
};
const Tag = ({ children, iconSvg }: TagProps) => {
return (
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" />
)}
<span>{children}</span>
</div>
);
};
export default Tag;

View File

@@ -1,5 +1,4 @@
import React from 'react';
import ReactDOM from 'react-dom';
import type { Config } from 'react-popper-tooltip';
import { usePopperTooltip } from 'react-popper-tooltip';
@@ -7,15 +6,9 @@ type TooltipProps = {
content: React.ReactNode;
children: React.ReactElement;
tooltipConfig?: Partial<Config>;
className?: string;
};
const Tooltip = ({
children,
content,
tooltipConfig,
className,
}: TooltipProps) => {
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
followCursor: true,
@@ -24,30 +17,20 @@ const Tooltip = ({
...tooltipConfig,
});
const tooltipStyle = [
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
];
if (className) {
tooltipStyle.push(className);
}
return (
<>
{React.cloneElement(children, { ref: setTriggerRef })}
{visible &&
content &&
ReactDOM.createPortal(
<div
ref={setTooltipRef}
{...getTooltipProps({
className: tooltipStyle.join(' '),
})}
>
{content}
</div>,
document.body
)}
{visible && (
<div
ref={setTooltipRef}
{...getTooltipProps({
className:
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
})}
>
{content}
</div>
)}
</>
);
};

View File

@@ -1,4 +1,3 @@
import CachedImage from '@app/components/Common/CachedImage';
import Link from 'next/link';
import { useState } from 'react';
@@ -31,15 +30,11 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
role="link"
tabIndex={0}
>
<div className="relative h-full w-full">
<CachedImage
src={image}
alt={name}
className="relative z-40 h-full w-full"
layout="fill"
objectFit="contain"
/>
</div>
<img
src={image}
alt={name}
className="relative z-40 max-h-full max-w-full"
/>
<div
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'

View File

@@ -1,28 +0,0 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
import type { ProductionCompany, TvNetwork } from '@server/models/common';
import useSWR from 'swr';
type CompanyTagProps = {
type: 'studio' | 'network';
companyId: number;
};
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
`/api/v1/${type}/${companyId}`
);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
};
export default CompanyTag;

View File

@@ -1,570 +0,0 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { WatchProviderSelector } from '@app/components/Selector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
providetmdbsearch: 'Provide a search query',
providetmdbstudio: 'Provide TMDB Studio ID',
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
editsuccess: 'Edited slider and saved discover customization settings.',
editfail: 'Failed to edit slider.',
needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
});
type CreateSliderProps = {
onCreate: () => void;
slider?: Partial<DiscoverSlider>;
};
type CreateOption = {
type: DiscoverSliderType;
title: string;
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText?: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
if (slider) {
const loadDefaultKeywords = async (): Promise<void> => {
if (!slider.data) {
return;
}
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
const loadDefaultGenre = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
label: genre?.name ?? '',
value: genre?.id ?? 0,
},
]);
};
const loadDefaultCompany = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
loadDefaultKeywords();
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
case DiscoverSliderType.TMDB_TV_GENRE:
loadDefaultGenre();
break;
case DiscoverSliderType.TMDB_STUDIO:
loadDefaultCompany();
break;
}
}
}, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
intl.formatMessage(messages.validationTitlerequired)
),
data: Yup.string().required(
intl.formatMessage(messages.validationDatarequired)
),
});
const updateResultCount = useCallback(
(count: number) => {
setResultCount(count);
},
[setResultCount]
);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const options: CreateOption[] = [
{
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
dataUrl: '/api/v1/discover/movies',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_TV_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
dataUrl: '/api/v1/discover/tv',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
dataUrl: '/api/v1/discover/movies/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_TV_GENRE,
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
dataUrl: '/api/v1/discover/tv/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_STUDIO,
title: intl.formatMessage(sliderTitles.tmdbstudio),
dataUrl: '/api/v1/discover/movies/studio/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
},
{
type: DiscoverSliderType.TMDB_NETWORK,
title: intl.formatMessage(sliderTitles.tmdbnetwork),
dataUrl: '/api/v1/discover/tv/network/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
},
{
type: DiscoverSliderType.TMDB_SEARCH,
title: intl.formatMessage(sliderTitles.tmdbsearch),
dataUrl: '/api/v1/search',
params: 'query=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
{
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
dataUrl: '/api/v1/discover/movies',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
{
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
dataUrl: '/api/v1/discover/tv',
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
},
];
return (
<Formik
initialValues={
slider
? {
sliderType: slider.type,
title: slider.title,
data: slider.data,
}
: {
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}
}
validationSchema={CreateSliderSchema}
enableReinitialize
onSubmit={async (values, { resetForm }) => {
try {
if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
} else {
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
}
addToast(
intl.formatMessage(
slider ? messages.editsuccess : messages.addsuccess
),
{
appearance: 'success',
autoDismiss: true,
}
);
onCreate();
resetForm();
} catch (e) {
addToast(
intl.formatMessage(slider ? messages.editfail : messages.addfail),
{
appearance: 'error',
autoDismiss: true,
}
);
}
}}
>
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
const activeOption = options.find(
(option) => option.type === Number(values.sliderType)
);
let dataInput: React.ReactNode;
switch (activeOption?.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
const keywords = value.map((item) => item.value).join(',');
setFieldValue('data', keywords);
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key={`movie-genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key={`tv-genre-select-${defaultDataValue}}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key={`studio-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'movie'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
dataInput = (
<WatchProviderSelector
type={'tv'}
region={slider?.data?.split(',')[0]}
activeProviders={
slider?.data
?.split(',')[1]
.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
setFieldValue('data', `${region},${providers.join('|')}`);
}}
/>
);
break;
default:
dataInput = (
<Field
type="text"
name="data"
id="data"
placeholder={activeOption?.dataPlaceholderText}
/>
);
}
return (
<Form data-testid="create-discover-option-form">
<div className="flex flex-col space-y-2 text-gray-100">
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
{option.title}
</option>
))}
</Field>
<Field
type="text"
name="title"
id="title"
placeholder={activeOption?.titlePlaceholderText}
/>
{errors.title &&
touched.title &&
typeof errors.title === 'string' && (
<div className="error">{errors.title}</div>
)}
{dataInput}
{errors.data &&
touched.data &&
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>
<Button buttonType="primary" buttonSize="sm" disabled>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
</Tooltip>
) : (
<div>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(
slider ? messages.editSlider : messages.addSlider
)}
</Button>
</div>
)}
</div>
{activeOption && values.title && values.data && (
<div className="relative py-4">
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
url={activeOption?.dataUrl.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={
activeOption.type ===
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
activeOption.type ===
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
? activeOption.params
?.replace(
'$regionValue',
encodeURIExtraParams(values?.data.split(',')[0])
)
.replace(
'$providersValue',
encodeURIExtraParams(values?.data.split(',')[1])
)
: activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)
}
onNewTitles={updateResultCount}
/>
</div>
)}
</Form>
);
}}
</Formik>
);
};
export default CreateSlider;

View File

@@ -1,20 +1,16 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
keywordMovies: '{keywordTitle} Movies',
discovermovies: 'Popular Movies',
});
const DiscoverMovieKeyword = () => {
const router = useRouter();
const DiscoverMovies = () => {
const intl = useIntl();
const {
@@ -25,25 +21,13 @@ const DiscoverMovieKeyword = () => {
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/movies`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
} = useDiscover<MovieResult>('/api/v1/discover/movies');
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordMovies, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
const title = intl.formatMessage(messages.discovermovies);
return (
<>
@@ -64,4 +48,4 @@ const DiscoverMovieKeyword = () => {
);
};
export default DiscoverMovieKeyword;
export default DiscoverMovies;

View File

@@ -1,147 +0,0 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Movies',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortReleaseDateAsc: 'Release Date Ascending',
sortReleaseDateDesc: 'Release Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
ReleaseDateAsc: 'release_date.asc',
ReleaseDateDesc: 'release_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverMovies = () => {
const intl = useIntl();
const router = useRouter();
const updateQueryParams = useUpdateQueryParams({});
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult, unknown, FilterOptions>(
'/api/v1/discover/movies',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
{intl.formatMessage(messages.sortReleaseDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
{intl.formatMessage(messages.sortReleaseDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="movie"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovies;

View File

@@ -1,340 +0,0 @@
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import CompanyTag from '@app/components/CompanyTag';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',
enable: 'Toggle Visibility',
});
const Position = {
None: 'None',
Above: 'Above',
Below: 'Below',
} as const;
type DiscoverSliderEditProps = {
slider: Partial<DiscoverSlider>;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position,
isClickable: boolean
) => void;
children: React.ReactNode;
disableUpButton: boolean;
disableDownButton: boolean;
};
const DiscoverSliderEdit = ({
slider,
children,
onEnable,
onDelete,
onPositionUpdate,
disableUpButton,
disableDownButton,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isEditing, setIsEditing] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,
});
onDelete();
} catch (e) {
addToast(intl.formatMessage(messages.deletefail), {
appearance: 'error',
autoDismiss: true,
});
}
};
const { dropProps } = useDrop({
ref,
onDropMove: (e) => {
if (ref.current) {
const middlePoint = ref.current.offsetHeight / 2;
if (e.y < middlePoint) {
setHoverPosition(Position.Above);
} else {
setHoverPosition(Position.Below);
}
}
},
onDropExit: () => {
setHoverPosition(Position.None);
},
onDrop: async (e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === 'text' && item.types.has('id'))
.map(async (item) => {
if (item.kind === 'text') {
return item.getText('id');
}
})
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition, false);
}
},
});
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return intl.formatMessage(sliderTitles.recentlyAdded);
case DiscoverSliderType.RECENT_REQUESTS:
return intl.formatMessage(sliderTitles.recentrequests);
case DiscoverSliderType.PLEX_WATCHLIST:
return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:
return intl.formatMessage(sliderTitles.moviegenres);
case DiscoverSliderType.UPCOMING_MOVIES:
return intl.formatMessage(sliderTitles.upcoming);
case DiscoverSliderType.STUDIOS:
return intl.formatMessage(sliderTitles.studios);
case DiscoverSliderType.POPULAR_TV:
return intl.formatMessage(sliderTitles.populartv);
case DiscoverSliderType.TV_GENRES:
return intl.formatMessage(sliderTitles.tvgenres);
case DiscoverSliderType.UPCOMING_TV:
return intl.formatMessage(sliderTitles.upcomingtv);
case DiscoverSliderType.NETWORKS:
return intl.formatMessage(sliderTitles.networks);
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
case DiscoverSliderType.TMDB_TV_GENRE:
return intl.formatMessage(sliderTitles.tmdbtvgenre);
case DiscoverSliderType.TMDB_STUDIO:
return intl.formatMessage(sliderTitles.tmdbstudio);
case DiscoverSliderType.TMDB_NETWORK:
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
default:
return 'Unknown Slider';
}
};
return (
<div
key={`discover-slider-${slider.id}-editing`}
data-testid="discover-slider-edit-mode"
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
{...dragProps}
{...dropProps}
ref={ref}
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
<div
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div className="w-7/12 truncate md:w-full">
{getSliderTitle(slider)}
</div>
</div>
<div
className={`pointer-events-none ${
slider.data ? 'mb-4' : ''
} flex-1 md:mb-0`}
>
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
<div className="flex space-x-2">
{slider.data?.split(',').map((keywordId) => (
<KeywordTag
key={`slider-keywords-${slider.id}-${keywordId}`}
keywordId={Number(keywordId)}
/>
))}
</div>
)}
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
<CompanyTag
type={
slider.type === DiscoverSliderType.TMDB_STUDIO
? 'studio'
: 'network'
}
companyId={Number(slider.data)}
/>
)}
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
<GenreTag
type={
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
? 'movie'
: 'tv'
}
genreId={Number(slider.data)}
/>
)}
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
)}
</div>
<div className="flex items-center space-x-2">
{!slider.isBuiltIn && (
<>
{!isEditing ? (
<Button
buttonType="warning"
buttonSize="sm"
onClick={() => {
setIsEditing(true);
}}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
) : (
<Button
buttonType="default"
buttonSize="sm"
onClick={() => {
setIsEditing(false);
}}
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
)}
<Button
data-testid="discover-slider-remove-button"
buttonType="danger"
buttonSize="sm"
onClick={() => {
deleteSlider();
}}
>
<XMarkIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</>
)}
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Above, true)
}
disabled={disableUpButton}
>
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Below, true)
}
disabled={disableDownButton}
>
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
</div>
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={slider.enabled}
/>
</div>
</Tooltip>
</div>
</div>
</div>
{isEditing ? (
<div className="p-4">
<CreateSlider
onCreate={() => {
onDelete();
setIsEditing(false);
}}
slider={slider}
/>
</div>
) : (
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
{children}
</div>
)}
</div>
);
};
export default DiscoverSliderEdit;

View File

@@ -1,20 +1,16 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
keywordSeries: '{keywordTitle} Series',
discovertv: 'Popular Series',
});
const DiscoverTvKeyword = () => {
const router = useRouter();
const DiscoverTv = () => {
const intl = useIntl();
const {
@@ -25,25 +21,13 @@ const DiscoverTvKeyword = () => {
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/tv`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
} = useDiscover<TvResult>('/api/v1/discover/tv');
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordSeries, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
const title = intl.formatMessage(messages.discovertv);
return (
<>
@@ -54,14 +38,14 @@ const DiscoverTvKeyword = () => {
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvKeyword;
export default DiscoverTv;

View File

@@ -1,145 +0,0 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Series',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortFirstAirDateAsc: 'First Air Date Ascending',
sortFirstAirDateDesc: 'First Air Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
FirstAirDateAsc: 'first_air_date.asc',
FirstAirDateDesc: 'first_air_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverTv = () => {
const intl = useIntl();
const router = useRouter();
const [showFilters, setShowFilters] = useState(false);
const preparedFilters = prepareFilterValues(router.query);
const updateQueryParams = useUpdateQueryParams({});
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
...preparedFilters,
});
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.FirstAirDateDesc}>
{intl.formatMessage(messages.sortFirstAirDateDesc)}
</option>
<option value={SortOptions.FirstAirDateAsc}>
{intl.formatMessage(messages.sortFirstAirDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="tv"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;

View File

@@ -1,338 +0,0 @@
import Button from '@app/components/Common/Button';
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
import SlideOver from '@app/components/Common/SlideOver';
import type { FilterOptions } from '@app/components/Discover/constants';
import { countActiveFilters } from '@app/components/Discover/constants';
import LanguageSelector from '@app/components/LanguageSelector';
import {
CompanySelector,
GenreSelector,
KeywordSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
import {
useBatchUpdateQueryParams,
useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { defineMessages, useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages({
filters: 'Filters',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
releaseDate: 'Release Date',
firstAirDate: 'First Air Date',
from: 'From',
to: 'To',
studio: 'Studio',
genres: 'Genres',
keywords: 'Keywords',
originalLanguage: 'Original Language',
runtimeText: '{minValue}-{maxValue} minute runtime',
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
});
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
currentFilters: FilterOptions;
};
const FilterSlideover = ({
show,
onClose,
type,
currentFilters,
}: FilterSlideoverProps) => {
const intl = useIntl();
const { currentSettings } = useSettings();
const updateQueryParams = useUpdateQueryParams({});
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
const dateGte =
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
const dateLte =
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
return (
<SlideOver
show={show}
title={intl.formatMessage(messages.filters)}
subText={intl.formatMessage(messages.activefilters, {
count: countActiveFilters(currentFilters),
})}
onClose={() => onClose()}
>
<div className="flex flex-col space-y-4">
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
</div>
</div>
{type === 'movie' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.studio)}
</span>
<CompanySelector
defaultValue={currentFilters.studio}
onChange={(value) => {
updateQueryParams('studio', value?.value.toString());
}}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.keywords}
isMulti
onChange={(value) => {
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
<div className="pt-4">
<Button
className="w-full"
disabled={Object.keys(currentFilters).length === 0}
onClick={() => {
const copyCurrent = Object.assign({}, currentFilters);
(
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
).forEach((k) => {
copyCurrent[k] = undefined;
});
batchUpdateQueryParams(copyCurrent);
onClose();
}}
>
<XCircleIcon />
<span>{intl.formatMessage(messages.clearfilters)}</span>
</Button>
</div>
</div>
</SlideOver>
);
};
export default FilterSlideover;

View File

@@ -1,7 +1,7 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowRightCircleIcon />
<ArrowCircleRightIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies?genre=${genre.id}`}
url={`/discover/movies/genre/${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

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