Compare commits
84 Commits
v1.20.0
...
v1.0.0-arm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc526ff26d | ||
|
|
bffb84d08b | ||
|
|
a66872588e | ||
|
|
a10921e411 | ||
|
|
7ca08057a7 | ||
|
|
8634081c86 | ||
|
|
f7d2723fab | ||
|
|
1be97fe7fb | ||
|
|
71773c91c6 | ||
|
|
6e95c8b7a1 | ||
|
|
b36aff912a | ||
|
|
e4686d664b | ||
|
|
18a7876668 | ||
|
|
d48edeb5a9 | ||
|
|
a51d2a24d5 | ||
|
|
fc73592b69 | ||
|
|
60d453b0bb | ||
|
|
45886ccef1 | ||
|
|
84ef40ea8e | ||
|
|
512809f7c0 | ||
|
|
b85cd5376f | ||
|
|
1fcd18f0b0 | ||
|
|
dbea4f0194 | ||
|
|
75011610e5 | ||
|
|
109aca8229 | ||
|
|
3c1a72b038 | ||
|
|
3384eb1c47 | ||
|
|
e5f5bdb95c | ||
|
|
36e614f4f3 | ||
|
|
f5fc0545a4 | ||
|
|
69bf817f59 | ||
|
|
1c6914f5ce | ||
|
|
57bc340840 | ||
|
|
48387e5b2f | ||
|
|
1a7dc1acf5 | ||
|
|
d0fa5f3dbe | ||
|
|
f900d953a0 | ||
|
|
74e39b0b45 | ||
|
|
239202d9c1 | ||
|
|
85076919c6 | ||
|
|
9d0b52a242 | ||
|
|
f86d907c74 | ||
|
|
9966632c64 | ||
|
|
7aa0239cd9 | ||
|
|
7289872937 | ||
|
|
1f8b03ff6f | ||
|
|
e98f2b9605 | ||
|
|
92508b3f42 | ||
|
|
4f1a8a0a78 | ||
|
|
b5ce7f0cab | ||
|
|
778dda67d5 | ||
|
|
cd21865c4d | ||
|
|
0c4637f779 | ||
|
|
420038d5ff | ||
|
|
bdf67e732b | ||
|
|
f6d00d8d15 | ||
|
|
6072e8aa9a | ||
|
|
63c122e5e0 | ||
|
|
ed0a7fbdf5 | ||
|
|
b57645d382 | ||
|
|
f28112f057 | ||
|
|
436523139e | ||
|
|
0fa005a99c | ||
|
|
6a1e3891aa | ||
|
|
1629d02f3d | ||
|
|
594aad9d3a | ||
|
|
1d45f427e5 | ||
|
|
253ec15bc4 | ||
|
|
b146d11e2f | ||
|
|
bbc0b74b9d | ||
|
|
1b55d2dfbc | ||
|
|
ddfc5e6aa8 | ||
|
|
2771376ecc | ||
|
|
eefcbcd3dd | ||
|
|
3fed26cfbe | ||
|
|
3f9a116b17 | ||
|
|
aeac15c63c | ||
|
|
abcd7c9975 | ||
|
|
9ce88abcc8 | ||
|
|
084917f02d | ||
|
|
69294a7c4c | ||
|
|
537850f414 | ||
|
|
e9f2fe910d | ||
|
|
5560abf459 |
@@ -312,6 +312,70 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "NuroDev",
|
||||
"name": "nuro",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4991309?v=4",
|
||||
"profile": "https://nuro.dev",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "onedr0p",
|
||||
"name": "ᗪєνιη ᗷυнʟ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/213795?v=4",
|
||||
"profile": "https://github.com/onedr0p",
|
||||
"contributions": [
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "JonnyWong16",
|
||||
"name": "JonnyWong16",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9099342?v=4",
|
||||
"profile": "https://github.com/JonnyWong16",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Roxedus",
|
||||
"name": "Roxedus",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7110194?v=4",
|
||||
"profile": "https://github.com/Roxedus",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "WoisWoi",
|
||||
"name": "WoisWoi",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75491231?v=4",
|
||||
"profile": "https://github.com/WoisWoi",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "HubDuck",
|
||||
"name": "HubDuck",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4",
|
||||
"profile": "https://github.com/HubDuck",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "costaht",
|
||||
"name": "costaht",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/50637431?v=4",
|
||||
"profile": "https://github.com/costaht",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"translation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -24,6 +24,7 @@ jobs:
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
|
||||
build_and_push:
|
||||
name: Build & Publish to Docker Hub
|
||||
needs: test
|
||||
@@ -59,6 +60,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ github.sha }}
|
||||
@@ -68,7 +70,15 @@ jobs:
|
||||
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,mode=max
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- # Temporary fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build_and_push
|
||||
@@ -76,8 +86,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.2
|
||||
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
@@ -87,7 +96,6 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
|
||||
158
.github/workflows/release.yml
vendored
158
.github/workflows/release.yml
vendored
@@ -4,26 +4,27 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- arm-test
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
run: yarn
|
||||
- name: lint
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
# test:
|
||||
# name: Lint & Test Build
|
||||
# runs-on: ubuntu-20.04
|
||||
# container: node:12.18-alpine
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v2
|
||||
# - name: install dependencies
|
||||
# env:
|
||||
# HUSKY_SKIP_INSTALL: 1
|
||||
# run: yarn
|
||||
# - name: lint
|
||||
# run: yarn lint
|
||||
# - name: build
|
||||
# run: yarn build
|
||||
semantic-release:
|
||||
name: Tag and release latest version
|
||||
needs: test
|
||||
# needs: test
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -31,9 +32,24 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 12
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Release
|
||||
@@ -42,65 +58,65 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: npx semantic-release
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: semantic-release
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Switch to master branch
|
||||
run: git checkout master
|
||||
- name: Pull latest changes
|
||||
run: git pull
|
||||
# build-snap:
|
||||
# name: Build Snap Package (${{ matrix.architecture }})
|
||||
# needs: semantic-release
|
||||
# runs-on: ubuntu-20.04
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# architecture:
|
||||
# - amd64
|
||||
# - arm64
|
||||
# - armhf
|
||||
# steps:
|
||||
# - name: Checkout Code
|
||||
# uses: actions/checkout@v2
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
# - name: Switch to master branch
|
||||
# run: git checkout master
|
||||
# - name: Pull latest changes
|
||||
# run: git pull
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
# - name: Prepare
|
||||
# id: prepare
|
||||
# run: |
|
||||
# git fetch --prune --tags
|
||||
# if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
# echo ::set-output name=RELEASE::stable
|
||||
# else
|
||||
# echo ::set-output name=RELEASE::edge
|
||||
# fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
# - name: Set Up QEMU
|
||||
# uses: docker/setup-qemu-action@v1
|
||||
# with:
|
||||
# image: tonistiigi/binfmt@sha256:df15403e06a03c2f461c1f7938b171fda34a5849eb63a70e2a2109ed5a778bde
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
# - name: Build Snap Package
|
||||
# uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
# id: build
|
||||
# with:
|
||||
# architecture: ${{ matrix.architecture }}
|
||||
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
# - name: Upload Snap Package
|
||||
# uses: actions/upload-artifact@v2
|
||||
# with:
|
||||
# name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
# path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
# - name: Review Snap Package
|
||||
# uses: diddlesnaps/snapcraft-review-tools-action@v1.2.0
|
||||
# with:
|
||||
# snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
# - name: Publish Snap Package
|
||||
# uses: snapcore/action-publish@v1
|
||||
# with:
|
||||
# store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
# snap: ${{ steps.build.outputs.snap }}
|
||||
# release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: semantic-release
|
||||
@@ -108,7 +124,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.2
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
|
||||
2
.github/workflows/snap.yaml
vendored
2
.github/workflows/snap.yaml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.2
|
||||
uses: technote-space/workflow-conclusion-action@v2.1.5
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
|
||||
3
.github/workflows/support.yml
vendored
3
.github/workflows/support.yml
vendored
@@ -19,7 +19,8 @@ jobs:
|
||||
to get help with Overseerr.
|
||||
|
||||
- [Discord](https://discord.gg/PkCWJSeCk7)
|
||||
|
||||
- [GitHub Discussions](https://github.com/sct/overseerr/discussions)
|
||||
close-issue: true
|
||||
lock-issue: false
|
||||
lock-issue: true
|
||||
issue-lock-reason: 'off-topic'
|
||||
|
||||
542
CHANGELOG.md
Normal file
542
CHANGELOG.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# 1.0.0-arm-test.1 (2021-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correctly send auto-approval notifictions for series ([8634081](https://github.com/sct/overseerr/commit/8634081c869a2078793ecf06b1b7e249bba0a2f8))
|
||||
* try custom buildx semantic-release package ([bffb84d](https://github.com/sct/overseerr/commit/bffb84d08ba3faa1552b2425d439a98334deb939))
|
||||
* **api:** check correct permissions for auto approve when requests are created ([3c1a72b](https://github.com/sct/overseerr/commit/3c1a72b038fd178b4be4dc082cd1496474148d7e))
|
||||
* **api:** filter out adult content from combined credits ([3052f12](https://github.com/sct/overseerr/commit/3052f12c91b3ce86128324e3698fff61bbce3f2a))
|
||||
* **api:** improve rottentomatoes rating matching for movies ([7db62ab](https://github.com/sct/overseerr/commit/7db62ab824eefc42e6db16e42d52f4266b136f82)), closes [#494](https://github.com/sct/overseerr/issues/494)
|
||||
* **api:** prevent duplicate movie requests ([421f4c1](https://github.com/sct/overseerr/commit/421f4c17f0f206bbe7bfcbf2819014b8c7f55b6a)), closes [#705](https://github.com/sct/overseerr/issues/705)
|
||||
* **build:** fix sqlite3 build error ([#691](https://github.com/sct/overseerr/issues/691)) ([3a1f6d5](https://github.com/sct/overseerr/commit/3a1f6d5706c8fc100e88425f3d89a26a0325af79))
|
||||
* **build:** remove cross import from client to server for UserType ([23624bd](https://github.com/sct/overseerr/commit/23624bd144af5df4c31995b68ce48105b95b20f6))
|
||||
* **cache:** use formatted numbers for displaying cache counts ([6c437c5](https://github.com/sct/overseerr/commit/6c437c515fc01b9fe4461968875e23542bae7542))
|
||||
* **email:** make image a link to the action url in request template ([ee0a7bd](https://github.com/sct/overseerr/commit/ee0a7bd8c0b3a79c292b0abceb2f780f3889e49f)), closes [#834](https://github.com/sct/overseerr/issues/834)
|
||||
* **frontend:** add github sponsor link to about page ([7c192d5](https://github.com/sct/overseerr/commit/7c192d54f422a5f2b55750535d2382e313f1d011))
|
||||
* **frontend:** add poster not found image to request card and request list item ([ae9a1b3](https://github.com/sct/overseerr/commit/ae9a1b3e940ac2abf6e842d91f458daab3dd0f0d))
|
||||
* **frontend:** add poster not found image to tv details page ([0b05545](https://github.com/sct/overseerr/commit/0b055458d0ddbfd4c87ebf9b0562f161fa3445a3))
|
||||
* **frontend:** check for ID instead of email after initial setup Plex login ([#1097](https://github.com/sct/overseerr/issues/1097)) ([778dda6](https://github.com/sct/overseerr/commit/778dda67d54df87347dd79577ef1bdc88d3c1d3f))
|
||||
* **frontend:** check if swr is validating to determine if we should fetch new data ([e5f5bdb](https://github.com/sct/overseerr/commit/e5f5bdb95c62eba31a3321a7457d354f0226bf85)), closes [#719](https://github.com/sct/overseerr/issues/719)
|
||||
* **frontend:** clarify which fields are required in radarr/sonarr modals ([860d71e](https://github.com/sct/overseerr/commit/860d71ed69a69a1a3f74b79290ef471e04f57a6b)), closes [#575](https://github.com/sct/overseerr/issues/575)
|
||||
* **frontend:** correctly show 4k download tracker activity ([a7314f8](https://github.com/sct/overseerr/commit/a7314f876ea528fdec0fb0a2adaa36a01afcdf38))
|
||||
* **frontend:** do not show failed media status on request list for declined requests ([00944b1](https://github.com/sct/overseerr/commit/00944b1ec2db8ddc5742448f6448f7364c473a98)), closes [#664](https://github.com/sct/overseerr/issues/664)
|
||||
* **frontend:** dont show external links unless slug is set ([946bd2d](https://github.com/sct/overseerr/commit/946bd2db5ecde0748b2e9bc5edfe7ca6000ec3d5))
|
||||
* **frontend:** fix possible division by zero in download status ([#839](https://github.com/sct/overseerr/issues/839)) ([c97c96a](https://github.com/sct/overseerr/commit/c97c96a30c50db7735f06c6d2d2f6193fb7da55e))
|
||||
* **frontend:** fix request button height ([a262727](https://github.com/sct/overseerr/commit/a2627270784bdef8644875fa5c5a7349a0b7fd81))
|
||||
* **frontend:** fix server name position on plex settings page ([86efcd8](https://github.com/sct/overseerr/commit/86efcd82c34ad6490f2899ebf6f84cdd1bffc498))
|
||||
* **frontend:** fixed mismatched rounded sizing on new login ([5e352c2](https://github.com/sct/overseerr/commit/5e352c201fc2f731ca5f713ecb6901527ef354da)), closes [#721](https://github.com/sct/overseerr/issues/721)
|
||||
* **frontend:** match request button color on titlecards to other request buttons ([5b39911](https://github.com/sct/overseerr/commit/5b39911e024513fab7a62948e653cee08fd166c7))
|
||||
* **frontend:** never hide available content in search results ([d48edeb](https://github.com/sct/overseerr/commit/d48edeb5a9bd8e2edce8bca0fea50e300bb7a1ae))
|
||||
* **frontend:** set 4k status on RequestItem when request is for 4k ([a3b00c3](https://github.com/sct/overseerr/commit/a3b00c3458b868506d4158fb24f0369fa5daefc5))
|
||||
* **frontend:** show auto approval on series request modal only with correct permissions ([8927c6d](https://github.com/sct/overseerr/commit/8927c6d2e39dbda2b1121095a7273f5cab1c9b74)), closes [#687](https://github.com/sct/overseerr/issues/687)
|
||||
* **frontend:** use consistent spinner style on TitleCard/Plex Presets ([cf7ebc4](https://github.com/sct/overseerr/commit/cf7ebc488db33725444c428b4244d780ab9d123b))
|
||||
* **html:** th elements should be nested under tr, not directly under thead ([#801](https://github.com/sct/overseerr/issues/801)) ([6e9ac27](https://github.com/sct/overseerr/commit/6e9ac275e19d56de8c7a366db970c7321f26fc8a))
|
||||
* **ip logging:** add env var for proxy to fix ip logging on failed logins ([#756](https://github.com/sct/overseerr/issues/756)) ([9342a40](https://github.com/sct/overseerr/commit/9342a40bbc03f7fdda23e3876b3a4a81ea8532c0))
|
||||
* **lang:** add missing i18n strings ([6072e8a](https://github.com/sct/overseerr/commit/6072e8aa9a0f84e50c44a92af303aad15b5f3021))
|
||||
* **lang:** add missing i18n strings for notification settings ([2f75c4c](https://github.com/sct/overseerr/commit/2f75c4c6aed42a15bb47d3652272de8f852ec79f))
|
||||
* **lang:** add missing see more i18n string for SeeMoreCard ([d9919ab](https://github.com/sct/overseerr/commit/d9919abb8998d28558ddec35b8e60ab2af75d5b7))
|
||||
* **lang:** Add missing source strings & remove local user sign-in setting tip ([#828](https://github.com/sct/overseerr/issues/828)) ([c0769d4](https://github.com/sct/overseerr/commit/c0769d4f8f2bad88e4638d8c3cbcc0414b3ef6fb))
|
||||
* **lang:** change email auth user/pass strings to SMTP Username/Password ([a77a2aa](https://github.com/sct/overseerr/commit/a77a2aa3ebb1be353d534db5b07647ac26c60e15))
|
||||
* **lang:** Edit English language strings ([#820](https://github.com/sct/overseerr/issues/820)) ([f54df21](https://github.com/sct/overseerr/commit/f54df214af86d90ea8d7cfcd4e39022215c3568c))
|
||||
* **lang:** edit new Telegram-related strings to conform to style guide ([#1093](https://github.com/sct/overseerr/issues/1093)) ([bdf67e7](https://github.com/sct/overseerr/commit/bdf67e732b6c77cbae768a25edfc9a663ef0108b))
|
||||
* **lang:** fix singular form of season count ([#1080](https://github.com/sct/overseerr/issues/1080)) ([b57645d](https://github.com/sct/overseerr/commit/b57645d382361c856281e7a74295afe16c5390f2))
|
||||
* **lang:** translate language names & change zh-Hant language code to zh-TW ([#793](https://github.com/sct/overseerr/issues/793)) ([3c5ae36](https://github.com/sct/overseerr/commit/3c5ae360fd179d794a78cc918fe97a09216ca6b2))
|
||||
* **notif:** escape application title in Telegram notifications ([#1012](https://github.com/sct/overseerr/issues/1012)) ([5560abf](https://github.com/sct/overseerr/commit/5560abf459b0350ff30b5e71d4208418fc8f3b3e))
|
||||
* **notif:** fixed typo in pushover hint ([#1029](https://github.com/sct/overseerr/issues/1029)) ([e9f2fe9](https://github.com/sct/overseerr/commit/e9f2fe910d72fa41bc27673ed43291211c3cac65))
|
||||
* **notif/ui:** Use custom application title in notifications & sign-in page ([#849](https://github.com/sct/overseerr/issues/849)) ([38c76b5](https://github.com/sct/overseerr/commit/38c76b55e0039c489cb6a4a0a298aa6385406db4))
|
||||
* **notifications:** correctly compare seasons before sending series notifications ([f17fa2a](https://github.com/sct/overseerr/commit/f17fa2a2db8144bac89936f588627e8dd37bf54a))
|
||||
* **notifications:** correctly send tv auto approval notifications ([537850f](https://github.com/sct/overseerr/commit/537850f414a88df24c78794a2fd68e1e24ff73d1)), closes [#1041](https://github.com/sct/overseerr/issues/1041)
|
||||
* **notifications:** only send a single notification when standard media becomes available ([b5fd1d5](https://github.com/sct/overseerr/commit/b5fd1d520cd2a7be6e6356a25129e93af1caf542)), closes [#770](https://github.com/sct/overseerr/issues/770)
|
||||
* **notifications:** send media declined email ([eb6fc8a](https://github.com/sct/overseerr/commit/eb6fc8a19099469794d471db0b48a258c2866633)), closes [#679](https://github.com/sct/overseerr/issues/679)
|
||||
* **permissions:** use default user permissions when creating a local user ([#713](https://github.com/sct/overseerr/issues/713)) ([660ada0](https://github.com/sct/overseerr/commit/660ada0b2025eb2c06d9054fd0a7b5a632af6af2))
|
||||
* **plex-sync:** no longer incorrectly sets 4k availability when there isnt any ([3f9a116](https://github.com/sct/overseerr/commit/3f9a116b17d78eeb04f0f125a4f3af6f907c83dd)), closes [#990](https://github.com/sct/overseerr/issues/990)
|
||||
* **plex-sync:** store plex added date and sort recently added by it ([d688a96](https://github.com/sct/overseerr/commit/d688a967596afcba9799b8133089bebb5add27cf))
|
||||
* **radarr:** correctly set requested status after sending to radarr (with auto approve) ([ec44841](https://github.com/sct/overseerr/commit/ec448413569ddc2f24bb856d29084169979f9f05))
|
||||
* **radarr:** fix request bug which made it unable to be added to radarr ([#760](https://github.com/sct/overseerr/issues/760)) ([45a2779](https://github.com/sct/overseerr/commit/45a277964b0c39346d7216873812e0ebe505cb79))
|
||||
* **radarr:** return the updated data when updating radarr request ([#765](https://github.com/sct/overseerr/issues/765)) ([0c6d478](https://github.com/sct/overseerr/commit/0c6d4780c355ffe1a951268fb6949491d435bbf1))
|
||||
* **requests:** add plex url to request item ([#1088](https://github.com/sct/overseerr/issues/1088)) ([420038d](https://github.com/sct/overseerr/commit/420038d5ffdd4070df03e5c5cb6ef8d6208fddb5))
|
||||
* **requests:** allow declined season requests to be re-requested ([e1032ff](https://github.com/sct/overseerr/commit/e1032ff5dfac4a8c9d4da9cf2788c19822343ad9)), closes [#690](https://github.com/sct/overseerr/issues/690)
|
||||
* **requests:** handle when tvdbid is null ([#657](https://github.com/sct/overseerr/issues/657)) ([2da0da8](https://github.com/sct/overseerr/commit/2da0da826ae1d73467bc8a671fda7cc5ca1f14c9))
|
||||
* **requests:** update requests to approved when parent media is set as available ([78444a9](https://github.com/sct/overseerr/commit/78444a9e643829823162389dee60cca70da56bff)), closes [#688](https://github.com/sct/overseerr/issues/688)
|
||||
* **server:** support absolute paths for CONFIG_DIRECTORY ([51d8fba](https://github.com/sct/overseerr/commit/51d8fba9162b9e148a35ced69e7e035438c8b0f1))
|
||||
* **sonarr:** correctly search when updating existing sonarr series ([ed0a7fb](https://github.com/sct/overseerr/commit/ed0a7fbdf5122a26fa936e83b76a97c55781782d)), closes [#588](https://github.com/sct/overseerr/issues/588)
|
||||
* **sonarr-sync:** correctly set series with no seasons to requested status ([3812989](https://github.com/sct/overseerr/commit/3812989a1ce1e07d4af09149008043a6e2e94060)), closes [#762](https://github.com/sct/overseerr/issues/762)
|
||||
* **sonarr-sync:** sonarr sync will no longer set shows with no episodes to partially available ([d20bd53](https://github.com/sct/overseerr/commit/d20bd530edaadc5887b0361358da80153e36505c)), closes [#796](https://github.com/sct/overseerr/issues/796)
|
||||
* **sync:** do not update series status if already available and no new seasons ([136d874](https://github.com/sct/overseerr/commit/136d874cba37babf9c0670844b002871710e6d99)), closes [#777](https://github.com/sct/overseerr/issues/777)
|
||||
* **ui:** Add additional URL & email input validation ([#843](https://github.com/sct/overseerr/issues/843)) ([3f9bfeb](https://github.com/sct/overseerr/commit/3f9bfeb01a67b2b587c7548b02ee826722e65c0f))
|
||||
* **ui:** add alt prop to studio/network logos & fix blinking text cursor ([#1095](https://github.com/sct/overseerr/issues/1095)) ([0c4637f](https://github.com/sct/overseerr/commit/0c4637f779d8904037b9cbd5fe9166cf05a891c5))
|
||||
* **ui:** add link to poster image on request items ([7289872](https://github.com/sct/overseerr/commit/7289872937d5bb94d027424760ee1ceb94095604))
|
||||
* **ui:** Capitalization, punctuation, and grammar inconsistences & errors ([#731](https://github.com/sct/overseerr/issues/731)) ([f05d4a0](https://github.com/sct/overseerr/commit/f05d4a0d0b42905fcaee49b2471bb1f4ee77fffe))
|
||||
* **ui:** correct language usage re: "sync" vs. "scan" ([#1079](https://github.com/sct/overseerr/issues/1079)) ([e98f2b9](https://github.com/sct/overseerr/commit/e98f2b96058fb9c5af77be2e8a1bd07fb8fcca06))
|
||||
* **ui:** Don't display empty dropdown when no trailer available ([#804](https://github.com/sct/overseerr/issues/804)) ([95c2a21](https://github.com/sct/overseerr/commit/95c2a2169799d96413b47ab24506b330435643eb))
|
||||
* **ui:** dont show bulk edit options on user list if there is only one user ([b658ddf](https://github.com/sct/overseerr/commit/b658ddf5cf61b2bb9b93cb1a4ca716cd75e18bb4))
|
||||
* **ui:** Dynamically generate path to config in warning message ([#851](https://github.com/sct/overseerr/issues/851)) ([b531a64](https://github.com/sct/overseerr/commit/b531a642f601f4ef9bf39c2f5915402157e55372))
|
||||
* **ui:** Fix card sizes on person detail pages ([#881](https://github.com/sct/overseerr/issues/881)) ([a3042f8](https://github.com/sct/overseerr/commit/a3042f8e1b05a91d98f48a4aecb08e831a48fc56))
|
||||
* **ui:** fix request list UI behavior when season list is too long ([#1106](https://github.com/sct/overseerr/issues/1106)) ([8507691](https://github.com/sct/overseerr/commit/85076919c6ccbf052699b7d5f4ba8b6e5e5af74d))
|
||||
* **ui:** fix tables extending outside viewport in mobile formats ([e270999](https://github.com/sct/overseerr/commit/e270999745f97c2860f6a5b84e897dc6da8d6001))
|
||||
* **ui:** fix webhook URL validation regex ([baad19a](https://github.com/sct/overseerr/commit/baad19a2c94728313ee996fe1a0ffc64fbd9aaa3))
|
||||
* **ui:** Handle missing movie/series data ([#862](https://github.com/sct/overseerr/issues/862)) ([7c0ddad](https://github.com/sct/overseerr/commit/7c0ddad653393327226a877692f046d8693ddc66))
|
||||
* **ui:** Hide 'Mark 4k as Available' button if 4k not enabled ([#833](https://github.com/sct/overseerr/issues/833)) ([e4a50c3](https://github.com/sct/overseerr/commit/e4a50c33f105b440243885d72a9e96595a525447))
|
||||
* **ui:** Limit max width of forms & lists ([#845](https://github.com/sct/overseerr/issues/845)) ([b9d14a9](https://github.com/sct/overseerr/commit/b9d14a9fd0f3c94d8267755147a87fe3b77fa2c3))
|
||||
* **ui:** list all movie studios instead of just the first result ([#1110](https://github.com/sct/overseerr/issues/1110)) ([239202d](https://github.com/sct/overseerr/commit/239202d9c11f27410b0fa084bcc4c824b7136081))
|
||||
* **ui:** prevent names from getting squished in AdvancedRequester user selector ([06e9411](https://github.com/sct/overseerr/commit/06e941171a1d019fbb178624167c026f6df5271c))
|
||||
* **ui:** remove yup validation from display name on user edit page ([63d7e2b](https://github.com/sct/overseerr/commit/63d7e2b39858fcb1cc0819a680eebccded7f4451))
|
||||
* **ui:** rename global group class to form-group ([8056187](https://github.com/sct/overseerr/commit/8056187c3c0ea464a8f751aa6347ea1d35c01aac))
|
||||
* **ui:** request list button sizes ([#1152](https://github.com/sct/overseerr/issues/1152)) ([fc73592](https://github.com/sct/overseerr/commit/fc73592b69c38191f91a68a020868b8e5ec2e2e2))
|
||||
* fix language filter link on movie detail pages ([#1142](https://github.com/sct/overseerr/issues/1142)) ([60d453b](https://github.com/sct/overseerr/commit/60d453b0bbba5e2060f72f40d1dde85ec6b05af4))
|
||||
* **frontend:** status, requested by, and modified alignment fix ([#1109](https://github.com/sct/overseerr/issues/1109)) ([1a7dc1a](https://github.com/sct/overseerr/commit/1a7dc1acf57888d3d0285b58c1c97a824a232216))
|
||||
* **ui:** display "Season" vs. "Seasons" as appropriate, and fix request block "Seasons" formatting ([#1127](https://github.com/sct/overseerr/issues/1127)) ([45886cc](https://github.com/sct/overseerr/commit/45886ccef1bee57dc555060a491834567e45b59c))
|
||||
* remove language/region filtering on studio/network results ([#1129](https://github.com/sct/overseerr/issues/1129)) ([109aca8](https://github.com/sct/overseerr/commit/109aca8229dc7b81cac314d84591f1c04c12ac2e))
|
||||
* **ui:** don't show "Password" user settings tab if current user lacks perms to modify the password ([#1063](https://github.com/sct/overseerr/issues/1063)) ([b146d11](https://github.com/sct/overseerr/commit/b146d11e2ffecedae76472b0491a4662ca4a4a4e))
|
||||
* **ui:** fix Radarr logo alignment ([#1068](https://github.com/sct/overseerr/issues/1068)) ([0fa005a](https://github.com/sct/overseerr/commit/0fa005a99cd868b5a235ae9ce65b4c64b05d0f47))
|
||||
* **ui:** for server default options, display "All" region/language option instead of empty string ([#1042](https://github.com/sct/overseerr/issues/1042)) ([3fed26c](https://github.com/sct/overseerr/commit/3fed26cfbe74cb662ca531fd37b69f159a051ac1))
|
||||
* **ui:** improve responsive design on new request list UI ([#1105](https://github.com/sct/overseerr/issues/1105)) ([1f8b03f](https://github.com/sct/overseerr/commit/1f8b03ff6f67ce76051667de05166da54ed3dc89))
|
||||
* add correct permission checks to modifying user password/permissions ([ddfc5e6](https://github.com/sct/overseerr/commit/ddfc5e6aa8fc636931f495d6f23d56367466e3b5))
|
||||
* **ui:** Restore original port input size ([#814](https://github.com/sct/overseerr/issues/814)) ([1ccafc0](https://github.com/sct/overseerr/commit/1ccafc0ebd368d798f9571b83910336efa317e37))
|
||||
* **ui:** show request as option even if there are no radarr/sonarr servers ([b116281](https://github.com/sct/overseerr/commit/b116281196c264b4ec35b07f1b4ffa717e50ade5))
|
||||
* **ui:** show translated string on sonarr sucesss/failure toast messages ([#1035](https://github.com/sct/overseerr/issues/1035)) ([eefcbcd](https://github.com/sct/overseerr/commit/eefcbcd3ddfa5258ee24dbbbd79de5bf50310f27))
|
||||
* **ui:** Size cards appropriately based on base font size ([#871](https://github.com/sct/overseerr/issues/871)) ([282f28f](https://github.com/sct/overseerr/commit/282f28f2b9d0cc8c9105d01b43d4e1f730320b8b))
|
||||
* **ui:** uniform-size checkboxes, vertically-aligned form labels, and fixes for other UI imperfections/inconsistencies ([#737](https://github.com/sct/overseerr/issues/737)) ([e34fbf7](https://github.com/sct/overseerr/commit/e34fbf72fda34d69b9f25563fa81f88b3c20912a))
|
||||
* **ui:** use country-flag-icons instead of country-flag-emoji for RegionSelector ([#1011](https://github.com/sct/overseerr/issues/1011)) ([abcd7c9](https://github.com/sct/overseerr/commit/abcd7c997584c1310bd8b313ac38f30e335af8d7))
|
||||
* add missing default value for settings context ([084917f](https://github.com/sct/overseerr/commit/084917f02d399e2d29bb9927e033c2e6533f586c))
|
||||
* added missing language default for ssr context defaults ([9ce88ab](https://github.com/sct/overseerr/commit/9ce88abcc85d744d77172cd2357fdb4ff60dc5e4))
|
||||
* allow users to override language/region settings ([69294a7](https://github.com/sct/overseerr/commit/69294a7c4c5bbe55c5cd276786cdfd48ddbff889)), closes [#1013](https://github.com/sct/overseerr/issues/1013)
|
||||
* **api:** add isAuthenticated middleware to base user route ([8a27c70](https://github.com/sct/overseerr/commit/8a27c7062599ea23dca115e6e6e95a594e1b219a))
|
||||
* **api:** correctly return firstAirDate for series in search endpoints ([32b4c99](https://github.com/sct/overseerr/commit/32b4c99950659d9e1da2ffa93c22383c54d0d904)), closes [#462](https://github.com/sct/overseerr/issues/462)
|
||||
* **api:** fix cross-imported type crashing build ([f35dae5](https://github.com/sct/overseerr/commit/f35dae56a583a5545375318fa5be994ae1f2557f))
|
||||
* **api:** prevent checking first admin account for plex server access ([22006e9](https://github.com/sct/overseerr/commit/22006e9dbde82609440f89bde9a40887b4742682))
|
||||
* **api:** return 202 when same seasons are requested again ([5c84702](https://github.com/sct/overseerr/commit/5c847026aad79fcac4d020786ded9f867696c226))
|
||||
* **build:** fixes build to include commit tag for app build step ([289864a](https://github.com/sct/overseerr/commit/289864af1a995ce04834bf8a220cc238e1954d19))
|
||||
* **docs:** fix typo in build instructions ([#503](https://github.com/sct/overseerr/issues/503)) ([2b27a71](https://github.com/sct/overseerr/commit/2b27a715b07c27200ba1e5e9623629a34389276d))
|
||||
* **email:** correctly log errors when emails fail to send ([0980fa5](https://github.com/sct/overseerr/commit/0980fa54f9fc3bdfae6c57fa5a20ce3b2a88a677))
|
||||
* **email:** fix link to Overseerr in email templates ([816fec1](https://github.com/sct/overseerr/commit/816fec1a83a53edb3b65c3e5e7d0e6e1bd49726d)), closes [#392](https://github.com/sct/overseerr/issues/392)
|
||||
* **frontend:** add i18n for request text on titlecard ([a524b9c](https://github.com/sct/overseerr/commit/a524b9c4c8968f6823d33eb270dc26069fe4a725))
|
||||
* **frontend:** add localized strings for status checker ([2dcda39](https://github.com/sct/overseerr/commit/2dcda39d40d820419e098bd6f1101eb820e5b42d))
|
||||
* **frontend:** add margin to ButtonWithDropdown component on movie/tv details page ([06fc98b](https://github.com/sct/overseerr/commit/06fc98b6b958221fa180f57f702c348f15b31f1c))
|
||||
* **frontend:** add name, short_name and start_url to manifest ([#424](https://github.com/sct/overseerr/issues/424)) ([c6836e0](https://github.com/sct/overseerr/commit/c6836e02c810e8adb12c3a4b110f9604cf5b7b81))
|
||||
* **frontend:** added new Radarr v3 logo ([#471](https://github.com/sct/overseerr/issues/471)) ([3bbc716](https://github.com/sct/overseerr/commit/3bbc716434dc04bfe6b55de9898eb2c0ecb03baa))
|
||||
* **frontend:** adjust padding of search box so placeholder text fits on mobile ([3601d44](https://github.com/sct/overseerr/commit/3601d442db32d3f98f7b050365c11ea8ef9bc4ae)), closes [#393](https://github.com/sct/overseerr/issues/393)
|
||||
* **frontend:** adjust person card layout to deal with overflowing content ([4891298](https://github.com/sct/overseerr/commit/48912988915ae40606a900a6f1dd23fc25ed567f)), closes [#416](https://github.com/sct/overseerr/issues/416)
|
||||
* **frontend:** adjust titlecard badge styling ([effc809](https://github.com/sct/overseerr/commit/effc80977a4ed732092254248f82363e52233171))
|
||||
* **frontend:** allow more special characters in search input ([5deb64a](https://github.com/sct/overseerr/commit/5deb64a87fd70e97da27a025ad11fb8ace0e0b57)), closes [#430](https://github.com/sct/overseerr/issues/430)
|
||||
* **frontend:** apply same titlecard hover effect to personcard ([67f2b57](https://github.com/sct/overseerr/commit/67f2b57f00216ded3b34965629d6fdd2f16bc25f))
|
||||
* **frontend:** approve and decline button (in manage panel) will now fit on mobile ([#441](https://github.com/sct/overseerr/issues/441)) ([66ef72d](https://github.com/sct/overseerr/commit/66ef72dd42912d83ea8f86aabb75fbee547f8de9))
|
||||
* **frontend:** center text in movie auto-approve modal on small screens ([#510](https://github.com/sct/overseerr/issues/510)) ([1438b08](https://github.com/sct/overseerr/commit/1438b08cf0b358d79c6688c64be99f1718ec2d23)), closes [#507](https://github.com/sct/overseerr/issues/507)
|
||||
* **frontend:** change titlecard to only have a request button ([b5a3a7a](https://github.com/sct/overseerr/commit/b5a3a7a89fcaf86dd794dc419711677b53646577))
|
||||
* **frontend:** changed request block for slideover on mobile UI ([#387](https://github.com/sct/overseerr/issues/387)) ([549567a](https://github.com/sct/overseerr/commit/549567a7e9db01933546d9970fc06f17218dfab1))
|
||||
* **frontend:** combine duplicate credits on a persons detail page ([d188f6f](https://github.com/sct/overseerr/commit/d188f6ffadff1564c47d5f33138e35498bed29fd)), closes [#504](https://github.com/sct/overseerr/issues/504)
|
||||
* **frontend:** correctly position title card hover section ([#486](https://github.com/sct/overseerr/issues/486)) ([4b7af86](https://github.com/sct/overseerr/commit/4b7af86111a0300e1a137f23fa4ad1639fa55feb))
|
||||
* **frontend:** correctly show an unauthorized error when a user fails to login ([18925de](https://github.com/sct/overseerr/commit/18925decafdac518f52a354c594cc378d2529022)), closes [#322](https://github.com/sct/overseerr/issues/322)
|
||||
* **frontend:** disable pointer-events on titlecard badges ([ce06879](https://github.com/sct/overseerr/commit/ce0687922a94588b3492e8ddf2e84f54dd1a0d4e))
|
||||
* **frontend:** filter out undefined backdrop paths for person details page ([2e0e4d5](https://github.com/sct/overseerr/commit/2e0e4d5129ed4912415f61eb8d1da41e88ddcaff))
|
||||
* **frontend:** fix button styling on details page on small screen sizes ([d9e0c90](https://github.com/sct/overseerr/commit/d9e0c90e76d80aef0c67318e00e997804805f46e))
|
||||
* **frontend:** fix count of requests in request list ([f124d73](https://github.com/sct/overseerr/commit/f124d732a2911abdccb5abc11471efe61cc20f7a))
|
||||
* **frontend:** fix missing styles for alert component ([de3d288](https://github.com/sct/overseerr/commit/de3d288949b60d3a3af889d69a62bea2bc799ed7))
|
||||
* **frontend:** fix mobile dropdown in notifications settings ([6353cda](https://github.com/sct/overseerr/commit/6353cda5825f442dd539886c7b9ba437edf27ac4))
|
||||
* **frontend:** fix scaling titlecard content position ([bd94740](https://github.com/sct/overseerr/commit/bd947409e6e8ff313011b77adc76ccd5f9112c78))
|
||||
* **frontend:** fix sliders overflowing on firefox ([67ac9e0](https://github.com/sct/overseerr/commit/67ac9e075f0ca1cfe7e4766d9168815d7ab600fa)), closes [#566](https://github.com/sct/overseerr/issues/566)
|
||||
* **frontend:** full season request modal fits on a smaller mobile UI ([#535](https://github.com/sct/overseerr/issues/535)) ([12db7a0](https://github.com/sct/overseerr/commit/12db7a065ad566b47d46de4b949343290894f153))
|
||||
* **frontend:** handle currentLibrary possibly being null on first manual sync ([93b57a7](https://github.com/sct/overseerr/commit/93b57a76f10a823615ca11ff59f523b67aa30fad))
|
||||
* **frontend:** hide Request More button if all current seasons are available ([2a4dd52](https://github.com/sct/overseerr/commit/2a4dd52275007e48f946c3b9e29f1d78da57bdaa)), closes [#343](https://github.com/sct/overseerr/issues/343)
|
||||
* **frontend:** improve flex header on movie/tv details page ([d7b1c28](https://github.com/sct/overseerr/commit/d7b1c2840690c144ebf29a360defcbd6fdb21354))
|
||||
* **frontend:** increase titlecard status badge size on larger screens ([ba106c4](https://github.com/sct/overseerr/commit/ba106c447d76db2f9ac70a60c5b38cc60ab554fe))
|
||||
* **frontend:** invalid dom-nesting title card fix ([#482](https://github.com/sct/overseerr/issues/482)) ([f2ebba7](https://github.com/sct/overseerr/commit/f2ebba7b1df775d33d2af6abc3ee2c9de5f2e57a)), closes [#476](https://github.com/sct/overseerr/issues/476)
|
||||
* **frontend:** only animate titlecard when showDetail is true ([0ab4c3c](https://github.com/sct/overseerr/commit/0ab4c3c36fe2c1ded142b6931111516f7f990a41))
|
||||
* **frontend:** remove vote permission for now ([5d06a34](https://github.com/sct/overseerr/commit/5d06a347311bd10c05d8f58068ca7104e265dcca))
|
||||
* **frontend:** request dropdown menu now properly shows up over collection button ([b491be1](https://github.com/sct/overseerr/commit/b491be1b1e7f6aa588274230e695e4c5302b961e))
|
||||
* **frontend:** search clear button now correctly triggers routing ([343f466](https://github.com/sct/overseerr/commit/343f466788abc308b91a414ef61bba816ac8875c))
|
||||
* **frontend:** set locale cookie expiration to be much longer ([fae4818](https://github.com/sct/overseerr/commit/fae481895736eab81d52eb93788beb00669fb355))
|
||||
* **frontend:** show backdrops instead of posters for new person detail design ([9f5f920](https://github.com/sct/overseerr/commit/9f5f920c23007363aa7f53ebef0b61236d4f53ea))
|
||||
* **frontend:** show correct request status on request cards for 4k requests ([1aa0005](https://github.com/sct/overseerr/commit/1aa0005b4298fc1af9c1d0bf1f357738c0fa2673))
|
||||
* **frontend:** show movie/series badges always ([8cbf39a](https://github.com/sct/overseerr/commit/8cbf39a9d12eaee7720fa4721c350c1ef9dee856))
|
||||
* **frontend:** sort person detail credits by tmdb votes ([17518db](https://github.com/sct/overseerr/commit/17518dbe7f545100770a892d03d1f8508adc3650))
|
||||
* **frontend:** status badge Unavailable renamed to Requested ([ed94a0f](https://github.com/sct/overseerr/commit/ed94a0f335c59de526dd812aea7616313fe002fd)), closes [#374](https://github.com/sct/overseerr/issues/374)
|
||||
* **frontend:** update login/setup images ([058fb65](https://github.com/sct/overseerr/commit/058fb65495baa08a0bd4c9e0aef320c6fc7d017b))
|
||||
* **frontend:** update titlecard status badge to new requested colors ([8f292d5](https://github.com/sct/overseerr/commit/8f292d538b937ea133175089979ef02599f6fef4))
|
||||
* **frontend:** use hardware acceleration for titlecard scale ([88810bf](https://github.com/sct/overseerr/commit/88810bf0a4ef74299f6541b60fa91cea3610f99c))
|
||||
* **holiday:** remove special holiday slider ([8c09033](https://github.com/sct/overseerr/commit/8c0903393cf2cb2a929ba70a8ab6ddcc4cba0574))
|
||||
* **logs:** improve logging when adding to sonarr/radarr ([4b50522](https://github.com/sct/overseerr/commit/4b505223b881a750007e3fbc7d4bcb9677d4d412))
|
||||
* **logs:** rotate logs on a daily basis instead of incrementing log filename ([395cbb2](https://github.com/sct/overseerr/commit/395cbb2be6c62f1d7573593e49a93615eaf22853))
|
||||
* **notifications:** only send one available notification for standard media ([fc6f7cc](https://github.com/sct/overseerr/commit/fc6f7ccea586165a30022b6d5554911c66ece6df))
|
||||
* **plex-sync:** do not run plex sync if no admin exists ([493d82b](https://github.com/sct/overseerr/commit/493d82b6b066d77609cf66e005fd1f1472b8e011))
|
||||
* **plex-sync:** improve plex sync error handling. add session id to fix stuck runs ([a740b07](https://github.com/sct/overseerr/commit/a740b07f06f892b72a651b928af28ce71cb495ee))
|
||||
* **requests:** select the correct radarr/sonarr server when sending request to service ([e0d9f89](https://github.com/sct/overseerr/commit/e0d9f891e797c3839f976b75a871903b6f2e55f1))
|
||||
* change default internal port to 5055 ([#389](https://github.com/sct/overseerr/issues/389)) ([5e5ba40](https://github.com/sct/overseerr/commit/5e5ba4050563f07bff367d2fb31ed7e7fca4291e))
|
||||
* clarify full sync runs every 24 hours ([0c8a180](https://github.com/sct/overseerr/commit/0c8a180189b2610bab2fa977d458743d8a60343e))
|
||||
* correctly deal with tmdb id duplicates between movies/series ([721ed9a](https://github.com/sct/overseerr/commit/721ed9a93087a57ae749388bddcacf26022e3df6)), closes [#526](https://github.com/sct/overseerr/issues/526)
|
||||
* **api:** accept the api key to perform actions on the api with X-API-Key header ([33f8831](https://github.com/sct/overseerr/commit/33f8831e880dc7fd3f69d951246cada5c6c0ffe7))
|
||||
* **api:** correctly generate clientId on first startup ([5f09e83](https://github.com/sct/overseerr/commit/5f09e83ed870336638d3e9d94fcf55ead928e737))
|
||||
* **api:** filter out libraries that do not have any metadata agent or are not movie/show ([01c179f](https://github.com/sct/overseerr/commit/01c179f762e686a1e5a3d4dab3a5bea53425b575))
|
||||
* **api:** only run recently added sync on enabled libraries ([e08fa35](https://github.com/sct/overseerr/commit/e08fa35548bb8644afa8df3124e6f9cc3a2c8f4a)), closes [#259](https://github.com/sct/overseerr/issues/259)
|
||||
* **api:** require package.json directly so typescript doesnt compile it into dist folder ([b9faa64](https://github.com/sct/overseerr/commit/b9faa6486b35aa865019aa8af9d307531054bc1d))
|
||||
* **api:** set plex libraries to disabled if the name changes ([675060b](https://github.com/sct/overseerr/commit/675060bcdf23acbfd4de2900a65f95e74f4966a5)), closes [#324](https://github.com/sct/overseerr/issues/324)
|
||||
* **email:** do not pass auth object to transport if no auth data present ([d5eb4d8](https://github.com/sct/overseerr/commit/d5eb4d8d438a159266b2de66b6bcdd9440a0c8ef)), closes [#312](https://github.com/sct/overseerr/issues/312)
|
||||
* **frontend:** add http/https prefix to hostname fields for plex/radarr/sonarr ([ce0266f](https://github.com/sct/overseerr/commit/ce0266f74ea3979b291ff962271a928682892788)), closes [#357](https://github.com/sct/overseerr/issues/357)
|
||||
* **frontend:** add validation for Radarr/Sonarr server name ([b5988f9](https://github.com/sct/overseerr/commit/b5988f9a5ff274e97f208c2726abe76c22c858ee))
|
||||
* **frontend:** adds a tip to plex setup to clarify that syncing runs in the background ([df4ac83](https://github.com/sct/overseerr/commit/df4ac8361f82971ee845f3be217408a9123a0bf3)), closes [#325](https://github.com/sct/overseerr/issues/325)
|
||||
* **frontend:** aligned movie and tv details ([#331](https://github.com/sct/overseerr/issues/331)) ([db0a5c4](https://github.com/sct/overseerr/commit/db0a5c44f678e76eee7f5582381016306d1f46a2))
|
||||
* **frontend:** also convert activeProfileId to a number for radarr/sonarr submissions ([7bf924f](https://github.com/sct/overseerr/commit/7bf924f7e94a0e0834f41b4ec067ed277c652766))
|
||||
* **frontend:** also convert ports to numbers when saving radarr/sonarr servers ([c53dc3b](https://github.com/sct/overseerr/commit/c53dc3b15da522c6e6ab76bbc9d15008a8a9fb9d))
|
||||
* **frontend:** clarify that radarr/sonnarr servers must be tested before profiles/folders appear ([fc12ab8](https://github.com/sct/overseerr/commit/fc12ab84d9482eb3a11f117f8cab6fd48a9401cd)), closes [#326](https://github.com/sct/overseerr/issues/326) [#328](https://github.com/sct/overseerr/issues/328)
|
||||
* **frontend:** close sidebar when clicking outside ([#333](https://github.com/sct/overseerr/issues/333)) ([6d7907e](https://github.com/sct/overseerr/commit/6d7907e844a909993d185759d660632f55aeaa35))
|
||||
* **frontend:** convert plex port to a number before posting to the api ([8cb05c4](https://github.com/sct/overseerr/commit/8cb05c413a15a4b74e37ece5e24367d115995b32))
|
||||
* **frontend:** converts email smtp port to a number before posting to the api ([2098a2d](https://github.com/sct/overseerr/commit/2098a2d3d2981fd2ae54392aec3ef81327f2858e)), closes [#251](https://github.com/sct/overseerr/issues/251)
|
||||
* **frontend:** encode special characters in search input to prevent crashing router ([15013d6](https://github.com/sct/overseerr/commit/15013d6c5dbff15704c7c30d261d68a265e7f2d7)), closes [#252](https://github.com/sct/overseerr/issues/252)
|
||||
* **frontend:** fix tv shows failing to open when firstAirDate is undefined ([c21fa5b](https://github.com/sct/overseerr/commit/c21fa5b5350abdd8e03c077fde7246fa398e176e)), closes [#347](https://github.com/sct/overseerr/issues/347)
|
||||
* **frontend:** make minimum availability required for Radarr servers ([2fe53ec](https://github.com/sct/overseerr/commit/2fe53ec5a8534e75c7d0cef31a8b46065111e0a7)), closes [#345](https://github.com/sct/overseerr/issues/345)
|
||||
* **frontend:** new radarr/sonarr ports will be converted to a number before posting ([92c9001](https://github.com/sct/overseerr/commit/92c9001c9d1f2cbd272a5897ea1157d2cadbce2d))
|
||||
* **frontend:** only show alpha notice to admins ([ff61895](https://github.com/sct/overseerr/commit/ff618956b5d9cf933d867ea979b612c3d8a6f30b))
|
||||
* **frontend:** person cards now show correctly in ListView's ([ccb9855](https://github.com/sct/overseerr/commit/ccb98553f104c1aebd33796b7090cc9bbe964bd7))
|
||||
* **frontend:** properly remove site overlay when closing modals ([3fa7ff9](https://github.com/sct/overseerr/commit/3fa7ff9858d14d132151f3329164d55d74638f53))
|
||||
* **frontend:** switch to using Transition component for modals ([b16fbaf](https://github.com/sct/overseerr/commit/b16fbafa1f3d5e105c0a4ba6f1d66aa064019636)), closes [#220](https://github.com/sct/overseerr/issues/220)
|
||||
* **plex sync:** catch errors that occur during processMovie ([edbbccf](https://github.com/sct/overseerr/commit/edbbccf3ae623430294f1a5c3fd2728dbd42e555)), closes [#244](https://github.com/sct/overseerr/issues/244) [#246](https://github.com/sct/overseerr/issues/246) [#250](https://github.com/sct/overseerr/issues/250)
|
||||
* **plex-sync:** bundle duplicate ratingKeys to speed up recently added sync ([67146c3](https://github.com/sct/overseerr/commit/67146c33ef7f28d520ba2c50b32673d43f4525c8)), closes [#360](https://github.com/sct/overseerr/issues/360)
|
||||
* **services:** improve logging for adding movies to Radarr ([6c1ee83](https://github.com/sct/overseerr/commit/6c1ee830a183f89bb1fe96a181a7d61684e23b22))
|
||||
* **services:** include radarr/sonarr baseUrl when adding media ([78af1a3](https://github.com/sct/overseerr/commit/78af1a3e6d00a5645a05e7bf3cf56a59439b6cc9))
|
||||
* **sonarr.ts, mediarequest.ts:** add missing seasonFolder option ([#358](https://github.com/sct/overseerr/issues/358)) ([e9c899c](https://github.com/sct/overseerr/commit/e9c899ce419d149dde2ad9a0f7d5a2f2545b3ebf))
|
||||
* add support for ssl when connecting to plex ([3ba09d0](https://github.com/sct/overseerr/commit/3ba09d07eb0367c41603cd55e7ff41c66fb641c4)), closes [#275](https://github.com/sct/overseerr/issues/275)
|
||||
* changing parameter name to use correct 'port' [#276](https://github.com/sct/overseerr/issues/276) ([#277](https://github.com/sct/overseerr/issues/277)) ([6d08b10](https://github.com/sct/overseerr/commit/6d08b108200177ca3068c852e60a0df75ce2232a))
|
||||
* spelling mistake on the word 'requested' fixed ([#319](https://github.com/sct/overseerr/issues/319)) ([961d110](https://github.com/sct/overseerr/commit/961d1107208069a6fc820a1ba97ffda7336677cb))
|
||||
* **services:** improve logging for when Radarr movie already exists ([#285](https://github.com/sct/overseerr/issues/285)) ([f998873](https://github.com/sct/overseerr/commit/f998873fc5669a547901f2733c9c785d744d27ca)), closes [#260](https://github.com/sct/overseerr/issues/260)
|
||||
* **services:** radarr/sonarr will use the correct default server ([0658b79](https://github.com/sct/overseerr/commit/0658b7943e1ab25816db9da34d4c9ea808d9203d))
|
||||
* fix missing personid in Discover ([d8060af](https://github.com/sct/overseerr/commit/d8060afe02574337f51b88cab0a0f824976ac721))
|
||||
* missing personId in ListView component ([6502feb](https://github.com/sct/overseerr/commit/6502feb1a5be3c6daab33230814fe74632c87f7e))
|
||||
* **api:** fix scheduling for plex full sync (maybe) ([7287a6a](https://github.com/sct/overseerr/commit/7287a6a95703b23acc0c4f6eb3beb9ec2295e33f))
|
||||
* **api:** sort users requests by most recent ([1798383](https://github.com/sct/overseerr/commit/17983837fc10661a59d29fc1531530fca0d77825))
|
||||
* **api:** Use POST instead of GET for API endpoints that mutate state ([#877](https://github.com/sct/overseerr/issues/877)) ([ff0b5ed](https://github.com/sct/overseerr/commit/ff0b5ed44132cc5a0cd178035796d042ba735a8d))
|
||||
* **auth:** handle sign-in attempts from emails with no password ([#933](https://github.com/sct/overseerr/issues/933)) ([5e37a96](https://github.com/sct/overseerr/commit/5e37a96bc017471f8dc4cbdd57f2e8c3568bd97f))
|
||||
* **frontend:** always show request modal option for tv ([2b46268](https://github.com/sct/overseerr/commit/2b462688243531b4be620a942f59defd4e0534d0))
|
||||
* **frontend:** canceled movie request should set parent movie status back to unknown ([#198](https://github.com/sct/overseerr/issues/198)) ([139871f](https://github.com/sct/overseerr/commit/139871f218812a15f742aa66408db12704e0b9b5))
|
||||
* **frontend:** changed plex, request, and cog buttons to align properly on smaller mobile UIs ([#928](https://github.com/sct/overseerr/issues/928)) ([f1c3358](https://github.com/sct/overseerr/commit/f1c335815f2f17465cdd36ceb223e78a58149b3b))
|
||||
* **frontend:** check for id instead of email after logging in ([c4af4c4](https://github.com/sct/overseerr/commit/c4af4c42ab00f1a63a2f5326c9cd8b26c19f4f14))
|
||||
* **frontend:** close request modals when complete ([85ae499](https://github.com/sct/overseerr/commit/85ae4998f0ba8d4869b9b244f2c440b9df1310d2))
|
||||
* **frontend:** Do not allow user w/ ID 1 to disable 'Admin' permission ([#965](https://github.com/sct/overseerr/issues/965)) ([77b2d9e](https://github.com/sct/overseerr/commit/77b2d9ea22a2f70cff58ac9421f3f6231bc93059))
|
||||
* **frontend:** dont show delete button in request list for users without correct permission ([83fde46](https://github.com/sct/overseerr/commit/83fde46a59c6f1910806a6106b5526b8adbc386c))
|
||||
* **frontend:** dont show runtime if there is no runtime data ([e0c39ae](https://github.com/sct/overseerr/commit/e0c39aeca119b822f2a54ff05a97f91780ddd052))
|
||||
* **frontend:** fix missing data for request modal title i18n ([a56fd16](https://github.com/sct/overseerr/commit/a56fd16ab6638d4649fe9f8b9d75e7cae7742f73))
|
||||
* **frontend:** fix missing import for ReactNode type in Slider ([b26a234](https://github.com/sct/overseerr/commit/b26a2347e7b0f7ff8720a204d9faefd501ba886c))
|
||||
* **frontend:** fix modal design and rename some text for adding servers ([46d99b0](https://github.com/sct/overseerr/commit/46d99b02b1c992c7b8dde2150217ed9ce326b7a5))
|
||||
* **frontend:** fix opening popups on safari ([364d9d1](https://github.com/sct/overseerr/commit/364d9d105ca3690fcd5f635485d7c025353bb9f1))
|
||||
* **frontend:** fix title detail background image to be centered ([b92f64f](https://github.com/sct/overseerr/commit/b92f64fa6e167bc89168d8f5c0f2eb12efa0b6f0))
|
||||
* **frontend:** fixed similar/recommendations showing when empty ([#180](https://github.com/sct/overseerr/issues/180)) ([a3ca9b4](https://github.com/sct/overseerr/commit/a3ca9b40c552e6cc5effc2f57f7562ff6f723e42))
|
||||
* **frontend:** have tvDetail use the new RequestModal ([6aca826](https://github.com/sct/overseerr/commit/6aca82607b97d4a4ad74e2ea843d52fba4689e6a))
|
||||
* **frontend:** remove requestId from tilecard request modal component ([61b6152](https://github.com/sct/overseerr/commit/61b6152e8915c99585b944756a61d33b8c8a0307))
|
||||
* **frontend:** update overflow issues with seasons + email ([#217](https://github.com/sct/overseerr/issues/217)) ([2d0afb2](https://github.com/sct/overseerr/commit/2d0afb29d37798a626e3f182571ccce43d80063c)), closes [#216](https://github.com/sct/overseerr/issues/216)
|
||||
* **lang:** fix missing i18n string for agent enabled in email notification page ([42788ad](https://github.com/sct/overseerr/commit/42788adb75f7d23e68327688b1c542dd047e9609))
|
||||
* fix a few misc unused imports and useless assignments/conditionals ([8e6daf7](https://github.com/sct/overseerr/commit/8e6daf7bd271ce5bebf4a00f5bb1144bd6b60aa5))
|
||||
* improve apple-touch-icon and android app icons ([329a814](https://github.com/sct/overseerr/commit/329a814a8fb791122266c0b04b05848c71d68ba1))
|
||||
* **frontend:** fix request card placeholder sizes for mobile ([ef62c67](https://github.com/sct/overseerr/commit/ef62c67480ed52d753ea6db8205f035b2e9da272))
|
||||
* **frontend:** handle empty array of media attributes ([#922](https://github.com/sct/overseerr/issues/922)) ([04fa9f7](https://github.com/sct/overseerr/commit/04fa9f79e2ec90082b3fa15590dd170f7d68ad52))
|
||||
* **frontend:** push updated i18n locale files ([b4002d7](https://github.com/sct/overseerr/commit/b4002d71323a04e7991198cedc263660e872df8d))
|
||||
* **frontend:** request and cog button would be misaligned without play on plex/watch trailer button ([#956](https://github.com/sct/overseerr/issues/956)) ([e28dfad](https://github.com/sct/overseerr/commit/e28dfadaf57d47887013c31dc5006332473156e3))
|
||||
* **frontend:** show a badge on requestcard for partially available status ([59056c4](https://github.com/sct/overseerr/commit/59056c44f942a37df536ff947b5faccc27f32246))
|
||||
* **frontend:** try not to render broken rottentomatoes data ([a0c5608](https://github.com/sct/overseerr/commit/a0c5608aa0b6c7a4294300589efa9a662163ce48))
|
||||
* **frontend:** Update AdvancedRequester to reflect new /user API response ([#970](https://github.com/sct/overseerr/issues/970)) ([b4bac6a](https://github.com/sct/overseerr/commit/b4bac6a9157119a4f234933245944e133c127bd0))
|
||||
* **frontend:** use region settings instead of hardcoded 'US' value for movie/TV ratings ([#1006](https://github.com/sct/overseerr/issues/1006)) ([6ecd202](https://github.com/sct/overseerr/commit/6ecd202607cb48d559440da810ecc585e740542b))
|
||||
* **lang:** formatMessage should not use an object spread ([8a7fa00](https://github.com/sct/overseerr/commit/8a7fa00164fd5c5501da525baa29be97bac7e7c4))
|
||||
* **lang:** Remove unused strings and correct spelling of 'canceling'/'canceled' ([#981](https://github.com/sct/overseerr/issues/981)) ([5b64655](https://github.com/sct/overseerr/commit/5b646557765d1ad75e44e1c0e60e0291313c7746))
|
||||
* **login:** fix the gap when 'use your overseer account' was selected ([#870](https://github.com/sct/overseerr/issues/870)) ([d163e29](https://github.com/sct/overseerr/commit/d163e294599c4bd9bdc0a148db15c8e8541410d8))
|
||||
* **migration:** fixes issue migrating away from the unique imdbId constraint ([69fd7a5](https://github.com/sct/overseerr/commit/69fd7a5511215674a5c22ba48627f221da900229))
|
||||
* **notif:** Do not HTML-escape email subjects ([#931](https://github.com/sct/overseerr/issues/931)) ([019622a](https://github.com/sct/overseerr/commit/019622aab1b94cc4d71cacbf0dc5cf64b62c8623))
|
||||
* **notif:** Remove extra newlines from Telegram notifications ([#973](https://github.com/sct/overseerr/issues/973)) ([bbea522](https://github.com/sct/overseerr/commit/bbea52249950eb98a8d3886f2bd7648a7d669bf4))
|
||||
* **plex:** Check Plex server access on user import ([#955](https://github.com/sct/overseerr/issues/955)) ([bdb3cb2](https://github.com/sct/overseerr/commit/bdb3cb202550e34d8951ac2b5015f97f6a5c1ebf))
|
||||
* **plex-sync:** fixes processing movies using TMDB agent ([764db94](https://github.com/sct/overseerr/commit/764db94f1bd7866309684d5bd56033b21cbc2e0c)), closes [#363](https://github.com/sct/overseerr/issues/363)
|
||||
* **plex-sync:** get correct Plex metadata for Hama movie items ([#901](https://github.com/sct/overseerr/issues/901)) ([03cecb3](https://github.com/sct/overseerr/commit/03cecb33559e27199c5a174fc86de0c4550fe666)), closes [#898](https://github.com/sct/overseerr/issues/898)
|
||||
* **plex-sync:** match correct tmdb format for movies ([4205e32](https://github.com/sct/overseerr/commit/4205e32ae71bc18c07209f1c82e6af1cb5f01335))
|
||||
* **requests:** correctly filter requests out for users without view requests permission ([e118501](https://github.com/sct/overseerr/commit/e118501bf1dfa8dada2c57090e62631de620f3dd))
|
||||
* **requests:** correctly handle when tvdbid is missing ([#891](https://github.com/sct/overseerr/issues/891)) ([e037ba4](https://github.com/sct/overseerr/commit/e037ba48f173c06b0c9c8b03085edf832d770c06))
|
||||
* **search:** Handle search errors and escape * ([#893](https://github.com/sct/overseerr/issues/893)) ([034968e](https://github.com/sct/overseerr/commit/034968e4370eaea726c94730274349c083856813))
|
||||
* **services:** update all radarr/sonarr endpoints to use v3 ([da5ca02](https://github.com/sct/overseerr/commit/da5ca02f81fe91070afbda3e1ebc8d869fe39a8f))
|
||||
* **sonarr:** use qualityProfileId instad of profileId when adding series ([552a7e3](https://github.com/sct/overseerr/commit/552a7e30da5fc2cc0bd43b5aef79a0225c75d233))
|
||||
* **sync:** fix sonarr/plex sync fighting over availability ([9b73423](https://github.com/sct/overseerr/commit/9b73423d49e1e799cd82764a9ade8c75d92a28a2)), closes [#872](https://github.com/sct/overseerr/issues/872)
|
||||
* **ui:** add fallback for region display name ([f9c83e1](https://github.com/sct/overseerr/commit/f9c83e14e52a57d6865307b3324a61c04a77a541))
|
||||
* **ui:** add missing string for default Discover Language & edit string for default Discover Region ([#1004](https://github.com/sct/overseerr/issues/1004)) ([0acad8e](https://github.com/sct/overseerr/commit/0acad8e9fa65a9de6cecac9b6a4a5b2313ba8f06))
|
||||
* **ui:** Add tip & validation for Discord ID input ([#966](https://github.com/sct/overseerr/issues/966)) ([e70a4ec](https://github.com/sct/overseerr/commit/e70a4ecae613e045977e262fd7f9643f30985ab7))
|
||||
* **ui:** also allow 17 digit discord ids ([57c00c1](https://github.com/sct/overseerr/commit/57c00c1ea71c1229d5a59e1b8dadd84a646772b9)), closes [#971](https://github.com/sct/overseerr/issues/971)
|
||||
* **ui:** Automatically disable and uncheck user permissions with unmet requirements ([#941](https://github.com/sct/overseerr/issues/941)) ([c9a150b](https://github.com/sct/overseerr/commit/c9a150b1db2adbb305cf1a448489d7a8c14cf1cb))
|
||||
* **ui:** change font size in request list/user list dropdowns to prevent zoom on mobile ([fb9c878](https://github.com/sct/overseerr/commit/fb9c878db49c01d13773e8d2f94c93f840be0b82))
|
||||
* **ui:** Display 4K download status on 4K status badge ([#988](https://github.com/sct/overseerr/issues/988)) ([40b07c3](https://github.com/sct/overseerr/commit/40b07c35d40c03039e4bfa5ed1e73af7e8aa6a7d))
|
||||
* **ui:** Fix settings navigation horizontal scroll issues ([#987](https://github.com/sct/overseerr/issues/987)) ([8701fb2](https://github.com/sct/overseerr/commit/8701fb20d07773f4cc32e857b68575a813cf7e21))
|
||||
* **ui:** fixed anime language profile typo ([#879](https://github.com/sct/overseerr/issues/879)) ([ee50761](https://github.com/sct/overseerr/commit/ee5076146ef3c5e8baba197a5b397d3c3f575262))
|
||||
* **ui:** Notification-related string/UI edits and field validation ([#985](https://github.com/sct/overseerr/issues/985)) ([c88fcb2](https://github.com/sct/overseerr/commit/c88fcb2e2d1c4b84527844a80680c15337626e72))
|
||||
* **ui:** Use minimum char validation message ([#850](https://github.com/sct/overseerr/issues/850)) ([7456bea](https://github.com/sct/overseerr/commit/7456bea2ae600a28cb933278ffb310b63a474d6a))
|
||||
* **ui:** validate application url and service external urls ([026795d](https://github.com/sct/overseerr/commit/026795d4c940cb4797d3e68089456a4c3defbb21))
|
||||
* **ui:** when PersonCard has no profilePath, correctly position name/role content ([3ffd5ab](https://github.com/sct/overseerr/commit/3ffd5ab0ee8ffa63199d1428e37206f9b59fb7a5))
|
||||
* **ui/notif:** Custom application title in password-related emails and UI messages ([#979](https://github.com/sct/overseerr/issues/979)) ([4e2706b](https://github.com/sct/overseerr/commit/4e2706b4211b06f364910c327d84c2ceb45b2fe3))
|
||||
* dont cross import SyncStatus type ([e032e38](https://github.com/sct/overseerr/commit/e032e385a5253d215490255c676f42ee48f39428))
|
||||
* fix type import from server side crashing build process ([89be56d](https://github.com/sct/overseerr/commit/89be56d8403ebc60c411e7cb357593edd9c79bb2))
|
||||
* lookup movie by imdbid if tmdbid does not exits for plex movie agent ([#711](https://github.com/sct/overseerr/issues/711)) ([e972288](https://github.com/sct/overseerr/commit/e97228899a5936b2525c8060abfa14b5ce31658d))
|
||||
* only run migrations in production ([ab9cef3](https://github.com/sct/overseerr/commit/ab9cef3624b5db1ec03507553a69d33b87857e29))
|
||||
* show recently added series even if they are not complete ([d0c830e](https://github.com/sct/overseerr/commit/d0c830e80d389f9e0f48a9b83659331f54630d03))
|
||||
* use new commit tag file for app version as well ([d00e470](https://github.com/sct/overseerr/commit/d00e470b55327489b49d770144b7cfdb24045be6))
|
||||
* **frontend:** reinitalize plex form after data loads ([97e3036](https://github.com/sct/overseerr/commit/97e30367fb5d2d27efc42c1d76b0d051b6f1da76))
|
||||
* **frontend:** run initial props for children components after getting the user ([fdf9f38](https://github.com/sct/overseerr/commit/fdf9f38776b6d4c08b3505c03b354639cebb011f))
|
||||
* **frontend:** when there were no results in the list view, it would call fetch more infinitely ([c0ce87b](https://github.com/sct/overseerr/commit/c0ce87b6f65bf0ab1301c7ca61090d779709529f))
|
||||
* **notifications:** always update the media table when seasons become available ([0916b58](https://github.com/sct/overseerr/commit/0916b58594a00db98c6701fdcaee4f3c3e08904e))
|
||||
* **user edit:** fix user edit not being able to be saved ([#651](https://github.com/sct/overseerr/issues/651)) ([b04d00e](https://github.com/sct/overseerr/commit/b04d00ef509d6f13c1f9677b3f318331782c0086))
|
||||
* fixed an issue with eslint-prettier on windows ([#32](https://github.com/sct/overseerr/issues/32)) ([b673ea1](https://github.com/sct/overseerr/commit/b673ea1b18ca0f432996bb9e4e5d148af0247170))
|
||||
* fixes next.js build to not include server files ([de8ee9b](https://github.com/sct/overseerr/commit/de8ee9ba85e0160b0b472cab44f92c01796efec8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** /request/count endpoint ([#682](https://github.com/sct/overseerr/issues/682)) ([192cfd8](https://github.com/sct/overseerr/commit/192cfd8a8ea9ab942d5bb265d42050917a2f5a04))
|
||||
* **api:** add movie keyword search ([f88c4a6](https://github.com/sct/overseerr/commit/f88c4a6d4a49f8f3451ba6c85153677f33b7f5f6))
|
||||
* **auth:** Add optional CSRF protection ([#697](https://github.com/sct/overseerr/issues/697)) ([6e25891](https://github.com/sct/overseerr/commit/6e2589178b99f8f32f0ded9a7cfd9921c33e9b60))
|
||||
* **cache:** add cache table and flush cache option to settings ([996bd9f](https://github.com/sct/overseerr/commit/996bd9f14ed0f56767892c169b071be4f0f628d0))
|
||||
* **cache:** external API cache ([#786](https://github.com/sct/overseerr/issues/786)) ([20289b5](https://github.com/sct/overseerr/commit/20289b5960a93545cdff9331a1a7b613f382e702))
|
||||
* **email:** add sendername to email notification ([#506](https://github.com/sct/overseerr/issues/506)) ([0185bb1](https://github.com/sct/overseerr/commit/0185bb1a7084c1faeb61fb1c63e34e26732711c8))
|
||||
* **email:** option to allow self signed certificates ([6898357](https://github.com/sct/overseerr/commit/6898357b13a6aa53a55709ea95819c2b3df6784c))
|
||||
* **frontend:** add clear-field-icon to search field ([#498](https://github.com/sct/overseerr/issues/498)) ([7434a26](https://github.com/sct/overseerr/commit/7434a26f76b5e9f74918f3e1a34443d20ecfcbe4))
|
||||
* **frontend:** add crew related movies/shows to person details page ([12127a7](https://github.com/sct/overseerr/commit/12127a77633f0e92ae88cbafd49581296f559c33))
|
||||
* **frontend:** add documentation link to about page ([c034496](https://github.com/sct/overseerr/commit/c034496f557a031aed35cd28dc7221d8cdf36643))
|
||||
* **frontend:** add external links to movie and tv detail pages ([a0024a0](https://github.com/sct/overseerr/commit/a0024a0cbe717d78f53413bb78644c829f143c4d))
|
||||
* **frontend:** add full crew page for movies/shows ([604ba2a](https://github.com/sct/overseerr/commit/604ba2a92f1d59489e7fc6dfc011347f8595c123))
|
||||
* **frontend:** add language picker to setup/login ([ff2ab29](https://github.com/sct/overseerr/commit/ff2ab29491a80c421525b9a394d6fbbf54914dc2))
|
||||
* **frontend:** add option to hide all available items from discovery ([#699](https://github.com/sct/overseerr/issues/699)) ([6c1742e](https://github.com/sct/overseerr/commit/6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae))
|
||||
* **frontend:** add prioritized crew under overview ([6753d9d](https://github.com/sct/overseerr/commit/6753d9daaafb18672f14fd86f2c1675dcec39b13)), closes [#406](https://github.com/sct/overseerr/issues/406)
|
||||
* **frontend:** add see more card to media sliders ([587e8db](https://github.com/sct/overseerr/commit/587e8db15e9c19b4c58406e3e4215d8bf87d8762))
|
||||
* **frontend:** add studio/networks to movie/tv details ([4b6ad8a](https://github.com/sct/overseerr/commit/4b6ad8a3871957db4192b603abf38404250cea5d)), closes [#370](https://github.com/sct/overseerr/issues/370)
|
||||
* **frontend:** add support overseerr block to about page ([c128898](https://github.com/sct/overseerr/commit/c128898206d6cbb482de4d8dca53f70b87e4911a))
|
||||
* **frontend:** add telegram integration ([#491](https://github.com/sct/overseerr/issues/491)) ([c8d4d67](https://github.com/sct/overseerr/commit/c8d4d674f412082ad9e9da09abd79660365cf728))
|
||||
* **frontend:** add template variable help button to custom webhook settings page ([29c5bc4](https://github.com/sct/overseerr/commit/29c5bc40975e7ab0a2e08bb77294f164f0c60769))
|
||||
* **frontend:** adjust person details design and add improved truncate ([1fb7ea7](https://github.com/sct/overseerr/commit/1fb7ea72589d2908ae80a2a688881d4eb3c050e5))
|
||||
* **frontend:** filter/sorting for request list ([5add44c](https://github.com/sct/overseerr/commit/5add44cfb0379aa6fed7c3b867230292feacc684)), closes [#431](https://github.com/sct/overseerr/issues/431)
|
||||
* **frontend:** first air date added to TV details page ([#470](https://github.com/sct/overseerr/issues/470)) ([a7db01f](https://github.com/sct/overseerr/commit/a7db01fba483ca633a6eb9d39eb085ab9939d4d2))
|
||||
* **frontend:** releases added to about page ([b7f5739](https://github.com/sct/overseerr/commit/b7f573903500cc8a62e39afd787bc1da8c09d88b)), closes [#303](https://github.com/sct/overseerr/issues/303)
|
||||
* **holiday:** special seasonal slider added to discover :) ([908f635](https://github.com/sct/overseerr/commit/908f63557ca03a1da8b16809ffa2c3acd782d94e))
|
||||
* **lang:** add support for Chinese (Traditional) language ([686c4f7](https://github.com/sct/overseerr/commit/686c4f71bf930625af082ac5e14dc5f79f5c42eb))
|
||||
* **lang:** add support for Italian, Portuguese (Brazil) and Serbian ([108dfc4](https://github.com/sct/overseerr/commit/108dfc4afd31388cb6c9e07deccd168ade8b1574))
|
||||
* **lang:** add support for swedish language ([c9fe6cb](https://github.com/sct/overseerr/commit/c9fe6cb0b7ea984d8e4e1cb3f284935c9da7cc2b))
|
||||
* **lang:** translated using Weblate (French) ([4789583](https://github.com/sct/overseerr/commit/4789583d66305ac7b3d393659b2f3604c0acc576))
|
||||
* **lang:** translated using Weblate (German) ([c2a3e8e](https://github.com/sct/overseerr/commit/c2a3e8ed5243925dce991ec7995ae831702dbc7b))
|
||||
* **lang:** translated using Weblate (Italian) ([9aa5c12](https://github.com/sct/overseerr/commit/9aa5c121644518c1fbb308a487c26d8998bb5a36))
|
||||
* **lang:** translated using Weblate (Portuguese (Brazil)) ([98570c9](https://github.com/sct/overseerr/commit/98570c920e4904a594bb7464161b985094958f84))
|
||||
* **lang:** translated using Weblate (Portuguese (Portugal)) ([f001fb3](https://github.com/sct/overseerr/commit/f001fb3b33d4fb749acb70c45b8a55a5bbef570c))
|
||||
* **lang:** translated using Weblate (Spanish) ([4f94d22](https://github.com/sct/overseerr/commit/4f94d227fc3096bcb8a1e5cf12fe9222d6c6b711))
|
||||
* **lang:** translations update from Weblate ([#1051](https://github.com/sct/overseerr/issues/1051)) ([69bf817](https://github.com/sct/overseerr/commit/69bf817f598babed99964f073259f827b60bd014))
|
||||
* **lang:** Translations update from Weblate ([#1131](https://github.com/sct/overseerr/issues/1131)) ([e4686d6](https://github.com/sct/overseerr/commit/e4686d664b52448e32488ff1c4236f72e01e9a29))
|
||||
* **lang:** translations update from Weblate ([#380](https://github.com/sct/overseerr/issues/380)) ([8408e19](https://github.com/sct/overseerr/commit/8408e19568b2f239c57e11e2946c75f193d1c22e))
|
||||
* **lang:** translations update from Weblate ([#391](https://github.com/sct/overseerr/issues/391)) ([5f71fb7](https://github.com/sct/overseerr/commit/5f71fb7ee280714275d2ac045c472fcdddd5a2ea))
|
||||
* **lang:** translations update from Weblate ([#400](https://github.com/sct/overseerr/issues/400)) ([1bd0e64](https://github.com/sct/overseerr/commit/1bd0e646e313ddf77ef331e818e03401fbf64a72))
|
||||
* **lang:** translations update from Weblate ([#403](https://github.com/sct/overseerr/issues/403)) ([3778ad8](https://github.com/sct/overseerr/commit/3778ad829c0897de178212b3bde4c0d3b5089161))
|
||||
* **lang:** translations update from Weblate ([#410](https://github.com/sct/overseerr/issues/410)) ([941fe19](https://github.com/sct/overseerr/commit/941fe1990454439cf05b48ef92bd3493432f8ed8))
|
||||
* **lang:** translations update from Weblate ([#479](https://github.com/sct/overseerr/issues/479)) ([c8c74b0](https://github.com/sct/overseerr/commit/c8c74b0ae54fcc524aa8b2edf5a5c5e5db6c1638))
|
||||
* **lang:** translations update from Weblate ([#495](https://github.com/sct/overseerr/issues/495)) ([b04eda6](https://github.com/sct/overseerr/commit/b04eda6c8a3bfcaa2a14b8a29612fdf690c9fba0))
|
||||
* **lang:** Translations update from Weblate ([#580](https://github.com/sct/overseerr/issues/580)) ([2bfe0f2](https://github.com/sct/overseerr/commit/2bfe0f2bf66956763ab26d5c54f26e6c456f59f7))
|
||||
* **lang:** Translations update from Weblate ([#604](https://github.com/sct/overseerr/issues/604)) ([801e765](https://github.com/sct/overseerr/commit/801e76524d6ea0887249f1630402e9c3a3430b44))
|
||||
* **login:** add local users functionality ([#591](https://github.com/sct/overseerr/issues/591)) ([492e19d](https://github.com/sct/overseerr/commit/492e19df4014e67dc6a2de5903a33c25e13fcf45))
|
||||
* **logs:** rotate log files if they reach 20MB in size ([22002ab](https://github.com/sct/overseerr/commit/22002ab4c76aace2bb202ac58da605b7a6f75d6d)), closes [#438](https://github.com/sct/overseerr/issues/438)
|
||||
* **notif:** add "Media Automatically Approved" notification type ([#1137](https://github.com/sct/overseerr/issues/1137)) ([f7d2723](https://github.com/sct/overseerr/commit/f7d2723fab2c30564fd23945709cd39b178a6eef))
|
||||
* **notif:** add settings for Discord bot username & avatar URL ([#1113](https://github.com/sct/overseerr/issues/1113)) ([3384eb1](https://github.com/sct/overseerr/commit/3384eb1c479114c0246cb22f9a933aa79fb95fcf))
|
||||
* **notif:** include poster image in Telegram notifications ([#1112](https://github.com/sct/overseerr/issues/1112)) ([48387e5](https://github.com/sct/overseerr/commit/48387e5b2f26c0c33acd436c6e1cf902d6c32101))
|
||||
* **notifications:** add notification for declined requests ([2f97f61](https://github.com/sct/overseerr/commit/2f97f61a6e8846975774aa16950a39ada2b1a016)), closes [#663](https://github.com/sct/overseerr/issues/663)
|
||||
* **notifications:** add pushover integration ([#574](https://github.com/sct/overseerr/issues/574)) ([ee5d018](https://github.com/sct/overseerr/commit/ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55))
|
||||
* **notifications:** add slack notification agent ([1163e81](https://github.com/sct/overseerr/commit/1163e81adc7da1e8334155ebee5b4672a22143db)), closes [#365](https://github.com/sct/overseerr/issues/365)
|
||||
* **notifications:** added ability to send test notifications ([44a3054](https://github.com/sct/overseerr/commit/44a305426f3e9829c167a4a73095d0d248641f47)), closes [#309](https://github.com/sct/overseerr/issues/309)
|
||||
* **notifications:** control notifcation types per agent ([8af6a1f](https://github.com/sct/overseerr/commit/8af6a1f566769c583af7dd9e18d162717835b7cc)), closes [#513](https://github.com/sct/overseerr/issues/513)
|
||||
* **notifications:** include direct links to media in notifications ([659fa50](https://github.com/sct/overseerr/commit/659fa505f0db32262ad0041cddb4daea893e6d65)), closes [#437](https://github.com/sct/overseerr/issues/437)
|
||||
* **notifications:** Webhook Notifications ([#632](https://github.com/sct/overseerr/issues/632)) ([a7cc7c5](https://github.com/sct/overseerr/commit/a7cc7c59753dd9649b2ec37eb9d46fe4fa8e1e1c))
|
||||
* **requests:** Request Overrides & Request Editing ([#653](https://github.com/sct/overseerr/issues/653)) ([bdb3372](https://github.com/sct/overseerr/commit/bdb33722e6df09dd6d8caa36b104b61c6b8dc00d))
|
||||
* **scan:** add support for new plex tv agent ([#1144](https://github.com/sct/overseerr/issues/1144)) ([a51d2a2](https://github.com/sct/overseerr/commit/a51d2a24d51d092a0c6da608e3322f19a37c2d28))
|
||||
* **server:** add CONFIG_DIRECTORY env var to control config directory location ([fa8f112](https://github.com/sct/overseerr/commit/fa8f112c31ccb5ee6244f776bc97e76d81958539))
|
||||
* **ui:** add user ID to profile header ([6e95c8b](https://github.com/sct/overseerr/commit/6e95c8b7a10e3467bfd2c3df84ccf886fe01ca5c))
|
||||
* 4K Requests ([#559](https://github.com/sct/overseerr/issues/559)) ([6b2df24](https://github.com/sct/overseerr/commit/6b2df24a2e8f96dd2277a814d7e02015d1f80cdc))
|
||||
* add collections ([#484](https://github.com/sct/overseerr/issues/484)) ([a333a09](https://github.com/sct/overseerr/commit/a333a095820ce3f10857026ba4770a2fffeed7cb)), closes [#418](https://github.com/sct/overseerr/issues/418)
|
||||
* add genre/studio/network view to Discover results ([#1067](https://github.com/sct/overseerr/issues/1067)) ([f28112f](https://github.com/sct/overseerr/commit/f28112f057df2589f31ae0d0b14e8b50e479fdb7))
|
||||
* add language-filtered Discover pages ([#1111](https://github.com/sct/overseerr/issues/1111)) ([7501161](https://github.com/sct/overseerr/commit/75011610e57f03098c8be9375d0c9ba1e3647e9b))
|
||||
* add missing tzdata package to image ([53bede6](https://github.com/sct/overseerr/commit/53bede692d4f0e940dededa63015fe1908129914)), closes [#394](https://github.com/sct/overseerr/issues/394)
|
||||
* add separate auto approve permissions for Movies/Series ([4809257](https://github.com/sct/overseerr/commit/480925781691de456abc427fbbba161be11a3a8a)), closes [#268](https://github.com/sct/overseerr/issues/268)
|
||||
* add studio/network sliders to discover ([1c6914f](https://github.com/sct/overseerr/commit/1c6914f5ce5c0d171c4609813915b50233a8e3ad))
|
||||
* **docker:** add tini to docker image ([#1017](https://github.com/sct/overseerr/issues/1017)) ([1629d02](https://github.com/sct/overseerr/commit/1629d02f3d8368bfd5f6fed05382974ae6fce51f))
|
||||
* **docker:** Check for /app/config volume mount during setup ([#826](https://github.com/sct/overseerr/issues/826)) ([1e5f88f](https://github.com/sct/overseerr/commit/1e5f88f462b0c69db5f6ab8e0249a5905bc6952a))
|
||||
* **frontend:** add TheTVDB external link ([#800](https://github.com/sct/overseerr/issues/800)) ([72cffd7](https://github.com/sct/overseerr/commit/72cffd74a75984ba98c456c0ec006ec378a8dcec))
|
||||
* **lang:** add support for Hungarian language ([cfacb15](https://github.com/sct/overseerr/commit/cfacb151b52d08e19d2fcd603fb4bbcd78707cdf))
|
||||
* **lang:** add support for Portuguese (Portugal) language ([e044146](https://github.com/sct/overseerr/commit/e044146aa55109a1eccfde9650b26beb0d5ec9a6))
|
||||
* **lang:** localize job names ([#1043](https://github.com/sct/overseerr/issues/1043)) ([594aad9](https://github.com/sct/overseerr/commit/594aad9d3ae9b323677f3af8c434d7664526593d))
|
||||
* **lang:** translated using Weblate (Dutch) ([6d0f7d4](https://github.com/sct/overseerr/commit/6d0f7d4b50370c420c1017f32d48313074543743))
|
||||
* **lang:** translated using Weblate (Dutch) ([059995e](https://github.com/sct/overseerr/commit/059995e0ef3370a3192bd386fa6875ca0f58690a))
|
||||
* **lang:** translated using Weblate (English) ([9bb11af](https://github.com/sct/overseerr/commit/9bb11afc6b4a109ae1e14d41c9fe2b71f19c470a))
|
||||
* **lang:** translated using Weblate (French) ([#1007](https://github.com/sct/overseerr/issues/1007)) ([970da66](https://github.com/sct/overseerr/commit/970da664b2700b8cd9ad8dce0cbca1d37820eceb))
|
||||
* **lang:** translated using Weblate (Portuguese (Brazil)) ([32f4916](https://github.com/sct/overseerr/commit/32f4916c4a926097f31ed472aee031536b847bb7))
|
||||
* **lang:** translations update from Weblate ([#727](https://github.com/sct/overseerr/issues/727)) ([71875ef](https://github.com/sct/overseerr/commit/71875efb48246dbb0139ad15a4261a5661fcfe17))
|
||||
* **lang:** translations update from Weblate ([#791](https://github.com/sct/overseerr/issues/791)) ([42295e0](https://github.com/sct/overseerr/commit/42295e076a7579b226d57407a20cb0ba044e9ec1))
|
||||
* **lang:** translations update from Weblate ([#819](https://github.com/sct/overseerr/issues/819)) ([9e5e4c2](https://github.com/sct/overseerr/commit/9e5e4c22f5b25df96f47875d599ed8685791382a))
|
||||
* **lang:** translations update from Weblate ([#841](https://github.com/sct/overseerr/issues/841)) ([e4f9b8a](https://github.com/sct/overseerr/commit/e4f9b8a9848f3af00e86fc7108c823ed0584609f))
|
||||
* **lang:** translations update from Weblate ([#852](https://github.com/sct/overseerr/issues/852)) ([c5be00e](https://github.com/sct/overseerr/commit/c5be00eebfd2b0e65295edbe282cbba22fffa660))
|
||||
* **lang:** translations update from Weblate ([#853](https://github.com/sct/overseerr/issues/853)) ([e156acc](https://github.com/sct/overseerr/commit/e156acc1ae2fa86b4441faacc0b58e1e993e0edc))
|
||||
* **lang:** translations update from Weblate ([#986](https://github.com/sct/overseerr/issues/986)) ([4296765](https://github.com/sct/overseerr/commit/4296765ad61bac09c2317b71b763366d328733e4))
|
||||
* **lang:** update languages and fix merge conflict ([083a74a](https://github.com/sct/overseerr/commit/083a74a686d202cce5775bf9752caaa9a626cf45))
|
||||
* **login:** add request ip to the failed request log ([#714](https://github.com/sct/overseerr/issues/714)) ([2d31ea9](https://github.com/sct/overseerr/commit/2d31ea940ac0a1a84d2150743798b41ff6490317))
|
||||
* **media:** add link to the item on plex ([#735](https://github.com/sct/overseerr/issues/735)) ([1d7150c](https://github.com/sct/overseerr/commit/1d7150c24ec5ad347093889bfceab61b664900d5))
|
||||
* **notif:** Add Pushbullet notification agent ([#950](https://github.com/sct/overseerr/issues/950)) ([29b97ef](https://github.com/sct/overseerr/commit/29b97ef6d85bbea31dd59b7ad857b0d8ab30bff0))
|
||||
* **notif:** Notification improvements ([#914](https://github.com/sct/overseerr/issues/914)) ([2768155](https://github.com/sct/overseerr/commit/2768155bbabe121a4c51fc1472461cd5114c4300))
|
||||
* **notifications:** add option to send notifications for auto-approved requests ([21db367](https://github.com/sct/overseerr/commit/21db3676d1464b63384b04c0c2926cb2a6252e9b)), closes [#267](https://github.com/sct/overseerr/issues/267)
|
||||
* **regions:** add region/original language setting for filtering Discover ([#732](https://github.com/sct/overseerr/issues/732)) ([#942](https://github.com/sct/overseerr/issues/942)) ([b557c06](https://github.com/sct/overseerr/commit/b557c06b0a78f5df5f64a05dc1e4511dae72df4f))
|
||||
* **requests:** add language profile support ([#860](https://github.com/sct/overseerr/issues/860)) ([53f6f59](https://github.com/sct/overseerr/commit/53f6f59798fa7e3f95959990a3df555db3c1c51e))
|
||||
* **telegram:** add support for individual chat notifications ([#1027](https://github.com/sct/overseerr/issues/1027)) ([f6d00d8](https://github.com/sct/overseerr/commit/f6d00d8d1559879189f83739193c6e2acafde51d))
|
||||
* **tv:** show cast for the entire show instead of only the last season ([#778](https://github.com/sct/overseerr/issues/778)) ([b239598](https://github.com/sct/overseerr/commit/b239598e64d33b78dc5d7972878840149aff360a)), closes [#775](https://github.com/sct/overseerr/issues/775)
|
||||
* **ui:** Add 'Available' filter to request list and remove unused MediaRequestStatus.AVAILABLE enum value ([#905](https://github.com/sct/overseerr/issues/905)) ([9757e3a](https://github.com/sct/overseerr/commit/9757e3ae0c572fb46177e25154b29e0ceced665f))
|
||||
* **ui:** Add 'Page Size' setting for request/user list pages ([#957](https://github.com/sct/overseerr/issues/957)) ([621db89](https://github.com/sct/overseerr/commit/621db893281f0280fe773ac7dbdc44434895242c))
|
||||
* **ui:** Add custom title functionality ([#825](https://github.com/sct/overseerr/issues/825)) ([35c6bfc](https://github.com/sct/overseerr/commit/35c6bfc0216bf879353b3ee546b439a06c8e6121))
|
||||
* **ui:** Add local login setting ([#817](https://github.com/sct/overseerr/issues/817)) ([9d0d5b8](https://github.com/sct/overseerr/commit/9d0d5b86aae025e4647bb664c6412d42192e2fe7))
|
||||
* **ui:** Add separate permissions for 4K auto approval ([#908](https://github.com/sct/overseerr/issues/908)) ([53b7425](https://github.com/sct/overseerr/commit/53b7425f6711e250935e7bb024c38ff6c62e07d9))
|
||||
* **ui:** Add sort options to user list ([#913](https://github.com/sct/overseerr/issues/913)) ([ef5d019](https://github.com/sct/overseerr/commit/ef5d019c18d7f6cdbbb1e1b7f8ff7816ed9b117b))
|
||||
* **ui:** Add support for requesting collections in 4K ([#968](https://github.com/sct/overseerr/issues/968)) ([139341b](https://github.com/sct/overseerr/commit/139341b0434b41e7c31af36baacd8d65566a6a0c))
|
||||
* **ui:** added content ratings for tv shows and movie ratings ([#878](https://github.com/sct/overseerr/issues/878)) ([c8b2a57](https://github.com/sct/overseerr/commit/c8b2a57721a51adcc7f90ec1acb48b127991d467))
|
||||
* **ui:** added next airing date to TV Shows ([#842](https://github.com/sct/overseerr/issues/842)) ([4eae02a](https://github.com/sct/overseerr/commit/4eae02a7e14e377fd69ddd4a43774cb7e3d1855b))
|
||||
* **ui:** display "Owner" role instead of "Admin" for user ID 1 ([#1050](https://github.com/sct/overseerr/issues/1050)) ([1b55d2d](https://github.com/sct/overseerr/commit/1b55d2dfbc06d900e7370a4ddfd81789a25bf00c))
|
||||
* **ui:** display season count on TV details page ([#1078](https://github.com/sct/overseerr/issues/1078)) ([4365231](https://github.com/sct/overseerr/commit/436523139e8f1594c352b17032734b4498d3994f))
|
||||
* **ui:** in Settings > Services, make Radarr/Sonarr server names and logos clickable links ([#1008](https://github.com/sct/overseerr/issues/1008)) ([6a1e389](https://github.com/sct/overseerr/commit/6a1e3891aa5f84b6adb1e475a6658a8cd4e34c22))
|
||||
* **ui:** request list redesign ([#1099](https://github.com/sct/overseerr/issues/1099)) ([cd21865](https://github.com/sct/overseerr/commit/cd21865c4d5be00c13c372e0b7a058f61ec855a2))
|
||||
* ability to edit user settings in bulk ([#597](https://github.com/sct/overseerr/issues/597)) ([4b0241c](https://github.com/sct/overseerr/commit/4b0241c3b34d4229f928c21defb10a1c051264d1))
|
||||
* add manual availability buttons to manage slideover ([67f8aef](https://github.com/sct/overseerr/commit/67f8aef00d98c834b60cb6152ccd5cb7b5709d12)), closes [#672](https://github.com/sct/overseerr/issues/672)
|
||||
* anime profile support ([#384](https://github.com/sct/overseerr/issues/384)) ([0972f40](https://github.com/sct/overseerr/commit/0972f40a4e1fb3b5f02b07ae46b997d71aab9bfb)), closes [#266](https://github.com/sct/overseerr/issues/266)
|
||||
* api key regeneration ([6beac73](https://github.com/sct/overseerr/commit/6beac736efcf7b9102e02e43b75d91a9a158cd22))
|
||||
* default user permissions added to settings ([e7ee85c](https://github.com/sct/overseerr/commit/e7ee85c29b5d25c6bff58717eae5e62de4dcef0c)), closes [#388](https://github.com/sct/overseerr/issues/388)
|
||||
* import users from plex ([#428](https://github.com/sct/overseerr/issues/428)) ([7e8f361](https://github.com/sct/overseerr/commit/7e8f361af711001cfc4dcc06a384b76f9846f90f)), closes [#281](https://github.com/sct/overseerr/issues/281)
|
||||
* map AniDB IDs from Hama agent to tvdb/tmdb/imdb IDs ([#538](https://github.com/sct/overseerr/issues/538)) ([0600ac7](https://github.com/sct/overseerr/commit/0600ac7c3a1bc0cdd906634d5f77ea3e99b10e94)), closes [#453](https://github.com/sct/overseerr/issues/453)
|
||||
* new permission to allow users to see other users requests ([033ba9d](https://github.com/sct/overseerr/commit/033ba9d41bddf6dc1c4512d8404f747e57923bca)), closes [#840](https://github.com/sct/overseerr/issues/840)
|
||||
* pre-populate server info from plex.tv API ([#563](https://github.com/sct/overseerr/issues/563)) ([82ac76b](https://github.com/sct/overseerr/commit/82ac76b0540ba1133cb5384744d2499c2488a4e8))
|
||||
* Radarr & Sonarr Sync ([#734](https://github.com/sct/overseerr/issues/734)) ([ec5fb83](https://github.com/sct/overseerr/commit/ec5fb836785855eb4846fd33b49faeb94c40506a))
|
||||
* request as another user ([59150f9](https://github.com/sct/overseerr/commit/59150f955f7003672ef19eb9d37156e93b79c97d))
|
||||
* simple failed request handling ([#474](https://github.com/sct/overseerr/issues/474)) ([02969d5](https://github.com/sct/overseerr/commit/02969d5426245062a2f53475d83c4a8639632c9d))
|
||||
* status checker to prompt users to reload their frontend when app version changes ([75a4264](https://github.com/sct/overseerr/commit/75a426437a4182e21da13684066966dd5bf8fc5e))
|
||||
* user profile/settings pages ([#958](https://github.com/sct/overseerr/issues/958)) ([bbb683e](https://github.com/sct/overseerr/commit/bbb683e637386ad8bbeb44dca97aac9cdaf11349))
|
||||
* **ui:** Move PROXY setting to UI ([#782](https://github.com/sct/overseerr/issues/782)) ([f1dd5e7](https://github.com/sct/overseerr/commit/f1dd5e7e12c1f602449c4769173dbce71e3569d0))
|
||||
* **users:** add editable usernames ([#715](https://github.com/sct/overseerr/issues/715)) ([20ca3f2](https://github.com/sct/overseerr/commit/20ca3f2f5fcf4a9eb0d6a8be671bb4fb1f5e6178))
|
||||
* YouTube Movie/TV Trailers ([#454](https://github.com/sct/overseerr/issues/454)) ([e88dc83](https://github.com/sct/overseerr/commit/e88dc83aeba0475e3ad421d5ab130cea4fc9a806))
|
||||
* **plex-sync:** add support for hama guid's ([ffe9e19](https://github.com/sct/overseerr/commit/ffe9e19c3b99de6af1185900e292da641ff44320)), closes [#453](https://github.com/sct/overseerr/issues/453)
|
||||
* **users:** add reset password flow ([#772](https://github.com/sct/overseerr/issues/772)) ([e5966bd](https://github.com/sct/overseerr/commit/e5966bd3fbfe172f264f4e986ad2aecf29ae1510))
|
||||
* allow to listen server on specific host interface ([#381](https://github.com/sct/overseerr/issues/381)) ([086183b](https://github.com/sct/overseerr/commit/086183b5636aa8d075d01fe59492c3eab0d1345b)), closes [#273](https://github.com/sct/overseerr/issues/273)
|
||||
* **api:** email notification agent ([0962392](https://github.com/sct/overseerr/commit/0962392e3930c7fdcb3164b9143cc8faca38bdfa))
|
||||
* **api:** plex tv sync and recently added sync ([1390cc1](https://github.com/sct/overseerr/commit/1390cc1f130bb3975996e84b12ac833f55f2f753))
|
||||
* **frontend:** add french language file ([cd6d8a8](https://github.com/sct/overseerr/commit/cd6d8a8216e7ae183b046d26cd22f3c1dc1d2b35))
|
||||
* **frontend:** add full cast page for movies and series ([051f1b3](https://github.com/sct/overseerr/commit/051f1b3e899bf749e632743e5c8d45a02b621998))
|
||||
* **frontend:** add header styling to movie/tv recommendation and similar list views ([f5f2545](https://github.com/sct/overseerr/commit/f5f2545520a43daa23e1276d24ff60d794ebbc6e))
|
||||
* **frontend:** add i18n strings for request list and request item ([6c4022f](https://github.com/sct/overseerr/commit/6c4022fb236583ad20d4c4c6693c1339e165b4af))
|
||||
* **frontend:** add links to detail pages from new request card ([6ad3384](https://github.com/sct/overseerr/commit/6ad3384a78f7bcb03f409cce8b35cc61d634d6b2))
|
||||
* **frontend:** add translatable strings for request card ([0d2f360](https://github.com/sct/overseerr/commit/0d2f360c22cd9bb50ae04f00a25e5fcc6c21bcdd))
|
||||
* **frontend:** added more localized strings ([659a601](https://github.com/sct/overseerr/commit/659a6018777718f7a90141307678d8dadcfd77f8))
|
||||
* **frontend:** added user deletion to the user list ([727fa06](https://github.com/sct/overseerr/commit/727fa06c18febb2a97ca219cc6bf0277ff462acd)), closes [#348](https://github.com/sct/overseerr/issues/348)
|
||||
* **frontend:** improved settings menu design for mobile ([16221a4](https://github.com/sct/overseerr/commit/16221a46a7d57c77f53aa0186263aa27267d9863))
|
||||
* **frontend:** initial version of the requests page (no filtering/sorting) ([1ba027b](https://github.com/sct/overseerr/commit/1ba027b4357e078c3f177d9d07208049f0c1ce65))
|
||||
* **frontend:** new design for request card ([93738e1](https://github.com/sct/overseerr/commit/93738e154c41fd11d5c6cf3d35573daf54ead471))
|
||||
* **frontend:** only load request/tmdb cards when in the browser view ([2d51efd](https://github.com/sct/overseerr/commit/2d51efd71612ec969b83c62d6aa0dac6df9391a3))
|
||||
* **frontend:** recently added on discover ([06dc606](https://github.com/sct/overseerr/commit/06dc606bcfeb50b7be1c35ac180c10738bade458))
|
||||
* **frontend:** show alert when there are no default radarr/sonarr servers ([0d088e0](https://github.com/sct/overseerr/commit/0d088e085e68d39455fda21d1fd08ebcaef2c06b)), closes [#344](https://github.com/sct/overseerr/issues/344)
|
||||
* **frontend:** update favicon ([886389a](https://github.com/sct/overseerr/commit/886389a361da54c616da3bdfeee9a85e9d12bcf3))
|
||||
* **lang:** add i18n strings for new about page ([900827b](https://github.com/sct/overseerr/commit/900827be97845688e4bea72a8c5d9611a3e9d069))
|
||||
* **lang:** add nb-NO and de language support to app ([d38b28d](https://github.com/sct/overseerr/commit/d38b28d2061b38366989ff412957a5dee5766c6f))
|
||||
* **lang:** add support for dutch language ([df94db0](https://github.com/sct/overseerr/commit/df94db050bf68a925118e0ce865d27178b702f9e))
|
||||
* **lang:** add support for russian languge ([8d8e750](https://github.com/sct/overseerr/commit/8d8e7509826514eebc859374d2e1ab212cc442d1))
|
||||
* **lang:** add support for Spanish language ([6cd2049](https://github.com/sct/overseerr/commit/6cd20491d2a0ceb995c4744eeb92a6e2f57a4893))
|
||||
* **lang:** added translation using Weblate (Russian) ([887f5dd](https://github.com/sct/overseerr/commit/887f5dd487b61676029652d99cbc5b40213aa22e))
|
||||
* **lang:** translated using Weblate (Dutch) ([1ab3a4b](https://github.com/sct/overseerr/commit/1ab3a4b80a081d7e4a201f1290cd270ed5b38ac7))
|
||||
* **lang:** translated using Weblate (English) ([0949c9b](https://github.com/sct/overseerr/commit/0949c9b334b3a4b6c342517a157a9e2b7596f2f0))
|
||||
* **lang:** translated using Weblate (French) ([f943701](https://github.com/sct/overseerr/commit/f943701e13c7f0de5a711302597858cc898b16e2))
|
||||
* **lang:** translated using Weblate (French) ([30a8934](https://github.com/sct/overseerr/commit/30a8934626fa2d47e95b5925d7e4227a0d0aa728))
|
||||
* **lang:** translated using Weblate (German) ([7bf9add](https://github.com/sct/overseerr/commit/7bf9addd13a707aac23b64ef3f1733e491d40a4e))
|
||||
* **lang:** translated using Weblate (German) ([b6e60a4](https://github.com/sct/overseerr/commit/b6e60a412b30907aea751a4cf1ce0cc8230f9814))
|
||||
* **lang:** translated using Weblate (Japanese) ([a494507](https://github.com/sct/overseerr/commit/a494507dfeafb0cfd2bd66fb01138522e0e80737))
|
||||
* **lang:** translated using Weblate (Russian) ([0d8e0d0](https://github.com/sct/overseerr/commit/0d8e0d0352f72fdb65ee8f054371eae08c39fe33))
|
||||
* **lang:** Translations update from Weblate ([#240](https://github.com/sct/overseerr/issues/240)) ([e17c637](https://github.com/sct/overseerr/commit/e17c63748362b6a480693e003ef5eec614dcec43))
|
||||
* **lang:** translations update from Weblate ([#336](https://github.com/sct/overseerr/issues/336)) ([ee84f74](https://github.com/sct/overseerr/commit/ee84f74f8a3558875b41daa539f42d00b949898a))
|
||||
* add version to startup logs ([2948f93](https://github.com/sct/overseerr/commit/2948f9360eb484d1d6c0740a840135ca97e7240a))
|
||||
* **frontend:** temporary logs page to clear up confusion about it 404ing ([d9788c4](https://github.com/sct/overseerr/commit/d9788c4aa9f87e2eda3f7e3f1adc985f16039552)), closes [#272](https://github.com/sct/overseerr/issues/272)
|
||||
* **lang:** translated using Weblate (French) ([30d04ce](https://github.com/sct/overseerr/commit/30d04ce35adc21070cce37ab10384154afda191b))
|
||||
* **lang:** translated using Weblate (German) ([44dbb74](https://github.com/sct/overseerr/commit/44dbb745b6216ce19fab4740520785c6414cf367))
|
||||
* **lang:** translated using Weblate (Japanese) ([08e968f](https://github.com/sct/overseerr/commit/08e968fd0097ec7b2a65de064ed5b07e7c49ef39))
|
||||
* **lang:** translated using Weblate (Norwegian Bokmål) ([83efb0e](https://github.com/sct/overseerr/commit/83efb0e3d4d96b6a2d2ebdd85d36c9d78c1717b2))
|
||||
* **lang:** Translations update from Weblate ([#291](https://github.com/sct/overseerr/issues/291)) ([fddbb3c](https://github.com/sct/overseerr/commit/fddbb3cdfe3d50b2835c248556139c769dc2b805))
|
||||
* about page initial version ([3f2a04c](https://github.com/sct/overseerr/commit/3f2a04c881bf06b73a952181fa463af84454b0dd))
|
||||
* **lang:** update language files ([8cd067b](https://github.com/sct/overseerr/commit/8cd067b6e9df1a3c8f4056789436a31177703986))
|
||||
* actually include email templates in built server files ([a28a8b3](https://github.com/sct/overseerr/commit/a28a8b37b0afc79583e4a7191a91f73ff6d3adad))
|
||||
* add application url config to main settings ui ([a359672](https://github.com/sct/overseerr/commit/a359672ebafffef742858814f0faa918e0341aa3))
|
||||
* add filtering for requests api ([cb9ae25](https://github.com/sct/overseerr/commit/cb9ae25d94f21e97113dfea3ca45c7002089e344))
|
||||
* add migration for delete cascades on season requests/seasons ([c688cf6](https://github.com/sct/overseerr/commit/c688cf60c710f0cf0b2da5ba6b0c18a2d137e7f9))
|
||||
* add trending to discover page ([ff8b9d8](https://github.com/sct/overseerr/commit/ff8b9d8e7ed228a153c2da4d237f7a4f99a79321))
|
||||
* force setup if app is not initialized ([a99705f](https://github.com/sct/overseerr/commit/a99705f6a5674b436ae28cbc558f4ee6e99ac910))
|
||||
* notification framework ([d8e542e](https://github.com/sct/overseerr/commit/d8e542e5fe2ed76dcb20fb6dfc5f59430cd4245d))
|
||||
* notifications for media_available and media_approved ([a6c5e65](https://github.com/sct/overseerr/commit/a6c5e65bbfc196545471e99fe2e5b7194f9dd387))
|
||||
* person details page ([d6eb3ae](https://github.com/sct/overseerr/commit/d6eb3ae64ef46bd62145010d3029e272676487c3))
|
||||
* **lang:** translated using Weblate (Russian) ([86cadb8](https://github.com/sct/overseerr/commit/86cadb8283fcab8745b4c09f8429fd9e46708813))
|
||||
* **lang:** translations update from Weblate ([#201](https://github.com/sct/overseerr/issues/201)) ([b0c663b](https://github.com/sct/overseerr/commit/b0c663baccd994e234b4d41d86486c3af4906344))
|
||||
* generate real api key ([a839370](https://github.com/sct/overseerr/commit/a8393707fec85a9262af5ba8c03d205190b2235b))
|
||||
* initial user list (no edit/delete yet) and job schedules ([24a0423](https://github.com/sct/overseerr/commit/24a0423f3b14303cfb0e83aef6e9e3bb273c5ba9))
|
||||
* manage series slideover added (and approve/decline/delete hooked up) ([236c4e5](https://github.com/sct/overseerr/commit/236c4e5e6126d2424a4badc08b7f7e6d1d70f401))
|
||||
* media delete option in manage media slideover ([250f484](https://github.com/sct/overseerr/commit/250f48492c95d74e40d95d3f026d2952157bc6e1))
|
||||
* other email notifications for approved/available ([0d73d88](https://github.com/sct/overseerr/commit/0d73d88f35b03e993f305873dc72672003c7d9e5))
|
||||
* rotten tomatoes scores on movie/tv details pages ([1694f60](https://github.com/sct/overseerr/commit/1694f60e8aa475ceeb7f170a783ec0ba70bd4bce))
|
||||
* season creation migration ([978f92a](https://github.com/sct/overseerr/commit/978f92a1c589ac404a3cb1103a68a8a5ffb0dd7d))
|
||||
* sonarr edit/delete modal ([3204326](https://github.com/sct/overseerr/commit/320432657e6ccf4d255238098e03590f28267bdb))
|
||||
* throw 404 when movie/tv show doesnt exist ([0601b44](https://github.com/sct/overseerr/commit/0601b446873e2eaf042044dd6a995b713586b0cc))
|
||||
* upcoming movies on discover ([67290dd](https://github.com/sct/overseerr/commit/67290dd502571a22dcf8559ac07f42e855275bd0))
|
||||
* upcoming/trending list views and larger title cards ([94eaaf9](https://github.com/sct/overseerr/commit/94eaaf96b4302a832c52ccb72009b3593452c779))
|
||||
* upgrade tailwindcss to 2.0.1 ([fb5c791](https://github.com/sct/overseerr/commit/fb5c791b0b6b7593a472bf01713999a001f92dc7))
|
||||
* **.editorconfig:** add .editorconfig ([b982066](https://github.com/sct/overseerr/commit/b982066327525156f8dd0d32818d3fe7cb28f9c8))
|
||||
* **frontend:** radarr delete modal ([877a518](https://github.com/sct/overseerr/commit/877a5184158fb4aa371fa2ea2107032543c9aa37))
|
||||
* radarr edit/create modal/backend functionality ([c4ac357](https://github.com/sct/overseerr/commit/c4ac357ef4cdd7a2c610260db46a4f0c325cd785))
|
||||
* **api:** add external ids to movie/tv response ([4aa7431](https://github.com/sct/overseerr/commit/4aa74319e0adcc19041239e57a00bc40fb127826))
|
||||
* **api:** add movie details endpoint ([b176148](https://github.com/sct/overseerr/commit/b1761484cb2861329763d51a868f37dd3098760d))
|
||||
* **api:** add tmdb discover api wrapper ([#67](https://github.com/sct/overseerr/issues/67)) ([839448f](https://github.com/sct/overseerr/commit/839448fcc8cc14ea83092af82e2ba3d0d92c9b73))
|
||||
* **api:** allow plex logins from users who have access to the server ([5147140](https://github.com/sct/overseerr/commit/514714071dfe4be04e607fe6412f5b3f0ef74dd4))
|
||||
* **api:** decouple media requests from media info ([8577db1](https://github.com/sct/overseerr/commit/8577db1be16f099d92c6649bbfb15f15e09a2f73))
|
||||
* **api:** discover endpoint for movie/tv ([#73](https://github.com/sct/overseerr/issues/73)) ([258bb93](https://github.com/sct/overseerr/commit/258bb93be2acc2ca32eaaefb617a5c326c5943ba))
|
||||
* **api:** initial implementation of the auth system ([#30](https://github.com/sct/overseerr/issues/30)) ([5343f35](https://github.com/sct/overseerr/commit/5343f35e5b572fe366a8712b24bd735de30e6170))
|
||||
* **api:** plex Sync (Movies) ([1be8b18](https://github.com/sct/overseerr/commit/1be8b183617c3a44ab8d4454a64b43dfe1d877fe))
|
||||
* **api:** public settings route ([#57](https://github.com/sct/overseerr/issues/57)) ([c0166e7](https://github.com/sct/overseerr/commit/c0166e7ecb5df110a4167f33338ed6406bf47f41))
|
||||
* **api:** radarr api wrapper / send to radarr when requests approved ([#93](https://github.com/sct/overseerr/issues/93)) ([48d62c3](https://github.com/sct/overseerr/commit/48d62c3178488d0d51831155ddd35cc31867db2b))
|
||||
* **api:** request api ([#80](https://github.com/sct/overseerr/issues/80)) ([f4c2c47](https://github.com/sct/overseerr/commit/f4c2c47e569e7faea7f99664966cb98b321ce952))
|
||||
* **api:** sonarr api wrapper / send to sonarr ([9385592](https://github.com/sct/overseerr/commit/9385592362eeba1dba05c5aa8fc7a2de1d054d74))
|
||||
* **api:** tmdb api wrapper / multi search route ([#62](https://github.com/sct/overseerr/issues/62)) ([c702c17](https://github.com/sct/overseerr/commit/c702c17cee00a52b23f685206e2d5d0c2eddf5a2))
|
||||
* **api:** tmdb trending api wrapper ([#68](https://github.com/sct/overseerr/issues/68)) ([ba34e54](https://github.com/sct/overseerr/commit/ba34e54d77d142d211df58d6ce9f53b6e673e004))
|
||||
* **api:** tv details endpoint ([a3beeed](https://github.com/sct/overseerr/commit/a3beeede7e72e99c7595673a27e38611ca4bb0cd))
|
||||
* **api:** validate plex when settings are saved ([8f6247d](https://github.com/sct/overseerr/commit/8f6247d82160704a3cfb76262696957b27641e87))
|
||||
* **api-user:** add basic User Entity and basic routing to fetch all users ([d902ef7](https://github.com/sct/overseerr/commit/d902ef72770712f2f71f33c09bca9ba99a30fc64))
|
||||
* **components/plexloginbutton:** added PlexLoginButton ([0abf743](https://github.com/sct/overseerr/commit/0abf743b17c664b58da18bdbf176f4a55ddc4179))
|
||||
* **extensions.json:** added recommended extensions for VSCode ([5dc9b51](https://github.com/sct/overseerr/commit/5dc9b510b8049516ad889c9d76a2f84daa0d2718))
|
||||
* **frontend:** add cancel request modal for titlecards ([f22f8c5](https://github.com/sct/overseerr/commit/f22f8c5d734be5cc0b1dcca869458a7321cd43a2))
|
||||
* **frontend:** allow permission check for showing nav items ([0b239f0](https://github.com/sct/overseerr/commit/0b239f0bdfb1394897bce5c50b0d112abfbb4ad7))
|
||||
* **frontend:** alpha notice ([33da7e9](https://github.com/sct/overseerr/commit/33da7e9df3a2546b0f208bd3b1d1f268e343cead))
|
||||
* **frontend:** approve/decline request well added to movie detail ([8f21358](https://github.com/sct/overseerr/commit/8f21358f797ed55923d90ba43acf1126856e9dfd))
|
||||
* **frontend:** basic discover page (only movies) ([#74](https://github.com/sct/overseerr/issues/74)) ([bbfe349](https://github.com/sct/overseerr/commit/bbfe349b52d308620796b37aaf986a0ed1ff0006))
|
||||
* **frontend:** buttonWithDropdown component added (no hookups yet) ([4975841](https://github.com/sct/overseerr/commit/4975841b5d4ba4ed1ba8cacaa5a063eeb3b8c311))
|
||||
* **frontend:** cancel movie request modal ([1f9cbbf](https://github.com/sct/overseerr/commit/1f9cbbfdf1ac98e54de5b8777c52c7bfc69c7e20))
|
||||
* **frontend:** design updates for responsive titlecards ([31809d9](https://github.com/sct/overseerr/commit/31809d952c8bafde3f63e2c1d952cc013149940e))
|
||||
* **frontend:** initial search functionality ([#78](https://github.com/sct/overseerr/issues/78)) ([342d1a3](https://github.com/sct/overseerr/commit/342d1a3c75b32b172a51ca7d82fdfde8510abedf))
|
||||
* **frontend:** initial Settings design ([8742da0](https://github.com/sct/overseerr/commit/8742da0ebb92d2f78309a998de0f67e788e14376))
|
||||
* **frontend:** loading spinner ([de84658](https://github.com/sct/overseerr/commit/de84658b48985e24b0f92a1690387f6d59d0bc16))
|
||||
* **frontend:** logo updates ([5a43ec5](https://github.com/sct/overseerr/commit/5a43ec5405855deb244e8085484a9d2b743caba6))
|
||||
* **frontend:** modal component and basic request hookup ([#91](https://github.com/sct/overseerr/issues/91)) ([626099a](https://github.com/sct/overseerr/commit/626099a2c98fb30d0cb53d8ccf79a6bf75a00059))
|
||||
* **frontend:** plex library scan ([1bc3f7b](https://github.com/sct/overseerr/commit/1bc3f7be4b07211563a1e254c28ce51e1bc337a2))
|
||||
* **frontend:** plex settings page ([47714b6](https://github.com/sct/overseerr/commit/47714b698cf4351c1ee38bdf0b672d9f0baed03a))
|
||||
* **frontend:** slideover initial work ([14b9cb6](https://github.com/sct/overseerr/commit/14b9cb610c0dcfef939ebec328f371e1cdfb689d))
|
||||
* logout route/sign out button ([#54](https://github.com/sct/overseerr/issues/54)) ([cb9098f](https://github.com/sct/overseerr/commit/cb9098f457f79b71734959fd924b6c72ca77d61d))
|
||||
* tv request modal status hookup ([5f8114f](https://github.com/sct/overseerr/commit/5f8114f730b067eb710704952824057e7b5b8fbf))
|
||||
* **frontend:** discover tv/movies full page ([be0003a](https://github.com/sct/overseerr/commit/be0003a85dc4e91799e85019aeb1110bd524a026))
|
||||
* **frontend:** new dashboard concept ([#82](https://github.com/sct/overseerr/issues/82)) ([eae38bb](https://github.com/sct/overseerr/commit/eae38bb9ec8588856f319387d2f262d7ee3f7e9c))
|
||||
* **frontend:** refresh indicator for titlecards / toasts ([4638fae](https://github.com/sct/overseerr/commit/4638fae336edc62a539796b3f55277a238683603))
|
||||
* **frontend:** request card / recent requests ([371e433](https://github.com/sct/overseerr/commit/371e43356d2c057e52368c32ffe2af1744311d91))
|
||||
* **frontend:** title detail (movie) initial version ([73ce24a](https://github.com/sct/overseerr/commit/73ce24a37bda3713e8cedc44e1ed065bdbc4ee4f))
|
||||
* **frontend/api:** beginning of new request modal ([2bf7e10](https://github.com/sct/overseerr/commit/2bf7e10e32718b36799be2feb0a7f9ff54d85744))
|
||||
* **frontend/api:** cast included with movie request and cast list on detail page ([04252f8](https://github.com/sct/overseerr/commit/04252f88bbdf51949923586feda582f86ac668ce))
|
||||
* **frontend/api:** i18n support ([9131254](https://github.com/sct/overseerr/commit/9131254f3371f12a17de44b6fa8f9bfb0e5c002e))
|
||||
* **frontend/api:** movie recommendations/similar request and frontend detail page update ([6398e36](https://github.com/sct/overseerr/commit/6398e3645a1e4ddbb9de9f4fda0a0659b4cac4d0))
|
||||
* **frontend/api:** tv details page ([02cbb5b](https://github.com/sct/overseerr/commit/02cbb5b030a3af5d62ab6c4cafdd4d800b4f61f4))
|
||||
* **frontend/api:** tv request modal (no status. only request) ([608b966](https://github.com/sct/overseerr/commit/608b96600a926adf16331b36e77789afa5d67069))
|
||||
* user avatars from plex ([#53](https://github.com/sct/overseerr/issues/53)) ([e6349c1](https://github.com/sct/overseerr/commit/e6349c13a0eb0489289aa7663fcc64fa7d2906e6))
|
||||
* **layout:** created Layout component ([1f497e8](https://github.com/sct/overseerr/commit/1f497e8913146ceb9748d667e638141b2ca4612a))
|
||||
* **login component/route:** add: Login Component and Route ([6e47be2](https://github.com/sct/overseerr/commit/6e47be2fa865bcd51582ce30ebee6fd820c5f9dd))
|
||||
* **login route conditional:** on login route, do not display layout ([7d179ae](https://github.com/sct/overseerr/commit/7d179ae3b42d8ffae5e1b6e266038793260f1bbe))
|
||||
* **pass pageprops to loginpage:** pass page props to loginPage ([1597188](https://github.com/sct/overseerr/commit/159718891fb363001c650ac8b7e1446a1520ce4a))
|
||||
* **plex/utils:** added Plex OAuth class ([72f9624](https://github.com/sct/overseerr/commit/72f9624f1db721fe0324b7be9f0f811d2ae02389))
|
||||
* bootstrap the basic app structure ([89a6017](https://github.com/sct/overseerr/commit/89a6017c7f6f7637fe249ac0d667a652f44e02bb))
|
||||
* user edit functionality (managing permissions) ([185ac26](https://github.com/sct/overseerr/commit/185ac2648fd21c4bf9692ac5ac055e9c740065ca))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* **deps:** revert back to next@10.0.3 until sharp optional dependency bug is fixed ([7962964](https://github.com/sct/overseerr/commit/79629645aacc1a042919834da79bff0c1f69c9d6))
|
||||
* **deps:** revert react-use-clipboard to 1.0.2 ([7083ddf](https://github.com/sct/overseerr/commit/7083ddf18121716e3442acab3506c395fdc351ac))
|
||||
@@ -1,80 +1,87 @@
|
||||
# Contributing to Overseerr
|
||||
|
||||
All help is welcome and greatly appreciated. If you would like to contribute to the project, the instructions below can get you started...
|
||||
All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started...
|
||||
|
||||
## Development
|
||||
|
||||
### Tools Required
|
||||
|
||||
- HTML/Typescript/Javascript editor of choice. ([VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.)
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 12.x.x or higher)
|
||||
- HTML/Typescript/Javascript editor
|
||||
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/overseerr.git
|
||||
cd overseerr/
|
||||
```
|
||||
|
||||
2. Add the remote upstream.
|
||||
2. Add the remote `upstream`:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/sct/overseerr.git
|
||||
```
|
||||
|
||||
3. Create a new branch
|
||||
3. Create a new branch:
|
||||
|
||||
```bash
|
||||
git checkout -b BRANCH_NAME develop
|
||||
```
|
||||
|
||||
- It is recommended to name the branch something relevant to the feature or fix you are working on.
|
||||
- An example of this would be `fix-title-cards` or `feature-new-system`.
|
||||
- Bad examples would be `patch` or `bug`.
|
||||
- It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on.
|
||||
- Good examples:
|
||||
- `docs-docker`
|
||||
- `feature-new-system`
|
||||
- `fix-title-cards`
|
||||
- Bad examples:
|
||||
- `bug`
|
||||
- `docs`
|
||||
- `feature`
|
||||
- `fix`
|
||||
- `patch`
|
||||
|
||||
4. Run development environment
|
||||
4. Run the development environment:
|
||||
|
||||
```bash
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
|
||||
5. Create your patch and run appropriate tests.
|
||||
5. Create your patch and test your changes.
|
||||
|
||||
6. Follow the [guidelines](#contributing-code).
|
||||
|
||||
7. Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
|
||||
- Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/develop
|
||||
git push origin BRANCH_NAME -f
|
||||
```
|
||||
|
||||
### Contributing Code
|
||||
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [GitHub Issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/sct/overseerr/issues) to avoid multiple people working on the same thing.
|
||||
- All commits **must** follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- It is okay if you squash your PR down to be a single commit that fits this standard.
|
||||
- PRs with commits not following this standard will not be merged.
|
||||
- It is okay to squash your pull request down into a single commit that fits this standard.
|
||||
- Pull requests with commits not following this standard will **not** be merged.
|
||||
- Please make meaningful commits, or squash them.
|
||||
- Always rebase your commit to the latest `develop` branch. Do not merge `develop` into your branch.
|
||||
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest `develop` branch.
|
||||
- Always rebase your commit to the latest `develop` branch. Do **not** merge `develop` into your branch.
|
||||
- It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch.
|
||||
- You can create a "draft" pull request early to get feedback on your work.
|
||||
- Your code must be formatted correctly or the tests will fail.
|
||||
- We use Prettier to format our codebase. It should automatically run with a `git` hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
|
||||
- If you have questions or need help, you can reach out in [GitHub Discussions](https://github.com/sct/overseerr/discussions) or in our [Discord](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`. Never `master`. Any PRs opened to `master` will be closed.
|
||||
- Your code **must** be formatted correctly, or the tests will fail.
|
||||
- We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save.
|
||||
- If you have questions or need help, you can reach out via [Discussions](https://github.com/sct/overseerr/discussions) or our [Discord server](https://discord.gg/PkCWJSeCk7).
|
||||
- Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed.
|
||||
|
||||
### UI Text Style
|
||||
|
||||
When adding new UI text, please be sure to adhere to the following guidelines:
|
||||
When adding new UI text, please try to adhere to the following guidelines:
|
||||
|
||||
1. Be concise and clear, and use as few words as possible to make your point.
|
||||
2. Use the Oxford comma where appropriate.
|
||||
@@ -90,7 +97,7 @@ When adding new UI text, please be sure to adhere to the following guidelines:
|
||||
|
||||
## Translation
|
||||
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose).
|
||||
We use [Weblate](https://hosted.weblate.org/engage/overseerr/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/sct/overseerr/issues/new/choose).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/overseerr/"><img src="https://hosted.weblate.org/widgets/overseerr/-/overseerr-frontend/multi-auto.svg" alt="Translation status" /></a>
|
||||
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,4 +1,7 @@
|
||||
FROM node:14.15-alpine AS BUILD_IMAGE
|
||||
FROM node:14.16-alpine AS BUILD_IMAGE
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
|
||||
ARG COMMIT_TAG
|
||||
ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
@@ -6,7 +9,13 @@ ENV COMMIT_TAG=${COMMIT_TAG}
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn --frozen-lockfile && \
|
||||
RUN \
|
||||
case "${TARGETPLATFORM}" in \
|
||||
'linux/arm64') apk add --no-cache python make g++ ;; \
|
||||
'linux/arm/v7') apk add --no-cache python make g++ ;; \
|
||||
esac
|
||||
|
||||
RUN yarn --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn build
|
||||
|
||||
# remove development dependencies
|
||||
@@ -20,14 +29,15 @@ RUN touch config/DOCKER
|
||||
RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
||||
|
||||
|
||||
FROM node:14.15-alpine
|
||||
FROM node:14.16-alpine
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
RUN apk add --no-cache tzdata tini
|
||||
|
||||
# copy from build image
|
||||
COPY --from=BUILD_IMAGE /app /app
|
||||
WORKDIR /app
|
||||
|
||||
CMD yarn start
|
||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
EXPOSE 5055
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:12.18-alpine
|
||||
FROM node:14.16-alpine
|
||||
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
14
README.md
14
README.md
@@ -12,7 +12,7 @@
|
||||
<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-33-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</p>
|
||||
|
||||
@@ -47,6 +47,7 @@ Currently, Overseerr is primarily distributed as Docker images. If you have Dock
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
@@ -57,7 +58,7 @@ docker run -d \
|
||||
|
||||
After running Overseerr for the first time, configure it by visiting the web UI at http://[address]:5055 and completing the setup steps
|
||||
|
||||
For more information or alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
|
||||
For more information and alternative installation methods, please see the [Overseerr documentation](https://docs.overseerr.dev/getting-started/installation).
|
||||
|
||||
⚠️ Overseerr is currently under very heavy, rapid development and things are likely to break often. We need all the help we can get to find bugs and get them fixed to hit a more stable release. If you would like to help test the bleeding edge, please use the `sctx/overseerr:develop` image instead! ⚠️
|
||||
|
||||
@@ -140,6 +141,15 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<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></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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Reverse Proxy Examples
|
||||
|
||||
{% hint style="warning" %}
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported.
|
||||
Base URLs cannot be configured in Overseerr. With this limitation, only subdomain configurations are supported. However, a Nginx subfolder workaround configuration is provided below to use at your own risk.
|
||||
{% endhint %}
|
||||
|
||||
## [SWAG (Secure Web Application Gateway, formerly known as `letsencrypt`)](https://github.com/linuxserver/docker-swag)
|
||||
## SWAG
|
||||
|
||||
A sample proxy configuration is included in SWAG. However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
|
||||
A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls).
|
||||
|
||||
To use the bundled configuration file, simply rename `overseerr.subdomain.conf.sample` in the `proxy-confs` folder to `overseerr.subdomain.conf`. Alternatively, create a new file `overseerr.subdomain.conf` in `proxy-confs` with the following configuration:
|
||||
|
||||
@@ -53,11 +53,14 @@ labels:
|
||||
|
||||
For more information, see the Traefik documentation for a [basic example](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
## `nginx`
|
||||
## Nginx
|
||||
|
||||
{% tabs %}
|
||||
{% tab title="Subdomain" %}
|
||||
|
||||
Add the following configuration to a new file `/etc/nginx/sites-available/overseerr.example.com.conf`:
|
||||
|
||||
```text
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name overseerr.example.com;
|
||||
@@ -111,6 +114,46 @@ Then, create a symlink to `/etc/nginx/sites-enabled`:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/overseerr.example.com.conf /etc/nginx/sites-enabled/overseerr.example.com.conf
|
||||
```
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Subfolder" %}
|
||||
|
||||
{% hint style="warning" %}
|
||||
Nginx subfolder reverse proxy is unsupported. The sub filters may stop working when Overseerr is updated. Use at your own risk!
|
||||
{% endhint %}
|
||||
|
||||
Add the following location block to your existing `nginx.conf` file.
|
||||
|
||||
```nginx
|
||||
location ^~ /overseerr {
|
||||
set $app 'overseerr';
|
||||
# Remove /overseerr path to pass to the app
|
||||
rewrite ^/overseerr/?(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH
|
||||
# Redirect location headers
|
||||
proxy_redirect ^ /$app;
|
||||
proxy_redirect /setup /$app/setup;
|
||||
proxy_redirect /login /$app/login;
|
||||
# Sub filters to replace hardcoded paths
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types *;
|
||||
sub_filter 'href="/"' 'href="/$app"';
|
||||
sub_filter 'href="/login"' 'href="/$app/login"';
|
||||
sub_filter 'href:"/"' 'href:"/$app"';
|
||||
sub_filter '/_next' '/$app/_next';
|
||||
sub_filter '/api/v1' '/$app/api/v1';
|
||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||
sub_filter '/images/' '/$app/images/';
|
||||
sub_filter '/android-' '/$app/android-';
|
||||
sub_filter '/apple-' '/$app/apple-';
|
||||
sub_filter '/favicon' '/$app/favicon';
|
||||
sub_filter '/logo.png' '/$app/logo.png';
|
||||
sub_filter '/site.webmanifest' '/$app/site.webmanifest';
|
||||
}
|
||||
```
|
||||
{% endtab %}
|
||||
{% endtabs %}
|
||||
|
||||
Next, test the configuration:
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ After running Overseerr for the first time, configure it by visiting the web UI
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
-p 5055:5055 \
|
||||
@@ -25,10 +26,35 @@ docker run -d \
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="Compose" %}
|
||||
|
||||
**docker-compose.yml:**
|
||||
|
||||
```yaml
|
||||
---
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
overseerr:
|
||||
image: sctx/overseerr:latest
|
||||
container_name: overseerr
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- TZ=Asia/Tokyo
|
||||
ports:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- /path/to/appdata/config:/app/config
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
{% endtab %}
|
||||
|
||||
{% tab title="UID/GID" %}
|
||||
|
||||
```text
|
||||
docker run -d \
|
||||
--name overseerr \
|
||||
--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ] \
|
||||
-e LOG_LEVEL=info \
|
||||
-e TZ=Asia/Tokyo \
|
||||
|
||||
@@ -35,6 +35,7 @@ These variables are usually the target user of the notification.
|
||||
- `{{notifyuser_email}}` Target user's email.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar.
|
||||
- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set).
|
||||
- `{{notifyuser_settings_telegramChatId}}` Target user's telegram Chat ID (if one is set).
|
||||
|
||||
### Media
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ components:
|
||||
default: true
|
||||
discordId:
|
||||
type: string
|
||||
telegramChatId:
|
||||
type: string
|
||||
telegramSendSilently:
|
||||
type: boolean
|
||||
required:
|
||||
- enableNotifications
|
||||
MainSettings:
|
||||
@@ -574,6 +578,19 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
Network:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 1
|
||||
logoPath:
|
||||
type: string
|
||||
nullable: true
|
||||
originCountry:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
RelatedVideo:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1085,6 +1102,10 @@ components:
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
botUsername:
|
||||
type: string
|
||||
botAvatarUrl:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
SlackSettings:
|
||||
@@ -1129,10 +1150,14 @@ components:
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
botUsername:
|
||||
type: string
|
||||
botAPI:
|
||||
type: string
|
||||
chatId:
|
||||
type: string
|
||||
sendSilently:
|
||||
type: boolean
|
||||
PushbulletSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1171,9 +1196,6 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
autoapprovalEnabled:
|
||||
type: boolean
|
||||
example: false
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1532,6 +1554,12 @@ components:
|
||||
discordId:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramChatId:
|
||||
type: string
|
||||
nullable: true
|
||||
telegramSendSilently:
|
||||
type: boolean
|
||||
nullable: true
|
||||
required:
|
||||
- enableNotifications
|
||||
securitySchemes:
|
||||
@@ -1693,13 +1721,13 @@ paths:
|
||||
$ref: '#/components/schemas/PlexLibrary'
|
||||
/settings/plex/sync:
|
||||
get:
|
||||
summary: Get status of full Plex library sync
|
||||
description: Returns sync progress in a JSON array.
|
||||
summary: Get status of full Plex library scan
|
||||
description: Returns scan progress in a JSON array.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Status of Plex sync
|
||||
description: Status of Plex scan
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1721,8 +1749,8 @@ paths:
|
||||
items:
|
||||
$ref: '#/components/schemas/PlexLibrary'
|
||||
post:
|
||||
summary: Start full Plex library sync
|
||||
description: Runs a full Plex library sync and returns the progress in a JSON array.
|
||||
summary: Start full Plex library scan
|
||||
description: Runs a full Plex library scan and returns the progress in a JSON array.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
@@ -1739,7 +1767,7 @@ paths:
|
||||
example: false
|
||||
responses:
|
||||
'200':
|
||||
description: Status of Plex sync
|
||||
description: Status of Plex scan
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -3223,6 +3251,16 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
example: 18
|
||||
- in: query
|
||||
name: studio
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -3244,6 +3282,147 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/discover/movies/genre/{genreId}:
|
||||
get:
|
||||
summary: Discover movies by genre
|
||||
description: Returns a list of movies based on the provided genre ID in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: genreId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
genre:
|
||||
$ref: '#/components/schemas/Genre'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/discover/movies/language/{language}:
|
||||
get:
|
||||
summary: Discover movies by original language
|
||||
description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: language
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
language:
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/discover/movies/studio/{studioId}:
|
||||
get:
|
||||
summary: Discover movies by studio
|
||||
description: Returns a list of movies based on the provided studio ID in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: studioId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
studio:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MovieResult'
|
||||
/discover/movies/upcoming:
|
||||
get:
|
||||
summary: Upcoming movies
|
||||
@@ -3301,6 +3480,16 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
- in: query
|
||||
name: genre
|
||||
schema:
|
||||
type: number
|
||||
example: 18
|
||||
- in: query
|
||||
name: network
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
@@ -3322,6 +3511,147 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TvResult'
|
||||
/discover/tv/language/{language}:
|
||||
get:
|
||||
summary: Discover TV shows by original language
|
||||
description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: language
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
language:
|
||||
$ref: '#/components/schemas/SpokenLanguage'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TvResult'
|
||||
/discover/tv/genre/{genreId}:
|
||||
get:
|
||||
summary: Discover TV shows by genre
|
||||
description: Returns a list of TV shows based on the provided genre ID in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: genreId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
genre:
|
||||
$ref: '#/components/schemas/Genre'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TvResult'
|
||||
/discover/tv/network/{networkId}:
|
||||
get:
|
||||
summary: Discover TV shows by network
|
||||
description: Returns a list of TV shows based on the provided network ID in a JSON object.
|
||||
tags:
|
||||
- search
|
||||
parameters:
|
||||
- in: path
|
||||
name: networkId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '1'
|
||||
- in: query
|
||||
name: page
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
default: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
type: string
|
||||
example: en
|
||||
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
|
||||
network:
|
||||
$ref: '#/components/schemas/Network'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TvResult'
|
||||
/discover/tv/upcoming:
|
||||
get:
|
||||
summary: Discover Upcoming TV shows
|
||||
@@ -4326,14 +4656,16 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
iso_3166_1:
|
||||
type: string
|
||||
example: US
|
||||
english_name:
|
||||
type: string
|
||||
example: United States of America
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_3166_1:
|
||||
type: string
|
||||
example: US
|
||||
english_name:
|
||||
type: string
|
||||
example: United States of America
|
||||
/languages:
|
||||
get:
|
||||
summary: Languages supported by TMDb
|
||||
@@ -4346,17 +4678,103 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
iso_639_1:
|
||||
type: string
|
||||
example: en
|
||||
english_name:
|
||||
type: string
|
||||
example: English
|
||||
name:
|
||||
type: string
|
||||
example: English
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
iso_639_1:
|
||||
type: string
|
||||
example: en
|
||||
english_name:
|
||||
type: string
|
||||
example: English
|
||||
name:
|
||||
type: string
|
||||
example: English
|
||||
/studio/{studioId}:
|
||||
get:
|
||||
summary: Get movie studio details
|
||||
description: Returns movie studio details in a JSON object.
|
||||
tags:
|
||||
- tmdb
|
||||
parameters:
|
||||
- in: path
|
||||
name: studioId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: Movie studio details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
/network/{networkId}:
|
||||
get:
|
||||
summary: Get TV network details
|
||||
description: Returns TV network details in a JSON object.
|
||||
tags:
|
||||
- tmdb
|
||||
parameters:
|
||||
- in: path
|
||||
name: networkId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: TV network details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProductionCompany'
|
||||
/genres/movie:
|
||||
get:
|
||||
summary: Get list of official TMDb movie genres
|
||||
description: Returns a list of genres in a JSON array.
|
||||
tags:
|
||||
- tmdb
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 10751
|
||||
name:
|
||||
type: string
|
||||
example: Family
|
||||
/genres/tv:
|
||||
get:
|
||||
summary: Get list of official TMDb movie genres
|
||||
description: Returns a list of genres in a JSON array.
|
||||
tags:
|
||||
- tmdb
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
example: 18
|
||||
name:
|
||||
type: string
|
||||
example: Drama
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
|
||||
77
package.json
77
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "overseerr",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0-arm-test.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node --files --project server/tsconfig.json server/index.ts",
|
||||
@@ -22,16 +22,16 @@
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"ace-builds": "^1.4.12",
|
||||
"axios": "^0.21.1",
|
||||
"bcrypt": "^5.0.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"connect-typeorm": "^1.1.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"country-code-emoji": "^2.2.0",
|
||||
"country-flag-icons": "^1.2.9",
|
||||
"csurf": "^1.11.0",
|
||||
"email-templates": "^8.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-openapi-validator": "^4.11.0",
|
||||
"express-openapi-validator": "^4.12.4",
|
||||
"express-session": "^1.17.1",
|
||||
"formik": "^2.2.6",
|
||||
"gravatar-url": "^3.1.0",
|
||||
@@ -40,16 +40,16 @@
|
||||
"next": "10.0.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-schedule": "^2.0.0",
|
||||
"nodemailer": "^6.4.18",
|
||||
"nodemailer": "^6.5.0",
|
||||
"nookies": "^2.5.2",
|
||||
"plex-api": "^5.3.1",
|
||||
"pug": "^3.0.0",
|
||||
"pug": "^3.0.2",
|
||||
"react": "17.0.1",
|
||||
"react-ace": "^9.3.0",
|
||||
"react-animate-height": "^2.0.23",
|
||||
"react-dom": "17.0.1",
|
||||
"react-intersection-observer": "^8.31.0",
|
||||
"react-intl": "^5.12.5",
|
||||
"react-intl": "^5.13.2",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
@@ -60,19 +60,19 @@
|
||||
"secure-random-password": "^0.2.2",
|
||||
"sqlite3": "^5.0.2",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"swr": "^0.4.2",
|
||||
"swr": "^0.5.1",
|
||||
"typeorm": "^0.2.31",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0",
|
||||
"winston-daily-rotate-file": "^4.5.1",
|
||||
"xml2js": "^0.4.23",
|
||||
"yamljs": "^0.3.0",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.12.17",
|
||||
"@commitlint/cli": "^11.0.0",
|
||||
"@commitlint/config-conventional": "^11.0.0",
|
||||
"@babel/cli": "^7.13.10",
|
||||
"@commitlint/cli": "^12.0.1",
|
||||
"@commitlint/config-conventional": "^12.0.1",
|
||||
"@semantic-release/changelog": "^5.0.1",
|
||||
"@semantic-release/commit-analyzer": "^8.0.1",
|
||||
"@semantic-release/exec": "^5.0.0",
|
||||
@@ -83,16 +83,17 @@
|
||||
"@types/bcrypt": "^3.0.0",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/country-flag-icons": "^1.2.0",
|
||||
"@types/csurf": "^1.11.0",
|
||||
"@types/email-templates": "^8.0.2",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-session": "^1.17.3",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.31",
|
||||
"@types/node": "^14.14.33",
|
||||
"@types/node-schedule": "^1.3.1",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-dom": "^17.0.1",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@types/react-toast-notifications": "^2.4.0",
|
||||
"@types/react-transition-group": "^4.4.1",
|
||||
"@types/secure-random-password": "^0.2.0",
|
||||
@@ -101,17 +102,17 @@
|
||||
"@types/xml2js": "^0.4.8",
|
||||
"@types/yamljs": "^0.2.31",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.15.1",
|
||||
"@typescript-eslint/parser": "^4.15.1",
|
||||
"autoprefixer": "^10.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||
"@typescript-eslint/parser": "^4.17.0",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"babel-plugin-react-intl": "^8.2.25",
|
||||
"babel-plugin-react-intl-auto": "^3.3.0",
|
||||
"commitizen": "^4.2.3",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^7.20.0",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-formatjs": "^2.12.4",
|
||||
"eslint-plugin-formatjs": "^2.12.7",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
@@ -120,14 +121,15 @@
|
||||
"husky": "4.3.8",
|
||||
"lint-staged": "^10.5.4",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^8.2.6",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.2.1",
|
||||
"semantic-release": "^17.3.9",
|
||||
"semantic-release": "^17.4.1",
|
||||
"semantic-release-docker": "^2.2.0",
|
||||
"semantic-release-docker-buildx": "^0.0.0-development",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.5"
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"sqlite3/node-gyp": "^5.1.0"
|
||||
@@ -185,13 +187,7 @@
|
||||
"message": "chore(release): ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "docker build --build-arg COMMIT_TAG=$GITHUB_SHA -t sctx/overseerr ."
|
||||
}
|
||||
],
|
||||
"semantic-release-docker",
|
||||
"semantic-release-docker-buildx",
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
@@ -200,13 +196,28 @@
|
||||
]
|
||||
],
|
||||
"branches": [
|
||||
"master"
|
||||
"master",
|
||||
{
|
||||
"name": "arm-test",
|
||||
"prerelease": true
|
||||
}
|
||||
],
|
||||
"npmPublish": false,
|
||||
"publish": [
|
||||
{
|
||||
"path": "semantic-release-docker",
|
||||
"name": "sctx/overseerr"
|
||||
"path": "semantic-release-docker-buildx",
|
||||
"buildArgs": {
|
||||
"COMMIT_TAG": "$GITHUB_SHA"
|
||||
},
|
||||
"imageNames": [
|
||||
"sctx/overseerr",
|
||||
"ghcr.io/sct/overseerr"
|
||||
],
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64",
|
||||
"linux/arm/v7"
|
||||
]
|
||||
},
|
||||
"@semantic-release/github"
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1024 1025" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="a" width="1024" height="1024"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="#24292E" fill-opacity="0" xlink:href="#a"/><g transform="translate(70 18)"><path d="m105.3 156.12l7.522 719.97c-60.173 7.579-105.3-22.736-105.3-83.364l-7.5216-598.71c0-189.46 173-234.94 278.3-159.15l534.03 310.72c75.216 53.05 90.259 151.57 52.651 219.78-7.522-53.05-30.086-83.365-75.216-113.68l-601.73-341.04c-45.13-30.315-82.738-22.736-82.738 45.471z" fill="#fff"/><path transform="translate(60.173 535.05)" d="m0 378.93c45.13 15.158 90.259 7.579 127.87-15.157l616.77-363.77c37.607 53.05 30.086 106.1-15.044 136.42l-518.99 303.14c-75.216 37.893-173 0-210.6-60.629z" fill="#fff"/><path transform="translate(240.69 284.95)" d="M0 416.822L368.558 204.622L7.52159 0L0 416.822Z" fill="#FFC230"/></g></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1023 B After Width: | Height: | Size: 1.2 KiB |
@@ -207,11 +207,6 @@ class SonarrAPI extends ExternalAPI {
|
||||
if (series.id) {
|
||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||
|
||||
series.addOptions = {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
};
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
'/series',
|
||||
series
|
||||
@@ -225,6 +220,9 @@ class SonarrAPI extends ExternalAPI {
|
||||
label: 'Sonarr',
|
||||
movie: newSeriesResponse.data,
|
||||
});
|
||||
if (options.searchNow) {
|
||||
this.searchSeries(newSeriesResponse.data.id);
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to update series in Sonarr', {
|
||||
label: 'Sonarr',
|
||||
@@ -350,6 +348,33 @@ class SonarrAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async searchSeries(seriesId: number): Promise<void> {
|
||||
logger.info('Executing series search command', {
|
||||
label: 'Sonarr API',
|
||||
seriesId,
|
||||
});
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
}
|
||||
|
||||
private async runCommand(
|
||||
commandName: string,
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.axios.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong attempting to run a Sonarr command.', {
|
||||
label: 'Sonarr API',
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
throw new Error('Failed to run Sonarr command.');
|
||||
}
|
||||
}
|
||||
|
||||
private buildSeasonList(
|
||||
seasons: number[],
|
||||
existingSeasons?: SonarrSeason[]
|
||||
|
||||
@@ -4,8 +4,11 @@ import ExternalAPI from '../externalapi';
|
||||
import {
|
||||
TmdbCollection,
|
||||
TmdbExternalIdResponse,
|
||||
TmdbGenre,
|
||||
TmdbGenresResult,
|
||||
TmdbLanguage,
|
||||
TmdbMovieDetails,
|
||||
TmdbNetwork,
|
||||
TmdbPersonCombinedCredits,
|
||||
TmdbPersonDetail,
|
||||
TmdbRegion,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbUpcomingMoviesResponse,
|
||||
TmdbProductionCompany,
|
||||
} from './interfaces';
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -30,6 +34,9 @@ interface DiscoverMovieOptions {
|
||||
language?: string;
|
||||
primaryReleaseDateGte?: string;
|
||||
primaryReleaseDateLte?: string;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
studio?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
@@ -53,6 +60,9 @@ interface DiscoverTvOptions {
|
||||
firstAirDateGte?: string;
|
||||
firstAirDateLte?: string;
|
||||
includeEmptyReleaseDate?: boolean;
|
||||
originalLanguage?: string;
|
||||
genre?: number;
|
||||
network?: number;
|
||||
sortBy?:
|
||||
| 'popularity.asc'
|
||||
| 'popularity.desc'
|
||||
@@ -120,7 +130,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch person details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,7 +152,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch person combined credits: ${e.message}`
|
||||
`[TMDb] Failed to fetch person combined credits: ${e.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -168,7 +178,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch movie details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +204,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv show details: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch TV show details: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,7 +256,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +282,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +308,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch movies by keyword: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch movies by keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +335,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to fetch tv recommendations: ${e.message}`
|
||||
`[TMDb] Failed to fetch TV recommendations: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +359,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch tv similar: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch TV similar: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +370,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
language = 'en',
|
||||
primaryReleaseDateGte,
|
||||
primaryReleaseDateLte,
|
||||
originalLanguage,
|
||||
genre,
|
||||
studio,
|
||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||
@@ -368,17 +381,18 @@ class TheMovieDb extends ExternalAPI {
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
with_release_type: '3|2',
|
||||
region: this.region,
|
||||
with_original_language: this.originalLanguage,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
'primary_release_date.gte': primaryReleaseDateGte,
|
||||
'primary_release_date.lte': primaryReleaseDateLte,
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch discover movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -389,6 +403,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
firstAirDateGte,
|
||||
firstAirDateLte,
|
||||
includeEmptyReleaseDate = false,
|
||||
originalLanguage,
|
||||
genre,
|
||||
network,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||
@@ -399,14 +416,16 @@ class TheMovieDb extends ExternalAPI {
|
||||
region: this.region,
|
||||
'first_air_date.gte': firstAirDateGte,
|
||||
'first_air_date.lte': firstAirDateLte,
|
||||
with_original_language: this.originalLanguage,
|
||||
with_original_language: originalLanguage ?? this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch discover tv: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch discover TV: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -432,7 +451,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch upcoming movies: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -459,7 +478,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -482,7 +501,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -505,7 +524,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch all trending: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -537,7 +556,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to find by external ID: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,11 +583,11 @@ class TheMovieDb extends ExternalAPI {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'[TMDB] Failed to find a title with the provided IMDB id'
|
||||
'[TMDb] Failed to find a title with the provided IMDB id'
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to get movie by external imdb ID: ${e.message}`
|
||||
`[TMDb] Failed to get movie by external imdb ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -596,11 +615,11 @@ class TheMovieDb extends ExternalAPI {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
|
||||
`[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
`[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -624,7 +643,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,7 +659,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return regions;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch countries: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +675,77 @@ class TheMovieDb extends ExternalAPI {
|
||||
|
||||
return languages;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`);
|
||||
throw new Error(`[TMDb] Failed to fetch langauges: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStudio(studioId: number): Promise<TmdbProductionCompany> {
|
||||
try {
|
||||
const data = await this.get<TmdbProductionCompany>(
|
||||
`/company/${studioId}`
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie studio: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getNetwork(networkId: number): Promise<TmdbNetwork> {
|
||||
try {
|
||||
const data = await this.get<TmdbNetwork>(`/network/${networkId}`);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV network: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getMovieGenres({
|
||||
language = 'en',
|
||||
}: {
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbGenre[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/movie/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const movieGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return movieGenres;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch movie genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvGenres({
|
||||
language = 'en',
|
||||
}: {
|
||||
language?: string;
|
||||
} = {}): Promise<TmdbGenre[]> {
|
||||
try {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/tv/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
const tvGenres = sortBy(data.genres, 'name');
|
||||
|
||||
return tvGenres;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDb] Failed to fetch TV genres: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,16 @@ export interface TmdbExternalIds {
|
||||
twitter_id?: string;
|
||||
}
|
||||
|
||||
export interface TmdbProductionCompany {
|
||||
id: number;
|
||||
logo_path?: string;
|
||||
name: string;
|
||||
origin_country: string;
|
||||
homepage?: string;
|
||||
headquarters?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TmdbMovieDetails {
|
||||
id: number;
|
||||
imdb_id?: string;
|
||||
@@ -125,12 +135,7 @@ export interface TmdbMovieDetails {
|
||||
original_title: string;
|
||||
overview?: string;
|
||||
popularity: number;
|
||||
production_companies: {
|
||||
id: number;
|
||||
name: string;
|
||||
logo_path?: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
production_companies: TmdbProductionCompany[];
|
||||
production_countries: {
|
||||
iso_3166_1: string;
|
||||
name: string;
|
||||
@@ -227,12 +232,7 @@ export interface TmdbTvDetails {
|
||||
last_episode_to_air?: TmdbTvEpisodeResult;
|
||||
name: string;
|
||||
next_episode_to_air?: TmdbTvEpisodeResult;
|
||||
networks: {
|
||||
id: number;
|
||||
name: string;
|
||||
logo_path: string;
|
||||
origin_country: string;
|
||||
}[];
|
||||
networks: TmdbNetwork[];
|
||||
number_of_episodes: number;
|
||||
number_of_seasons: number;
|
||||
origin_country: string[];
|
||||
@@ -381,3 +381,21 @@ export interface TmdbLanguage {
|
||||
english_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbGenresResult {
|
||||
genres: TmdbGenre[];
|
||||
}
|
||||
|
||||
export interface TmdbGenre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TmdbNetwork {
|
||||
id: number;
|
||||
name: string;
|
||||
headquarters?: string;
|
||||
homepage?: string;
|
||||
logo_path?: string;
|
||||
origin_country?: string;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export class MediaRequest {
|
||||
* auto approved content
|
||||
*/
|
||||
@AfterUpdate()
|
||||
public async notifyApprovedOrDeclined(): Promise<void> {
|
||||
public async notifyApprovedOrDeclined(autoApproved = false): Promise<void> {
|
||||
if (
|
||||
this.status === MediaRequestStatus.APPROVED ||
|
||||
this.status === MediaRequestStatus.DECLINED
|
||||
@@ -171,7 +171,9 @@ export class MediaRequest {
|
||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
? autoApproved
|
||||
? Notification.MEDIA_AUTO_APPROVED
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: movie.title,
|
||||
@@ -186,7 +188,9 @@ export class MediaRequest {
|
||||
const tv = await tmdb.getTvShow({ tvId: this.media.tmdbId });
|
||||
notificationManager.sendNotification(
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
? Notification.MEDIA_APPROVED
|
||||
? autoApproved
|
||||
? Notification.MEDIA_AUTO_APPROVED
|
||||
: Notification.MEDIA_APPROVED
|
||||
: Notification.MEDIA_DECLINED,
|
||||
{
|
||||
subject: tv.name,
|
||||
@@ -211,13 +215,8 @@ export class MediaRequest {
|
||||
|
||||
@AfterInsert()
|
||||
public async autoapprovalNotification(): Promise<void> {
|
||||
const settings = getSettings().notifications;
|
||||
|
||||
if (
|
||||
settings.autoapprovalEnabled &&
|
||||
this.status === MediaRequestStatus.APPROVED
|
||||
) {
|
||||
this.notifyApprovedOrDeclined();
|
||||
if (this.status === MediaRequestStatus.APPROVED) {
|
||||
this.notifyApprovedOrDeclined(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ export class UserSettings {
|
||||
@Column({ nullable: true })
|
||||
public discordId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramChatId?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public telegramSendSilently?: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public region?: string;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PublicSettingsResponse {
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
||||
@@ -6,5 +6,8 @@ export interface UserSettingsGeneralResponse {
|
||||
|
||||
export interface UserSettingsNotificationsResponse {
|
||||
enableNotifications: boolean;
|
||||
telegramBotUsername?: string;
|
||||
discordId?: string;
|
||||
telegramChatId?: string;
|
||||
telegramSendSilently?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,918 +0,0 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../api/plexapi';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
} from '../../api/themoviedb/interfaces';
|
||||
import Media from '../../entity/Media';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import animeList from '../../api/animelist';
|
||||
import AsyncLock from '../../utils/asyncLock';
|
||||
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
|
||||
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
|
||||
const HAMA_AGENT = 'com.plexapp.agents.hama';
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class JobPlexSync {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
private plexClient: PlexAPI;
|
||||
private items: PlexLibraryItem[] = [];
|
||||
private progress = 0;
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private running = false;
|
||||
private isRecentOnly = false;
|
||||
private enable4kMovie = false;
|
||||
private enable4kShow = false;
|
||||
private asyncLock = new AsyncLock();
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async processMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata.Guid) {
|
||||
logger.debug('No Guid metadata for this title. Skipping', {
|
||||
label: 'Plex Sync',
|
||||
ratingKey: plexitem.ratingKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
newMedia.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
newMedia.tmdbId = Number(tmdbMatch);
|
||||
}
|
||||
});
|
||||
if (newMedia.imdbId && !newMedia.tmdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: newMedia.imdbId,
|
||||
});
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
if (!newMedia.tmdbId) {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
const hasOtherResolution = metadata.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
);
|
||||
|
||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
||||
existing.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status4k = MediaStatus.AVAILABLE;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
|
||||
existing.ratingKey !== plexitem.ratingKey
|
||||
) {
|
||||
existing.ratingKey = plexitem.ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.ratingKey4k !== plexitem.ratingKey
|
||||
) {
|
||||
existing.ratingKey4k = plexitem.ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`Title already exists and no new media types found ${metadata.title}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newMedia.status =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
has4k && this.enable4kMovie
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
newMedia.ratingKey =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? plexitem.ratingKey
|
||||
: undefined;
|
||||
newMedia.ratingKey4k =
|
||||
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${plexitem.title}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let tmdbMovieId: number | undefined;
|
||||
let tmdbMovie: TmdbMovieDetails | undefined;
|
||||
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
const tmdbMatch = plexitem.guid.match(tmdbShowRegex);
|
||||
|
||||
if (imdbMatch) {
|
||||
tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: imdbMatch[1],
|
||||
});
|
||||
tmdbMovieId = tmdbMovie.id;
|
||||
} else if (tmdbMatch) {
|
||||
tmdbMovieId = Number(tmdbMatch[1]);
|
||||
}
|
||||
|
||||
if (!tmdbMovieId) {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processMovieWithId(
|
||||
plexitem: PlexLibraryItem,
|
||||
tmdbMovie: TmdbMovieDetails | undefined,
|
||||
tmdbMovieId: number
|
||||
) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbMovieId, async () => {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
const hasOtherResolution = metadata.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
||||
existing.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status4k = MediaStatus.AVAILABLE;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
|
||||
existing.ratingKey !== plexitem.ratingKey
|
||||
) {
|
||||
existing.ratingKey = plexitem.ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.ratingKey4k !== plexitem.ratingKey
|
||||
) {
|
||||
existing.ratingKey4k = plexitem.ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`Title already exists and no new media types found ${metadata.title}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If we have a tmdb movie guid but it didn't already exist, only then
|
||||
// do we request the movie from tmdb (to reduce api requests)
|
||||
if (!tmdbMovie) {
|
||||
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
|
||||
}
|
||||
const newMedia = new Media();
|
||||
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
newMedia.status =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
has4k && this.enable4kMovie
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.ratingKey =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? plexitem.ratingKey
|
||||
: undefined;
|
||||
newMedia.ratingKey4k =
|
||||
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tmdbMovie.title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// this adds all movie episodes from specials season for Hama agent
|
||||
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
|
||||
const specials = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === 0
|
||||
);
|
||||
if (specials) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
specials.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
for (const episode of episodes) {
|
||||
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
|
||||
if (special) {
|
||||
if (special.tmdbId) {
|
||||
await this.processMovieWithId(episode, undefined, special.tmdbId);
|
||||
} else if (special.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: special.imdbId,
|
||||
});
|
||||
await this.processMovieWithId(episode, tmdbMovie, tmdbMovie.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// movies with hama agent actually are tv shows with at least one episode in it
|
||||
// try to get first episode of any season - cannot hardcode season or episode number
|
||||
// because sometimes user can have it in other season/ep than s01e01
|
||||
private async processHamaMovie(
|
||||
metadata: PlexMetadata,
|
||||
tmdbMovie: TmdbMovieDetails | undefined,
|
||||
tmdbMovieId: number
|
||||
) {
|
||||
const season = metadata.Children?.Metadata[0];
|
||||
if (season) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
await this.processMovieWithId(episodes[0], tmdbMovie, tmdbMovieId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processShow(plexitem: PlexLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
try {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey;
|
||||
const metadata = await this.plexClient.getMetadata(ratingKey, {
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
if (metadata.guid.match(tvdbRegex)) {
|
||||
const matchedtvdb = metadata.guid.match(tvdbRegex);
|
||||
|
||||
// If we can find a tvdb Id, use it to get the full tmdb show details
|
||||
if (matchedtvdb?.[1]) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
}
|
||||
} else if (metadata.guid.match(tmdbShowRegex)) {
|
||||
const matchedtmdb = metadata.guid.match(tmdbShowRegex);
|
||||
|
||||
if (matchedtmdb?.[1]) {
|
||||
tvShow = await this.tmdb.getTvShow({ tvId: Number(matchedtmdb[1]) });
|
||||
}
|
||||
} else if (metadata.guid.match(hamaTvdbRegex)) {
|
||||
const matched = metadata.guid.match(hamaTvdbRegex);
|
||||
const tvdbId = matched?.[1];
|
||||
|
||||
if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId) });
|
||||
if (animeList.isLoaded()) {
|
||||
await this.processHamaSpecials(metadata, Number(tvdbId));
|
||||
} else {
|
||||
this.log(
|
||||
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn'
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (metadata.guid.match(hamaAnidbRegex)) {
|
||||
const matched = metadata.guid.match(hamaAnidbRegex);
|
||||
|
||||
if (!animeList.isLoaded()) {
|
||||
this.log(
|
||||
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn'
|
||||
);
|
||||
} else if (matched?.[1]) {
|
||||
const anidbId = Number(matched[1]);
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
|
||||
// first try to lookup tvshow by tvdbid
|
||||
if (result?.tvdbId) {
|
||||
const extResponse = await this.tmdb.getByExternalId({
|
||||
externalId: result.tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
if (extResponse.tv_results[0]) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
});
|
||||
} else {
|
||||
this.log(
|
||||
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
|
||||
);
|
||||
}
|
||||
await this.processHamaSpecials(metadata, result.tvdbId);
|
||||
}
|
||||
|
||||
if (!tvShow) {
|
||||
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
|
||||
// note - some tv shows have imdbid set too, that's why this need to go second
|
||||
if (result?.tmdbId) {
|
||||
return await this.processHamaMovie(
|
||||
metadata,
|
||||
undefined,
|
||||
result.tmdbId
|
||||
);
|
||||
} else if (result?.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: result.imdbId,
|
||||
});
|
||||
return await this.processHamaMovie(
|
||||
metadata,
|
||||
tmdbMovie,
|
||||
tmdbMovie.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow) {
|
||||
await this.asyncLock.dispatch(tvShow.id, async () => {
|
||||
if (!tvShow) {
|
||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
||||
// outer scope back to null before this async gets called
|
||||
return;
|
||||
}
|
||||
|
||||
// Lets get the available seasons from Plex
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentStandardSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
const current4kSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
for (const season of seasons) {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.season_number
|
||||
);
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedPlexSeason) {
|
||||
// If we have a matched Plex season, get its children metadata so we can check details
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
matchedPlexSeason.ratingKey
|
||||
);
|
||||
// Total episodes that are in standard definition (not 4k)
|
||||
const totalStandard = episodes.filter((episode) =>
|
||||
!this.enable4kShow
|
||||
? true
|
||||
: episode.Media.some(
|
||||
(media) => media.videoResolution !== '4k'
|
||||
)
|
||||
).length;
|
||||
|
||||
// Total episodes that are in 4k
|
||||
const total4k = episodes.filter((episode) =>
|
||||
episode.Media.some((media) => media.videoResolution === '4k')
|
||||
).length;
|
||||
|
||||
if (
|
||||
media &&
|
||||
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
|
||||
media.ratingKey !== ratingKey
|
||||
) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
total4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.ratingKey4k !== ratingKey
|
||||
) {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// These ternary statements look super confusing, but they are simply
|
||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||
// and then not modifying the status if there are 0 items.
|
||||
// If the season was already available, we don't modify it as well.
|
||||
existingSeason.status =
|
||||
totalStandard === season.episode_count ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
(this.enable4kShow && total4k === season.episode_count) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.season_number,
|
||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||
// if we dont have any items for the season
|
||||
status:
|
||||
totalStandard === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow && total4k === season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
|
||||
const isAllStandardSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
const isAll4kSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const new4kSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newStandardSeasonAvailable - currentStandardSeasonAvailable
|
||||
} new standard season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
}
|
||||
|
||||
if (new4kSeasonAvailable > current4kSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
new4kSeasonAvailable - current4kSeasonAvailable
|
||||
} new 4K season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (!media.mediaAddedAt) {
|
||||
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
).length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
).length === 0;
|
||||
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${tvShow.name}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.log(`failed show: ${plexitem.guid}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process Plex item. ratingKey: ${
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey
|
||||
}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
plexitem,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: PlexLibraryItem[]) {
|
||||
await Promise.all(
|
||||
slicedItems.map(async (plexitem) => {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processMovie(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processShow(plexitem);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Plex Sync', ...optional });
|
||||
}
|
||||
|
||||
// checks if any of this.libraries has Hama agent set in Plex
|
||||
private async hasHamaAgent() {
|
||||
const plexLibraries = await this.plexClient.getLibraries();
|
||||
return this.libraries.some((library) =>
|
||||
plexLibraries.some(
|
||||
(plexLibrary) =>
|
||||
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
logger.info('Plex Sync Starting', { sessionId, label: 'Plex Sync' });
|
||||
try {
|
||||
this.running = true;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Plex sync skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4kShow) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
const hasHama = await this.hasHamaAgent();
|
||||
if (hasHama) {
|
||||
await animeList.sync();
|
||||
}
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop({ sessionId });
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop({ sessionId });
|
||||
}
|
||||
}
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
: 'Full Scan Complete',
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Sync interrupted', {
|
||||
label: 'Plex Sync',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobPlexFullSync = new JobPlexSync();
|
||||
export const jobPlexRecentSync = new JobPlexSync({ isRecentOnly: true });
|
||||
@@ -1,248 +0,0 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import RadarrAPI, { RadarrMovie } from '../../api/radarr';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import { getSettings, RadarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const BUNDLE_SIZE = 50;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentServer: RadarrSettings;
|
||||
servers: RadarrSettings[];
|
||||
}
|
||||
|
||||
class JobRadarrSync {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private enable4k = false;
|
||||
private sessionId: string;
|
||||
private servers: RadarrSettings[];
|
||||
private currentServer: RadarrSettings;
|
||||
private radarrApi: RadarrAPI;
|
||||
private items: RadarrMovie[] = [];
|
||||
|
||||
public async run() {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
this.log('Radarr sync starting', 'info', { sessionId });
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
|
||||
// Remove any duplicate Radarr servers and assign them to the servers field
|
||||
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4k) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Radarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.radarrApi = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.radarrApi.getMovies();
|
||||
|
||||
await this.loop({ sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Radarr sync complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Something went wrong.', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processRadarrMovie(radarrMovie: RadarrMovie) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4k && this.currentServer.is4k;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: radarrMovie.tmdbId },
|
||||
});
|
||||
|
||||
if (media) {
|
||||
let isChanged = false;
|
||||
if (media.status === MediaStatus.AVAILABLE) {
|
||||
this.log(`Movie already available: ${radarrMovie.title}`);
|
||||
} else {
|
||||
media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.PROCESSING;
|
||||
this.log(
|
||||
`Updated existing ${server4k ? '4K ' : ''}movie ${
|
||||
radarrMovie.title
|
||||
} to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
|
||||
) {
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
|
||||
this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
radarrMovie.id
|
||||
) {
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
radarrMovie.id;
|
||||
this.log(
|
||||
`Updated external service ID for media entity: ${radarrMovie.title}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
radarrMovie.titleSlug
|
||||
) {
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
radarrMovie.titleSlug;
|
||||
this.log(
|
||||
`Updated external service slug for media entity: ${radarrMovie.title}`
|
||||
);
|
||||
isChanged = true;
|
||||
}
|
||||
|
||||
if (isChanged) {
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
tmdbId: radarrMovie.tmdbId,
|
||||
imdbId: radarrMovie.imdbId,
|
||||
mediaType: MediaType.MOVIE,
|
||||
serviceId: !server4k ? this.currentServer.id : undefined,
|
||||
serviceId4k: server4k ? this.currentServer.id : undefined,
|
||||
externalServiceId: !server4k ? radarrMovie.id : undefined,
|
||||
externalServiceId4k: server4k ? radarrMovie.id : undefined,
|
||||
status:
|
||||
!server4k && radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && radarrMovie.downloaded
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Added media for movie ${radarrMovie.title} and set status to ${
|
||||
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
|
||||
}`
|
||||
);
|
||||
await mediaRepository.save(newMedia);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(items: RadarrMovie[]) {
|
||||
await Promise.all(
|
||||
items.map(async (radarrMovie) => {
|
||||
await this.processRadarrMovie(radarrMovie);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Radarr Sync', ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export const jobRadarrSync = new JobRadarrSync();
|
||||
@@ -1,9 +1,9 @@
|
||||
import schedule from 'node-schedule';
|
||||
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
|
||||
import logger from '../logger';
|
||||
import { jobRadarrSync } from './radarrsync';
|
||||
import { jobSonarrSync } from './sonarrsync';
|
||||
import downloadTracker from '../lib/downloadtracker';
|
||||
import { plexFullScanner, plexRecentScanner } from '../lib/scanners/plex';
|
||||
import { radarrScanner } from '../lib/scanners/radarr';
|
||||
import { sonarrScanner } from '../lib/scanners/sonarr';
|
||||
|
||||
interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -17,58 +17,60 @@ interface ScheduledJob {
|
||||
export const scheduledJobs: ScheduledJob[] = [];
|
||||
|
||||
export const startJobs = (): void => {
|
||||
// Run recently added plex sync every 5 minutes
|
||||
// Run recently added plex scan every 5 minutes
|
||||
scheduledJobs.push({
|
||||
id: 'plex-recently-added-sync',
|
||||
name: 'Plex Recently Added Sync',
|
||||
id: 'plex-recently-added-scan',
|
||||
name: 'Plex Recently Added Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 */5 * * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Sync', {
|
||||
logger.info('Starting scheduled job: Plex Recently Added Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
jobPlexRecentSync.run();
|
||||
plexRecentScanner.run();
|
||||
}),
|
||||
running: () => jobPlexRecentSync.status().running,
|
||||
cancelFn: () => jobPlexRecentSync.cancel(),
|
||||
running: () => plexRecentScanner.status().running,
|
||||
cancelFn: () => plexRecentScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full plex sync every 24 hours
|
||||
// Run full plex scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'plex-full-sync',
|
||||
name: 'Plex Full Library Sync',
|
||||
id: 'plex-full-scan',
|
||||
name: 'Plex Full Library Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 3 * * *', () => {
|
||||
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
|
||||
jobPlexFullSync.run();
|
||||
logger.info('Starting scheduled job: Plex Full Library Scan', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
plexFullScanner.run();
|
||||
}),
|
||||
running: () => jobPlexFullSync.status().running,
|
||||
cancelFn: () => jobPlexFullSync.cancel(),
|
||||
running: () => plexFullScanner.status().running,
|
||||
cancelFn: () => plexFullScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full radarr sync every 24 hours
|
||||
// Run full radarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'radarr-sync',
|
||||
name: 'Radarr Sync',
|
||||
id: 'radarr-scan',
|
||||
name: 'Radarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 0 4 * * *', () => {
|
||||
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
|
||||
jobRadarrSync.run();
|
||||
logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' });
|
||||
radarrScanner.run();
|
||||
}),
|
||||
running: () => jobRadarrSync.status().running,
|
||||
cancelFn: () => jobRadarrSync.cancel(),
|
||||
running: () => radarrScanner.status().running,
|
||||
cancelFn: () => radarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run full sonarr sync every 24 hours
|
||||
// Run full sonarr scan every 24 hours
|
||||
scheduledJobs.push({
|
||||
id: 'sonarr-sync',
|
||||
name: 'Sonarr Sync',
|
||||
id: 'sonarr-scan',
|
||||
name: 'Sonarr Scan',
|
||||
type: 'process',
|
||||
job: schedule.scheduleJob('0 30 4 * * *', () => {
|
||||
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
|
||||
jobSonarrSync.run();
|
||||
logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' });
|
||||
sonarrScanner.run();
|
||||
}),
|
||||
running: () => jobSonarrSync.status().running,
|
||||
cancelFn: () => jobSonarrSync.cancel(),
|
||||
running: () => sonarrScanner.status().running,
|
||||
cancelFn: () => sonarrScanner.cancel(),
|
||||
});
|
||||
|
||||
// Run download sync
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import { TmdbTvDetails } from '../../api/themoviedb/interfaces';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import Season from '../../entity/Season';
|
||||
import { getSettings, SonarrSettings } from '../../lib/settings';
|
||||
import logger from '../../logger';
|
||||
|
||||
const BUNDLE_SIZE = 50;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
currentServer: SonarrSettings;
|
||||
servers: SonarrSettings[];
|
||||
}
|
||||
|
||||
class JobSonarrSync {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private enable4k = false;
|
||||
private sessionId: string;
|
||||
private servers: SonarrSettings[];
|
||||
private currentServer: SonarrSettings;
|
||||
private sonarrApi: SonarrAPI;
|
||||
private items: SonarrSeries[] = [];
|
||||
|
||||
public async run() {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
this.log('Sonarr sync starting', 'info', { sessionId });
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
|
||||
// Remove any duplicate Sonarr servers and assign them to the servers field
|
||||
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4k) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Sonarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.sonarrApi = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.sonarrApi.getSeries();
|
||||
|
||||
await this.loop({ sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Sonarr sync complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Something went wrong.', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4k && this.currentServer.is4k;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tvdbId: sonarrSeries.tvdbId },
|
||||
});
|
||||
|
||||
const currentSeasonsAvailable = (media?.seasons ?? []).filter(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length;
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
for (const season of sonarrSeries.seasons) {
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We are already tracking this season so we can work on it directly
|
||||
if (existingSeason) {
|
||||
if (
|
||||
existingSeason[server4k ? 'status4k' : 'status'] !==
|
||||
MediaStatus.AVAILABLE &&
|
||||
season.statistics
|
||||
) {
|
||||
existingSeason[server4k ? 'status4k' : 'status'] =
|
||||
season.statistics.episodeFileCount ===
|
||||
season.statistics.totalEpisodeCount
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason[server4k ? 'status4k' : 'status'];
|
||||
}
|
||||
} else {
|
||||
if (season.statistics && season.seasonNumber !== 0) {
|
||||
const allEpisodes =
|
||||
season.statistics.episodeFileCount ===
|
||||
season.statistics.totalEpisodeCount;
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.seasonNumber,
|
||||
status:
|
||||
!server4k && allEpisodes
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k && season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k && season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && allEpisodes
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k && season.statistics.episodeFileCount > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k && season.monitored
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
(s) => s.seasonNumber !== 0
|
||||
);
|
||||
|
||||
const isAllSeasons =
|
||||
(media?.seasons ?? []).filter(
|
||||
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
newSeasons.filter(
|
||||
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length >=
|
||||
filteredSeasons.length && filteredSeasons.length > 0;
|
||||
|
||||
if (media) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newSeasonsAvailable = (media?.seasons ?? []).filter(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||
).length;
|
||||
|
||||
if (newSeasonsAvailable > currentSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
|
||||
server4k ? '4K ' : ''
|
||||
}season(s) for ${sonarrSeries.title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
|
||||
) {
|
||||
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
|
||||
this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
sonarrSeries.id
|
||||
) {
|
||||
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||
sonarrSeries.id;
|
||||
this.log(
|
||||
`Updated external service ID for media entity: ${sonarrSeries.title}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
sonarrSeries.titleSlug
|
||||
) {
|
||||
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||
sonarrSeries.titleSlug;
|
||||
this.log(
|
||||
`Updated external service slug for media entity: ${sonarrSeries.title}`
|
||||
);
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN
|
||||
).length === 0;
|
||||
|
||||
media[server4k ? 'status4k' : 'status'] =
|
||||
isAllSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE ||
|
||||
season[server4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PARTIALLY_AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season[server4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
const tmdb = new TheMovieDb();
|
||||
let tvShow: TmdbTvDetails;
|
||||
|
||||
try {
|
||||
tvShow = await tmdb.getShowByTvdbId({
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log(
|
||||
'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
|
||||
'warn',
|
||||
{ sonarrSeries, errorMessage: e.message }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMedia = new Media({
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
mediaType: MediaType.TV,
|
||||
serviceId: !server4k ? this.currentServer.id : undefined,
|
||||
serviceId4k: server4k ? this.currentServer.id : undefined,
|
||||
externalServiceId: !server4k ? sonarrSeries.id : undefined,
|
||||
externalServiceId4k: server4k ? sonarrSeries.id : undefined,
|
||||
externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
|
||||
externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
|
||||
seasons: newSeasons,
|
||||
status:
|
||||
!server4k && isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: !server4k &&
|
||||
newSeasons.some(
|
||||
(s) =>
|
||||
s.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
s.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
server4k && isAllSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: server4k &&
|
||||
newSeasons.some(
|
||||
(s) =>
|
||||
s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
s.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: server4k
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Added media for series ${sonarrSeries.title} and set status to ${
|
||||
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
|
||||
}`
|
||||
);
|
||||
await mediaRepository.save(newMedia);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(items: SonarrSeries[]) {
|
||||
await Promise.all(
|
||||
items.map(async (sonarrSeries) => {
|
||||
await this.processSonarrSeries(sonarrSeries);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Sonarr Sync', ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export const jobSonarrSync = new JobSonarrSync();
|
||||
@@ -21,6 +21,12 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||
}
|
||||
|
||||
protected abstract getSettings(): T;
|
||||
|
||||
protected userNotificationTypes: Notification[] = [
|
||||
Notification.MEDIA_APPROVED,
|
||||
Notification.MEDIA_DECLINED,
|
||||
Notification.MEDIA_AVAILABLE,
|
||||
];
|
||||
}
|
||||
|
||||
export interface NotificationAgent {
|
||||
|
||||
@@ -71,7 +71,7 @@ interface DiscordRichEmbed {
|
||||
|
||||
interface DiscordWebhookPayload {
|
||||
embeds: DiscordRichEmbed[];
|
||||
username: string;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
tts: boolean;
|
||||
content?: string;
|
||||
@@ -122,6 +122,7 @@ class DiscordAgent
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
color = EmbedColors.PURPLE;
|
||||
fields.push({
|
||||
name: 'Status',
|
||||
@@ -201,10 +202,13 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending discord notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Discord notification', { label: 'Notifications' });
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
const {
|
||||
botUsername,
|
||||
botAvatarUrl,
|
||||
webhookUrl,
|
||||
} = this.getSettings().options;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
@@ -214,6 +218,7 @@ class DiscordAgent
|
||||
let content = undefined;
|
||||
|
||||
if (
|
||||
this.userNotificationTypes.includes(type) &&
|
||||
payload.notifyUser.settings?.enableNotifications &&
|
||||
payload.notifyUser.settings?.discordId
|
||||
) {
|
||||
@@ -222,7 +227,8 @@ class DiscordAgent
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
username: settings.main.applicationTitle,
|
||||
username: botUsername,
|
||||
avatar_url: botAvatarUrl,
|
||||
embeds: [this.buildEmbed(type, payload)],
|
||||
content,
|
||||
allowed_mentions: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRepository } from 'typeorm';
|
||||
import { User } from '../../../entity/User';
|
||||
import { Permission } from '../../permissions';
|
||||
import PreparedEmail from '../../email';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
class EmailAgent
|
||||
extends BaseAgent<NotificationAgentEmail>
|
||||
@@ -57,7 +58,9 @@ class EmailAgent
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'A user has requested new media!',
|
||||
body: `A user has requested a new ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
}!`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
@@ -67,13 +70,15 @@ class EmailAgent
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: 'New Request',
|
||||
requestType: `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -103,8 +108,11 @@ class EmailAgent
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body:
|
||||
"A user's new request has failed to add to Sonarr or Radarr",
|
||||
body: `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} could not be added to ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
|
||||
}`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
@@ -114,13 +122,15 @@ class EmailAgent
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: 'Failed Request',
|
||||
requestType: `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -143,7 +153,9 @@ class EmailAgent
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your request for the following media has been approved:',
|
||||
body: `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been approved:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
@@ -153,12 +165,64 @@ class EmailAgent
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: 'Request Approved',
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
|
||||
// This is getting main settings for the whole app
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find();
|
||||
|
||||
// Send to all users with the manage requests permission (or admins)
|
||||
users
|
||||
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
|
||||
.forEach((user) => {
|
||||
const email = new PreparedEmail();
|
||||
|
||||
email.send({
|
||||
template: path.join(
|
||||
__dirname,
|
||||
'../../../templates/email/media-request'
|
||||
),
|
||||
message: {
|
||||
to: user.email,
|
||||
},
|
||||
locals: {
|
||||
body: `A new request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} has been automatically approved:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.notifyUser.displayName,
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -181,7 +245,9 @@ class EmailAgent
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your request for the following media was declined:',
|
||||
body: `Your request for the following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} was declined:`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
@@ -191,12 +257,14 @@ class EmailAgent
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: 'Request Declined',
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -219,7 +287,9 @@ class EmailAgent
|
||||
to: payload.notifyUser.email,
|
||||
},
|
||||
locals: {
|
||||
body: 'Your requested media is now available!',
|
||||
body: `The following ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
|
||||
} you requested is now available!`,
|
||||
mediaName: payload.subject,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
@@ -229,12 +299,14 @@ class EmailAgent
|
||||
: undefined,
|
||||
applicationUrl,
|
||||
applicationTitle,
|
||||
requestType: 'Now Available',
|
||||
requestType: `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -261,7 +333,7 @@ class EmailAgent
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Mail notification failed to send', {
|
||||
logger.error('Email notification failed to send', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
@@ -282,6 +354,9 @@ class EmailAgent
|
||||
case Notification.MEDIA_APPROVED:
|
||||
this.sendMediaApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
this.sendMediaAutoApprovedEmail(payload);
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
this.sendMediaDeclinedEmail(payload);
|
||||
break;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface PushbulletPayload {
|
||||
title: string;
|
||||
@@ -50,7 +51,9 @@ class PushbulletAgent
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = 'New Request';
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
@@ -59,7 +62,20 @@ class PushbulletAgent
|
||||
message += `\nStatus: Pending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = 'Request Approved';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
@@ -68,7 +84,9 @@ class PushbulletAgent
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = 'Now Available';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
@@ -77,7 +95,9 @@ class PushbulletAgent
|
||||
message += `\nStatus: Available`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = 'Request Declined';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
@@ -86,7 +106,9 @@ class PushbulletAgent
|
||||
message += `\nStatus: Declined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = 'Failed Request';
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushover } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface PushoverPayload {
|
||||
token: string;
|
||||
@@ -64,7 +65,9 @@ class PushoverAgent
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = 'New Request';
|
||||
messageTitle = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -73,7 +76,20 @@ class PushoverAgent
|
||||
message += `\n\n<b>Status</b>\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = 'Request Approved';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n<b>Requested By</b>\n${username}`;
|
||||
message += `\n\n<b>Status</b>\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -82,7 +98,9 @@ class PushoverAgent
|
||||
message += `\n\n<b>Status</b>\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = 'Now Available';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -91,7 +109,9 @@ class PushoverAgent
|
||||
message += `\n\n<b>Status</b>\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = 'Request Declined';
|
||||
messageTitle = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -101,7 +121,9 @@ class PushoverAgent
|
||||
priority = 1;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = 'Failed Request';
|
||||
messageTitle = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
message += `<b>${title}</b>`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentSlack } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
|
||||
interface EmbedField {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
@@ -72,35 +73,54 @@ class SlackAgent
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
header = 'New Request';
|
||||
header = `New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nPending Approval',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
header = 'Request Approved';
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nProcessing',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
header = 'Now Available';
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nAvailable',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
header = 'Request Declined';
|
||||
header = `${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nDeclined',
|
||||
});
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
header = 'Failed Request';
|
||||
header = `Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request`;
|
||||
fields.push({
|
||||
type: 'mrkdwn',
|
||||
text: '*Status*\nFailed',
|
||||
@@ -206,7 +226,7 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending slack notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Slack notification', { label: 'Notifications' });
|
||||
try {
|
||||
const webhookUrl = this.getSettings().options.webhookUrl;
|
||||
|
||||
|
||||
@@ -2,15 +2,24 @@ import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentTelegram } from '../../settings';
|
||||
import { MediaType } from '../../../constants/media';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface TelegramPayload {
|
||||
interface TelegramMessagePayload {
|
||||
text: string;
|
||||
parse_mode: string;
|
||||
chat_id: string;
|
||||
disable_notification: boolean;
|
||||
}
|
||||
|
||||
interface TelegramPhotoPayload {
|
||||
photo: string;
|
||||
caption: string;
|
||||
parse_mode: string;
|
||||
chat_id: string;
|
||||
disable_notification: boolean;
|
||||
}
|
||||
|
||||
class TelegramAgent
|
||||
extends BaseAgent<NotificationAgentTelegram>
|
||||
implements NotificationAgent {
|
||||
@@ -53,11 +62,14 @@ class TelegramAgent
|
||||
const title = this.escapeText(payload.subject);
|
||||
const plot = this.escapeText(payload.message);
|
||||
const user = this.escapeText(payload.notifyUser.displayName);
|
||||
const applicationTitle = this.escapeText(settings.main.applicationTitle);
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
message += `\*New Request\*`;
|
||||
message += `\*New ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -66,7 +78,20 @@ class TelegramAgent
|
||||
message += `\n\n\*Status\*\nPending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
message += `\*Request Approved\*`;
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
}
|
||||
message += `\n\n\*Requested By\*\n${user}`;
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AUTO_APPROVED:
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Automatically Approved\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -75,7 +100,9 @@ class TelegramAgent
|
||||
message += `\n\n\*Status\*\nProcessing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
message += `\*Now Available\*`;
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Now Available\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -84,7 +111,9 @@ class TelegramAgent
|
||||
message += `\n\n\*Status\*\nAvailable`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
message += `\*Request Declined\*`;
|
||||
message += `\*${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request Declined\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -93,7 +122,9 @@ class TelegramAgent
|
||||
message += `\n\n\*Status\*\nDeclined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
message += `\*Failed Request\*`;
|
||||
message += `\*Failed ${
|
||||
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
|
||||
} Request\*`;
|
||||
message += `\n\n\*${title}\*`;
|
||||
if (plot) {
|
||||
message += `\n${plot}`;
|
||||
@@ -109,7 +140,7 @@ class TelegramAgent
|
||||
|
||||
if (settings.main.applicationUrl && payload.media) {
|
||||
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||
message += `\n\n\[Open in ${settings.main.applicationTitle}\]\(${actionUrl}\)`;
|
||||
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
@@ -120,18 +151,53 @@ class TelegramAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending telegram notification', { label: 'Notifications' });
|
||||
logger.debug('Sending Telegram notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = `${this.baseUrl}bot${
|
||||
this.getSettings().options.botAPI
|
||||
}/sendMessage`;
|
||||
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
|
||||
|
||||
await axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
disable_notification: this.getSettings().options.sendSilently,
|
||||
} as TelegramPayload);
|
||||
// Send system notification
|
||||
await (payload.image
|
||||
? axios.post(endpoint, {
|
||||
photo: payload.image,
|
||||
caption: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
disable_notification: this.getSettings().options.sendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${this.getSettings().options.chatId}`,
|
||||
disable_notification: this.getSettings().options.sendSilently,
|
||||
} as TelegramMessagePayload));
|
||||
|
||||
// Send user notification
|
||||
if (
|
||||
this.userNotificationTypes.includes(type) &&
|
||||
payload.notifyUser.settings?.enableNotifications &&
|
||||
payload.notifyUser.settings?.telegramChatId &&
|
||||
payload.notifyUser.settings?.telegramChatId !==
|
||||
this.getSettings().options.chatId
|
||||
) {
|
||||
await (payload.image
|
||||
? axios.post(endpoint, {
|
||||
photo: payload.image,
|
||||
caption: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
|
||||
disable_notification:
|
||||
payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramPhotoPayload)
|
||||
: axios.post(endpoint, {
|
||||
text: this.buildMessage(type, payload),
|
||||
parse_mode: 'MarkdownV2',
|
||||
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
|
||||
disable_notification:
|
||||
payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notifyuser_email: 'notifyUser.email',
|
||||
notifyuser_avatar: 'notifyUser.avatar',
|
||||
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
|
||||
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
@@ -137,7 +138,7 @@ class WebhookAgent
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Webhook notification', {
|
||||
logger.error('Error sending webhook notification', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum Notification {
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
}
|
||||
|
||||
export const hasNotificationType = (
|
||||
|
||||
616
server/lib/scanners/baseScanner.ts
Normal file
616
server/lib/scanners/baseScanner.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { getRepository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import TheMovieDb from '../../api/themoviedb';
|
||||
import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import Media from '../../entity/Media';
|
||||
import Season from '../../entity/Season';
|
||||
import logger from '../../logger';
|
||||
import AsyncLock from '../../utils/asyncLock';
|
||||
import { getSettings } from '../settings';
|
||||
|
||||
// Default scan rates (can be overidden)
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
export type StatusBase = {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export interface RunnableScanner<T> {
|
||||
run: () => Promise<void>;
|
||||
status: () => T & StatusBase;
|
||||
}
|
||||
|
||||
export interface MediaIds {
|
||||
tmdbId: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
isHama?: boolean;
|
||||
}
|
||||
|
||||
interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
title?: string;
|
||||
processing?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessableSeason {
|
||||
seasonNumber: number;
|
||||
totalEpisodes: number;
|
||||
episodes: number;
|
||||
episodes4k: number;
|
||||
is4kOverride?: boolean;
|
||||
processing?: boolean;
|
||||
}
|
||||
|
||||
class BaseScanner<T> {
|
||||
private bundleSize;
|
||||
private updateRate;
|
||||
protected progress = 0;
|
||||
protected items: T[] = [];
|
||||
protected scannerName: string;
|
||||
protected enable4kMovie = false;
|
||||
protected enable4kShow = false;
|
||||
protected sessionId: string;
|
||||
protected running = false;
|
||||
readonly asyncLock = new AsyncLock();
|
||||
readonly tmdb = new TheMovieDb();
|
||||
|
||||
protected constructor(
|
||||
scannerName: string,
|
||||
{
|
||||
updateRate,
|
||||
bundleSize,
|
||||
}: {
|
||||
updateRate?: number;
|
||||
bundleSize?: number;
|
||||
} = {}
|
||||
) {
|
||||
this.scannerName = scannerName;
|
||||
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
|
||||
this.updateRate = updateRate ?? UPDATE_RATE;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
protected async processMovie(
|
||||
tmdbId: number,
|
||||
{
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
processing = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbId, async () => {
|
||||
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
ratingKey &&
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
|
||||
) {
|
||||
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
) {
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceId !== undefined &&
|
||||
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
|
||||
externalServiceId
|
||||
) {
|
||||
existing[
|
||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
||||
] = externalServiceId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
externalServiceSlug !== undefined &&
|
||||
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
|
||||
externalServiceSlug
|
||||
) {
|
||||
existing[
|
||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = externalServiceSlug;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Media for ${title} exists. Changed were detected and the title will be updated.`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(`Title already exists and no changes detected for ${title}`);
|
||||
}
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: !is4k && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
is4k && this.enable4kMovie && !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: is4k && this.enable4kMovie && processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.serviceId = !is4k ? serviceId : undefined;
|
||||
newMedia.serviceId4k = is4k ? serviceId : undefined;
|
||||
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
|
||||
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
|
||||
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
|
||||
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
|
||||
|
||||
if (mediaAddedAt) {
|
||||
newMedia.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
|
||||
if (ratingKey) {
|
||||
newMedia.ratingKey = !is4k ? ratingKey : undefined;
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
|
||||
* should include the total episodes a sesaon has + the total available
|
||||
* episodes that each season currently has. Unlike processMovie, this method
|
||||
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
|
||||
* in one method.
|
||||
*
|
||||
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
|
||||
* into the normal episodes properties and avoid using the 4k properties.
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
is4k = false,
|
||||
title = 'Unknown Title',
|
||||
}: ProcessOptions = {}
|
||||
): Promise<void> {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
await this.asyncLock.dispatch(tmdbId, async () => {
|
||||
const media = await this.getExisting(tmdbId, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentStandardSeasonsAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const current4kSeasonsAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
for (const season of seasons) {
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.ratingKey4k !== ratingKey
|
||||
) {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
// force it to stay available (to avoid competing scanners)
|
||||
existingSeason.status =
|
||||
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
existingSeason.status4k =
|
||||
(this.enable4kShow &&
|
||||
season.episodes4k === season.totalEpisodes &&
|
||||
season.episodes4k > 0) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
seasonNumber: season.seasonNumber,
|
||||
status:
|
||||
season.totalEpisodes === season.episodes && season.episodes > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: season.episodes > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: !season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow &&
|
||||
season.totalEpisodes === season.episodes4k &&
|
||||
season.episodes4k > 0
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && season.episodes4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: season.is4kOverride && season.processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isAllStandardSeasons =
|
||||
seasons.length &&
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||
);
|
||||
|
||||
const isAll4kSeasons =
|
||||
seasons.length &&
|
||||
seasons.every(
|
||||
(season) =>
|
||||
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
|
||||
);
|
||||
|
||||
if (media) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonsAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const new4kSeasonsAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
|
||||
} new standard season(s) for ${title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
|
||||
if (mediaAddedAt) {
|
||||
media.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
new4kSeasonsAvailable - current4kSeasonsAvailable
|
||||
} new 4K season(s) for ${title}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (!media.mediaAddedAt && mediaAddedAt) {
|
||||
media.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
|
||||
if (serviceId !== undefined) {
|
||||
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
|
||||
}
|
||||
|
||||
if (externalServiceId !== undefined) {
|
||||
media[
|
||||
is4k ? 'externalServiceId4k' : 'externalServiceId'
|
||||
] = externalServiceId;
|
||||
}
|
||||
|
||||
if (externalServiceSlug !== undefined) {
|
||||
media[
|
||||
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||
] = externalServiceSlug;
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
||||
.length === 0;
|
||||
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
mediaAddedAt,
|
||||
serviceId: !is4k ? serviceId : undefined,
|
||||
serviceId4k: is4k ? serviceId : undefined,
|
||||
externalServiceId: !is4k ? externalServiceId : undefined,
|
||||
externalServiceId4k: is4k ? externalServiceId : undefined,
|
||||
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
|
||||
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
|
||||
ratingKey: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
ratingKey4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) =>
|
||||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status4k === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${title}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call startRun from child class whenever a run is starting to
|
||||
* ensure required values are set
|
||||
*
|
||||
* Returns the session ID which is requried for the cleanup method
|
||||
*/
|
||||
protected startRun(): string {
|
||||
const settings = getSettings();
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
|
||||
this.log('Scan starting', 'info', { sessionId });
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4kShow) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call at end of run loop to perform cleanup
|
||||
*/
|
||||
protected endRun(sessionId: string): void {
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
protected async loop(
|
||||
processFn: (item: T) => Promise<void>,
|
||||
{
|
||||
start = 0,
|
||||
end = this.bundleSize,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(processFn, slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop(processFn, {
|
||||
start: start + this.bundleSize,
|
||||
end: end + this.bundleSize,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, this.updateRate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(
|
||||
processFn: (items: T) => Promise<void>,
|
||||
items: T[]
|
||||
) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await processFn(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: this.scannerName, ...optional });
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseScanner;
|
||||
463
server/lib/scanners/plex/index.ts
Normal file
463
server/lib/scanners/plex/index.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import animeList from '../../../api/animelist';
|
||||
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
|
||||
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
|
||||
import { User } from '../../../entity/User';
|
||||
import { getSettings, Library } from '../../settings';
|
||||
import BaseScanner, {
|
||||
MediaIds,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
ProcessableSeason,
|
||||
} from '../baseScanner';
|
||||
|
||||
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
|
||||
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
|
||||
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
|
||||
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
|
||||
const plexRegex = new RegExp(/plex:\/\//);
|
||||
// Hama agent uses ASS naming, see details here:
|
||||
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
|
||||
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
|
||||
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
|
||||
const HAMA_AGENT = 'com.plexapp.agents.hama';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
};
|
||||
|
||||
class PlexScanner
|
||||
extends BaseScanner<PlexLibraryItem>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private plexClient: PlexAPI;
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private isRecentOnly = false;
|
||||
|
||||
public constructor(isRecentOnly = false) {
|
||||
super('Plex Scan');
|
||||
this.isRecentOnly = isRecentOnly;
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentLibrary: this.currentLibrary,
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
select: ['id', 'plexToken'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return this.log('No admin configured. Plex scan skipped.', 'warn');
|
||||
}
|
||||
|
||||
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
||||
|
||||
this.libraries = settings.plex.libraries.filter(
|
||||
(library) => library.enabled
|
||||
);
|
||||
|
||||
const hasHama = await this.hasHamaAgent();
|
||||
if (hasHama) {
|
||||
await animeList.sync();
|
||||
}
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
);
|
||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||
library.id
|
||||
);
|
||||
|
||||
// Bundle items up by rating keys
|
||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||
return (
|
||||
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||
}
|
||||
|
||||
return mediaA.ratingKey === mediaB.ratingKey;
|
||||
});
|
||||
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.plexClient.getLibraryContents(library.id);
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
}
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
: 'Full Scan Complete',
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItem(plexitem: PlexLibraryItem) {
|
||||
try {
|
||||
if (plexitem.type === 'movie') {
|
||||
await this.processPlexMovie(plexitem);
|
||||
} else if (
|
||||
plexitem.type === 'show' ||
|
||||
plexitem.type === 'episode' ||
|
||||
plexitem.type === 'season'
|
||||
) {
|
||||
await this.processPlexShow(plexitem);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('Failed to process Plex media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processPlexMovie(plexitem: PlexLibraryItem) {
|
||||
const mediaIds = await this.getMediaIds(plexitem);
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
const has4k = metadata.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
|
||||
await this.processMovie(mediaIds.tmdbId, {
|
||||
is4k: has4k && this.enable4kMovie,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
ratingKey: plexitem.ratingKey,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
|
||||
private async processPlexMovieByTmdbId(
|
||||
plexitem: PlexMetadata,
|
||||
tmdbId: number
|
||||
) {
|
||||
const has4k = plexitem.Media.some(
|
||||
(media) => media.videoResolution === '4k'
|
||||
);
|
||||
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: has4k && this.enable4kMovie,
|
||||
mediaAddedAt: new Date(plexitem.addedAt * 1000),
|
||||
ratingKey: plexitem.ratingKey,
|
||||
title: plexitem.title,
|
||||
});
|
||||
}
|
||||
|
||||
private async processPlexShow(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
plexitem.parentRatingKey ??
|
||||
plexitem.ratingKey;
|
||||
const metadata = await this.plexClient.getMetadata(ratingKey, {
|
||||
includeChildren: true,
|
||||
});
|
||||
|
||||
const mediaIds = await this.getMediaIds(metadata);
|
||||
|
||||
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
|
||||
// as a special HAMA movie
|
||||
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
|
||||
this.processHamaMovie(metadata, mediaIds.tmdbId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the media is from HAMA and we have a TVDb ID, we will attempt
|
||||
// to process any specials that may exist
|
||||
if (mediaIds.tvdbId && mediaIds.isHama) {
|
||||
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
||||
}
|
||||
|
||||
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
|
||||
|
||||
const seasons = tvShow.seasons;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const matchedPlexSeason = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === season.season_number
|
||||
);
|
||||
|
||||
if (matchedPlexSeason) {
|
||||
// If we have a matched Plex season, get its children metadata so we can check details
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
matchedPlexSeason.ratingKey
|
||||
);
|
||||
// Total episodes that are in standard definition (not 4k)
|
||||
const totalStandard = episodes.filter((episode) =>
|
||||
!this.enable4kShow
|
||||
? true
|
||||
: episode.Media.some((media) => media.videoResolution !== '4k')
|
||||
).length;
|
||||
|
||||
// Total episodes that are in 4k
|
||||
const total4k = this.enable4kShow
|
||||
? episodes.filter((episode) =>
|
||||
episode.Media.some((media) => media.videoResolution === '4k')
|
||||
).length
|
||||
: 0;
|
||||
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
episodes: totalStandard,
|
||||
episodes4k: total4k,
|
||||
totalEpisodes: season.episode_count,
|
||||
});
|
||||
} else {
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
episodes: 0,
|
||||
episodes4k: 0,
|
||||
totalEpisodes: season.episode_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaIds.tvdbId) {
|
||||
await this.processShow(
|
||||
mediaIds.tmdbId,
|
||||
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
|
||||
processableSeasons,
|
||||
{
|
||||
mediaAddedAt: new Date(metadata.addedAt * 1000),
|
||||
ratingKey: ratingKey,
|
||||
title: metadata.title,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
|
||||
const mediaIds: Partial<MediaIds> = {};
|
||||
// Check if item is using new plex movie/tv agent
|
||||
if (plexitem.guid.match(plexRegex)) {
|
||||
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
|
||||
|
||||
// If there is no Guid field at all, then we bail
|
||||
if (!metadata.Guid) {
|
||||
throw new Error(
|
||||
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
|
||||
);
|
||||
}
|
||||
|
||||
// Map all IDs to MediaId object
|
||||
metadata.Guid.forEach((ref) => {
|
||||
if (ref.id.match(imdbRegex)) {
|
||||
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
|
||||
} else if (ref.id.match(tmdbRegex)) {
|
||||
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
|
||||
mediaIds.tmdbId = Number(tmdbMatch);
|
||||
} else if (ref.id.match(tvdbRegex)) {
|
||||
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
|
||||
mediaIds.tvdbId = Number(tvdbMatch);
|
||||
}
|
||||
});
|
||||
|
||||
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
|
||||
if (mediaIds.imdbId && !mediaIds.tmdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: mediaIds.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
// Check if the agent is IMDb
|
||||
} else if (plexitem.guid.match(imdbRegex)) {
|
||||
const imdbMatch = plexitem.guid.match(imdbRegex);
|
||||
if (imdbMatch) {
|
||||
mediaIds.imdbId = imdbMatch[1];
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: mediaIds.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
}
|
||||
// Check if the agent is TMDb
|
||||
} else if (plexitem.guid.match(tmdbRegex)) {
|
||||
const tmdbMatch = plexitem.guid.match(tmdbRegex);
|
||||
if (tmdbMatch) {
|
||||
mediaIds.tmdbId = Number(tmdbMatch[1]);
|
||||
}
|
||||
// Check if the agent is TVDb
|
||||
} else if (plexitem.guid.match(tvdbRegex)) {
|
||||
const matchedtvdb = plexitem.guid.match(tvdbRegex);
|
||||
|
||||
// If we can find a tvdb Id, use it to get the full tmdb show details
|
||||
if (matchedtvdb) {
|
||||
const show = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
|
||||
mediaIds.tvdbId = Number(matchedtvdb[1]);
|
||||
mediaIds.tmdbId = show.id;
|
||||
}
|
||||
// Check if the agent (for shows) is TMDb
|
||||
} else if (plexitem.guid.match(tmdbShowRegex)) {
|
||||
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
|
||||
if (matchedtmdb) {
|
||||
mediaIds.tmdbId = Number(matchedtmdb[1]);
|
||||
}
|
||||
// Check for HAMA (with TVDb guid)
|
||||
} else if (plexitem.guid.match(hamaTvdbRegex)) {
|
||||
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
|
||||
|
||||
if (matchedtvdb) {
|
||||
const show = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(matchedtvdb[1]),
|
||||
});
|
||||
|
||||
mediaIds.tvdbId = Number(matchedtvdb[1]);
|
||||
mediaIds.tmdbId = show.id;
|
||||
// Set isHama to true, so we can know to add special processing to this item
|
||||
mediaIds.isHama = true;
|
||||
}
|
||||
// Check for HAMA (with anidb guid)
|
||||
} else if (plexitem.guid.match(hamaAnidbRegex)) {
|
||||
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
|
||||
|
||||
if (!animeList.isLoaded()) {
|
||||
this.log(
|
||||
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
|
||||
'warn',
|
||||
{ title: plexitem.title }
|
||||
);
|
||||
} else if (matchedhama) {
|
||||
const anidbId = Number(matchedhama[1]);
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
// Set isHama to true, so we can know to add special processing to this item
|
||||
mediaIds.isHama = true;
|
||||
|
||||
// First try to lookup the show by TVDb ID
|
||||
if (result?.tvdbId) {
|
||||
const extResponse = await this.tmdb.getByExternalId({
|
||||
externalId: result.tvdbId,
|
||||
type: 'tvdb',
|
||||
});
|
||||
if (extResponse.tv_results[0]) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: extResponse.tv_results[0].id,
|
||||
});
|
||||
mediaIds.tvdbId = result.tvdbId;
|
||||
mediaIds.tmdbId = tvShow.id;
|
||||
} else {
|
||||
this.log(
|
||||
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tvShow) {
|
||||
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
|
||||
// note - some tv shows have imdbid set too, that's why this need to go second
|
||||
if (result?.tmdbId) {
|
||||
mediaIds.tmdbId = result.tmdbId;
|
||||
mediaIds.imdbId = result?.imdbId;
|
||||
} else if (result?.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: result.imdbId,
|
||||
});
|
||||
mediaIds.tmdbId = tmdbMovie.id;
|
||||
mediaIds.imdbId = result.imdbId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mediaIds.tmdbId) {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
// We check above if we have the TMDb ID, so we can safely assert the type below
|
||||
return mediaIds as MediaIds;
|
||||
}
|
||||
|
||||
// movies with hama agent actually are tv shows with at least one episode in it
|
||||
// try to get first episode of any season - cannot hardcode season or episode number
|
||||
// because sometimes user can have it in other season/ep than s01e01
|
||||
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
|
||||
const season = metadata.Children?.Metadata[0];
|
||||
if (season) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
season.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this adds all movie episodes from specials season for Hama agent
|
||||
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
|
||||
const specials = metadata.Children?.Metadata.find(
|
||||
(md) => Number(md.index) === 0
|
||||
);
|
||||
if (specials) {
|
||||
const episodes = await this.plexClient.getChildrenMetadata(
|
||||
specials.ratingKey
|
||||
);
|
||||
if (episodes) {
|
||||
for (const episode of episodes) {
|
||||
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
|
||||
if (special) {
|
||||
if (special.tmdbId) {
|
||||
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
|
||||
} else if (special.imdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMovieByImdbId({
|
||||
imdbId: special.imdbId,
|
||||
});
|
||||
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checks if any of this.libraries has Hama agent set in Plex
|
||||
private async hasHamaAgent() {
|
||||
const plexLibraries = await this.plexClient.getLibraries();
|
||||
return this.libraries.some((library) =>
|
||||
plexLibraries.some(
|
||||
(plexLibrary) =>
|
||||
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const plexFullScanner = new PlexScanner();
|
||||
export const plexRecentScanner = new PlexScanner(true);
|
||||
94
server/lib/scanners/radarr/index.ts
Normal file
94
server/lib/scanners/radarr/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
|
||||
import { getSettings, RadarrSettings } from '../../settings';
|
||||
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: RadarrSettings;
|
||||
servers: RadarrSettings[];
|
||||
};
|
||||
|
||||
class RadarrScanner
|
||||
extends BaseScanner<RadarrMovie>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private servers: RadarrSettings[];
|
||||
private currentServer: RadarrSettings;
|
||||
private radarrApi: RadarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Radarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
|
||||
return (
|
||||
radarrA.hostname === radarrB.hostname &&
|
||||
radarrA.port === radarrB.port &&
|
||||
radarrA.baseUrl === radarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Radarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.radarrApi = new RadarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.radarrApi.getMovies();
|
||||
|
||||
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Radarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
|
||||
try {
|
||||
const server4k = this.enable4kMovie && this.currentServer.is4k;
|
||||
await this.processMovie(radarrMovie.tmdbId, {
|
||||
is4k: server4k,
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: radarrMovie.id,
|
||||
externalServiceSlug: radarrMovie.titleSlug,
|
||||
title: radarrMovie.title,
|
||||
processing: !radarrMovie.downloaded,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Radarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: radarrMovie.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const radarrScanner = new RadarrScanner();
|
||||
134
server/lib/scanners/sonarr/index.ts
Normal file
134
server/lib/scanners/sonarr/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { uniqWith } from 'lodash';
|
||||
import { getRepository } from 'typeorm';
|
||||
import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
|
||||
import Media from '../../../entity/Media';
|
||||
import { getSettings, SonarrSettings } from '../../settings';
|
||||
import BaseScanner, {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '../baseScanner';
|
||||
|
||||
type SyncStatus = StatusBase & {
|
||||
currentServer: SonarrSettings;
|
||||
servers: SonarrSettings[];
|
||||
};
|
||||
|
||||
class SonarrScanner
|
||||
extends BaseScanner<SonarrSeries>
|
||||
implements RunnableScanner<SyncStatus> {
|
||||
private servers: SonarrSettings[];
|
||||
private currentServer: SonarrSettings;
|
||||
private sonarrApi: SonarrAPI;
|
||||
|
||||
constructor() {
|
||||
super('Sonarr Scan', { bundleSize: 50 });
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
total: this.items.length,
|
||||
currentServer: this.currentServer,
|
||||
servers: this.servers,
|
||||
};
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
|
||||
return (
|
||||
sonarrA.hostname === sonarrB.hostname &&
|
||||
sonarrA.port === sonarrB.port &&
|
||||
sonarrA.baseUrl === sonarrB.baseUrl
|
||||
);
|
||||
});
|
||||
|
||||
for (const server of this.servers) {
|
||||
this.currentServer = server;
|
||||
if (server.syncEnabled) {
|
||||
this.log(
|
||||
`Beginning to process Sonarr server: ${server.name}`,
|
||||
'info'
|
||||
);
|
||||
|
||||
this.sonarrApi = new SonarrAPI({
|
||||
apiKey: server.apiKey,
|
||||
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
|
||||
});
|
||||
|
||||
this.items = await this.sonarrApi.getSeries();
|
||||
|
||||
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
|
||||
} else {
|
||||
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.log('Sonarr scan complete', 'info');
|
||||
} catch (e) {
|
||||
this.log('Scan interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
|
||||
try {
|
||||
const mediaRepository = getRepository(Media);
|
||||
const server4k = this.enable4kShow && this.currentServer.is4k;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
let tmdbId: number;
|
||||
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tvdbId: sonarrSeries.tvdbId },
|
||||
});
|
||||
|
||||
if (!media || !media.tmdbId) {
|
||||
const tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: sonarrSeries.tvdbId,
|
||||
});
|
||||
|
||||
tmdbId = tvShow.id;
|
||||
} else {
|
||||
tmdbId = media.tmdbId;
|
||||
}
|
||||
|
||||
const filteredSeasons = sonarrSeries.seasons.filter(
|
||||
(sn) => sn.seasonNumber !== 0
|
||||
);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
|
||||
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodes: !server4k ? totalAvailableEpisodes : 0,
|
||||
episodes4k: server4k ? totalAvailableEpisodes : 0,
|
||||
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
|
||||
processing: season.monitored && totalAvailableEpisodes === 0,
|
||||
is4kOverride: server4k,
|
||||
});
|
||||
}
|
||||
|
||||
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
|
||||
serviceId: this.currentServer.id,
|
||||
externalServiceId: sonarrSeries.id,
|
||||
externalServiceSlug: sonarrSeries.titleSlug,
|
||||
title: sonarrSeries.title,
|
||||
is4k: server4k,
|
||||
});
|
||||
} catch (e) {
|
||||
this.log('Failed to process Sonarr media', 'error', {
|
||||
errorMessage: e.message,
|
||||
title: sonarrSeries.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sonarrScanner = new SonarrScanner();
|
||||
@@ -85,6 +85,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
movie4kEnabled: boolean;
|
||||
series4kEnabled: boolean;
|
||||
region: string;
|
||||
originalLanguage: string;
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
@@ -94,6 +95,8 @@ export interface NotificationAgentConfig {
|
||||
}
|
||||
export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
||||
options: {
|
||||
botUsername?: string;
|
||||
botAvatarUrl?: string;
|
||||
webhookUrl: string;
|
||||
};
|
||||
}
|
||||
@@ -119,6 +122,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
|
||||
|
||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||
options: {
|
||||
botUsername?: string;
|
||||
botAPI: string;
|
||||
chatId: string;
|
||||
sendSilently: boolean;
|
||||
@@ -159,7 +163,6 @@ interface NotificationAgents {
|
||||
|
||||
interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
autoapprovalEnabled: boolean;
|
||||
agents: NotificationAgents;
|
||||
}
|
||||
|
||||
@@ -209,7 +212,6 @@ class Settings {
|
||||
},
|
||||
notifications: {
|
||||
enabled: true,
|
||||
autoapprovalEnabled: false,
|
||||
agents: {
|
||||
email: {
|
||||
enabled: false,
|
||||
@@ -227,6 +229,8 @@ class Settings {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botUsername: '',
|
||||
botAvatarUrl: '',
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
@@ -241,6 +245,7 @@ class Settings {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
botUsername: '',
|
||||
botAPI: '',
|
||||
chatId: '',
|
||||
sendSilently: false,
|
||||
@@ -337,6 +342,7 @@ class Settings {
|
||||
(sonarr) => sonarr.is4k && sonarr.isDefault
|
||||
),
|
||||
region: this.data.main.region,
|
||||
originalLanguage: this.data.main.originalLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTelegramSettingsToUserSettings1614334195680
|
||||
implements MigrationInterface {
|
||||
name = 'AddTelegramSettingsToUserSettings1614334195680';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "temporary_user_settings"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbMovieReleaseResult,
|
||||
TmdbProductionCompany,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import {
|
||||
ProductionCompany,
|
||||
@@ -79,6 +80,18 @@ export interface MovieDetails {
|
||||
plexUrl?: string;
|
||||
}
|
||||
|
||||
export const mapProductionCompany = (
|
||||
company: TmdbProductionCompany
|
||||
): ProductionCompany => ({
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
originCountry: company.origin_country,
|
||||
description: company.description,
|
||||
headquarters: company.headquarters,
|
||||
homepage: company.homepage,
|
||||
logoPath: company.logo_path,
|
||||
});
|
||||
|
||||
export const mapMovieDetails = (
|
||||
movie: TmdbMovieDetails,
|
||||
media?: Media
|
||||
@@ -91,12 +104,7 @@ export const mapMovieDetails = (
|
||||
originalLanguage: movie.original_language,
|
||||
originalTitle: movie.original_title,
|
||||
popularity: movie.popularity,
|
||||
productionCompanies: movie.production_companies.map((company) => ({
|
||||
id: company.id,
|
||||
logoPath: company.logo_path,
|
||||
originCountry: company.origin_country,
|
||||
name: company.name,
|
||||
})),
|
||||
productionCompanies: movie.production_companies.map(mapProductionCompany),
|
||||
productionCountries: movie.production_countries,
|
||||
releaseDate: movie.release_date,
|
||||
releases: movie.release_dates,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapExternalIds,
|
||||
Keyword,
|
||||
mapVideos,
|
||||
TvNetwork,
|
||||
} from './common';
|
||||
import type {
|
||||
TmdbTvEpisodeResult,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
TmdbTvDetails,
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvRatingResult,
|
||||
TmdbNetwork,
|
||||
} from '../api/themoviedb/interfaces';
|
||||
import type Media from '../entity/Media';
|
||||
import { Video } from './Movie';
|
||||
@@ -77,7 +79,7 @@ export interface TvDetails {
|
||||
lastEpisodeToAir?: Episode;
|
||||
name: string;
|
||||
nextEpisodeToAir?: Episode;
|
||||
networks: ProductionCompany[];
|
||||
networks: TvNetwork[];
|
||||
numberOfEpisodes: number;
|
||||
numberOfSeasons: number;
|
||||
originCountry: string[];
|
||||
@@ -139,6 +141,15 @@ export const mapSeasonWithEpisodes = (
|
||||
posterPath: season.poster_path,
|
||||
});
|
||||
|
||||
export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
|
||||
id: network.id,
|
||||
name: network.name,
|
||||
originCountry: network.origin_country,
|
||||
headquarters: network.headquarters,
|
||||
homepage: network.homepage,
|
||||
logoPath: network.logo_path,
|
||||
});
|
||||
|
||||
export const mapTvDetails = (
|
||||
show: TmdbTvDetails,
|
||||
media?: Media
|
||||
@@ -157,12 +168,7 @@ export const mapTvDetails = (
|
||||
languages: show.languages,
|
||||
lastAirDate: show.last_air_date,
|
||||
name: show.name,
|
||||
networks: show.networks.map((network) => ({
|
||||
id: network.id,
|
||||
name: network.name,
|
||||
originCountry: network.origin_country,
|
||||
logoPath: network.logo_path,
|
||||
})),
|
||||
networks: show.networks.map(mapNetwork),
|
||||
numberOfEpisodes: show.number_of_episodes,
|
||||
numberOfSeasons: show.number_of_seasons,
|
||||
originCountry: show.origin_country,
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface ProductionCompany {
|
||||
logoPath?: string;
|
||||
originCountry: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
headquarters?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface TvNetwork {
|
||||
id: number;
|
||||
logoPath?: string;
|
||||
originCountry?: string;
|
||||
name: string;
|
||||
headquarters?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface Keyword {
|
||||
|
||||
@@ -5,20 +5,43 @@ import Media from '../entity/Media';
|
||||
import { isMovie, isPerson } from '../utils/typeHelpers';
|
||||
import { MediaType } from '../constants/media';
|
||||
import { getSettings } from '../lib/settings';
|
||||
import { User } from '../entity/User';
|
||||
import { mapProductionCompany } from '../models/Movie';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
|
||||
const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
||||
const settings = getSettings();
|
||||
|
||||
const region =
|
||||
user?.settings?.region === 'all'
|
||||
? ''
|
||||
: user?.settings?.region
|
||||
? user?.settings?.region
|
||||
: settings.main.region;
|
||||
|
||||
const originalLanguage =
|
||||
user?.settings?.originalLanguage === 'all'
|
||||
? ''
|
||||
: user?.settings?.originalLanguage
|
||||
? user?.settings?.originalLanguage
|
||||
: settings.main.originalLanguage;
|
||||
|
||||
return new TheMovieDb({
|
||||
region,
|
||||
originalLanguage,
|
||||
});
|
||||
};
|
||||
|
||||
const discoverRoutes = Router();
|
||||
|
||||
discoverRoutes.get('/movies', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const tmdb = new TheMovieDb({
|
||||
region: req.user?.settings?.region ?? settings.main.region,
|
||||
originalLanguage:
|
||||
req.user?.settings?.originalLanguage ?? settings.main.originalLanguage,
|
||||
});
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: 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(
|
||||
@@ -40,13 +63,135 @@ discoverRoutes.get('/movies', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
discoverRoutes.get<{ language: string }>(
|
||||
'/movies/language/:language',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const languages = await tmdb.getLanguages();
|
||||
|
||||
const language = languages.find(
|
||||
(lang) => lang.iso_639_1 === req.params.language
|
||||
);
|
||||
|
||||
if (!language) {
|
||||
return next({ status: 404, message: 'Unable to retrieve language' });
|
||||
}
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
originalLanguage: req.params.language,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
language,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ genreId: string }>(
|
||||
'/movies/genre/:genreId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const genres = await tmdb.getMovieGenres({
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const genre = genres.find(
|
||||
(genre) => genre.id === Number(req.params.genreId)
|
||||
);
|
||||
|
||||
if (!genre) {
|
||||
return next({ status: 404, message: 'Unable to retrieve genre' });
|
||||
}
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
genre: Number(req.params.genreId),
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
genre,
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ studioId: string }>(
|
||||
'/movies/studio/:studioId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const studio = await tmdb.getStudio(Number(req.params.studioId));
|
||||
|
||||
const data = await tmdb.getDiscoverMovies({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
studio: Number(req.params.studioId),
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
studio: mapProductionCompany(studio),
|
||||
results: data.results.map((result) =>
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
return next({ status: 404, message: 'Unable to retrieve studio' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get('/movies/upcoming', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const tmdb = new TheMovieDb({
|
||||
region: req.user?.settings?.region ?? settings.main.region,
|
||||
originalLanguage:
|
||||
req.user?.settings?.originalLanguage ?? settings.main.originalLanguage,
|
||||
});
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
@@ -80,16 +225,13 @@ discoverRoutes.get('/movies/upcoming', async (req, res) => {
|
||||
});
|
||||
|
||||
discoverRoutes.get('/tv', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const tmdb = new TheMovieDb({
|
||||
region: req.user?.settings?.region ?? settings.main.region,
|
||||
originalLanguage:
|
||||
req.user?.settings?.originalLanguage ?? settings.main.originalLanguage,
|
||||
});
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: 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(
|
||||
@@ -111,13 +253,133 @@ discoverRoutes.get('/tv', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
discoverRoutes.get<{ language: string }>(
|
||||
'/tv/language/:language',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const languages = await tmdb.getLanguages();
|
||||
|
||||
const language = languages.find(
|
||||
(lang) => lang.iso_639_1 === req.params.language
|
||||
);
|
||||
|
||||
if (!language) {
|
||||
return next({ status: 404, message: 'Unable to retrieve language' });
|
||||
}
|
||||
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
originalLanguage: req.params.language,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
language,
|
||||
results: data.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ genreId: string }>(
|
||||
'/tv/genre/:genreId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const genres = await tmdb.getTvGenres({
|
||||
language: req.query.language as string,
|
||||
});
|
||||
|
||||
const genre = genres.find(
|
||||
(genre) => genre.id === Number(req.params.genreId)
|
||||
);
|
||||
|
||||
if (!genre) {
|
||||
return next({ status: 404, message: 'Unable to retrieve genre' });
|
||||
}
|
||||
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
genre: Number(req.params.genreId),
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
genre,
|
||||
results: data.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) => med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get<{ networkId: string }>(
|
||||
'/tv/network/:networkId',
|
||||
async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const network = await tmdb.getNetwork(Number(req.params.networkId));
|
||||
|
||||
const data = await tmdb.getDiscoverTv({
|
||||
page: Number(req.query.page),
|
||||
language: req.query.language as string,
|
||||
network: Number(req.params.networkId),
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
data.results.map((result) => result.id)
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
page: data.page,
|
||||
totalPages: data.total_pages,
|
||||
totalResults: data.total_results,
|
||||
network: mapNetwork(network),
|
||||
results: data.results.map((result) =>
|
||||
mapTvResult(
|
||||
result,
|
||||
media.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
return next({ status: 404, message: 'Unable to retrieve network' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
discoverRoutes.get('/tv/upcoming', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const tmdb = new TheMovieDb({
|
||||
region: req.user?.settings?.region ?? settings.main.region,
|
||||
originalLanguage:
|
||||
req.user?.settings?.originalLanguage ?? settings.main.originalLanguage,
|
||||
});
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
@@ -151,12 +413,7 @@ discoverRoutes.get('/tv/upcoming', async (req, res) => {
|
||||
});
|
||||
|
||||
discoverRoutes.get('/trending', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const tmdb = new TheMovieDb({
|
||||
region: req.user?.settings?.region ?? settings.main.region,
|
||||
originalLanguage:
|
||||
req.user?.settings?.originalLanguage ?? settings.main.originalLanguage,
|
||||
});
|
||||
const tmdb = createTmdbWithRegionLanaguage(req.user);
|
||||
|
||||
const data = await tmdb.getAllTrending({
|
||||
page: Number(req.query.page),
|
||||
@@ -176,15 +433,18 @@ discoverRoutes.get('/trending', async (req, res) => {
|
||||
? mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
: isPerson(result)
|
||||
? mapPersonResult(result)
|
||||
: mapTvResult(
|
||||
result,
|
||||
media.find((req) => req.tmdbId === result.id && MediaType.TV)
|
||||
media.find(
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.TV
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
@@ -213,8 +473,8 @@ discoverRoutes.get<{ keywordId: string }>(
|
||||
mapMovieResult(
|
||||
result,
|
||||
media.find(
|
||||
(req) =>
|
||||
req.tmdbId === result.id && req.mediaType === MediaType.MOVIE
|
||||
(med) =>
|
||||
med.tmdbId === result.id && med.mediaType === MediaType.MOVIE
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
@@ -17,6 +17,8 @@ import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||
import serviceRoutes from './service';
|
||||
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import { mapProductionCompany } from '../models/Movie';
|
||||
import { mapNetwork } from '../models/Tv';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -74,6 +76,38 @@ router.get('/languages', isAuthenticated(), async (req, res) => {
|
||||
return res.status(200).json(languages);
|
||||
});
|
||||
|
||||
router.get<{ id: string }>('/studio/:id', async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const studio = await tmdb.getStudio(Number(req.params.id));
|
||||
|
||||
return res.status(200).json(mapProductionCompany(studio));
|
||||
});
|
||||
|
||||
router.get<{ id: string }>('/network/:id', async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const network = await tmdb.getNetwork(Number(req.params.id));
|
||||
|
||||
return res.status(200).json(mapNetwork(network));
|
||||
});
|
||||
|
||||
router.get('/genres/movie', isAuthenticated(), async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const genres = await tmdb.getMovieGenres();
|
||||
|
||||
return res.status(200).json(genres);
|
||||
});
|
||||
|
||||
router.get('/genres/tv', isAuthenticated(), async (req, res) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const genres = await tmdb.getTvGenres();
|
||||
|
||||
return res.status(200).json(genres);
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
return res.status(200).json({
|
||||
api: 'Overseerr API',
|
||||
|
||||
@@ -211,32 +211,34 @@ requestRoutes.post(
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status:
|
||||
req.user?.hasPermission(
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE
|
||||
) ||
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy:
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE
|
||||
) ||
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
: Permission.AUTO_APPROVE_MOVIE,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
@@ -283,37 +285,37 @@ requestRoutes.post(
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media: {
|
||||
id: media.id,
|
||||
} as Media,
|
||||
media,
|
||||
requestedBy: requestUser,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status:
|
||||
req.user?.hasPermission(
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE
|
||||
) ||
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy:
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
modifiedBy: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE
|
||||
) ||
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? req.user
|
||||
: undefined,
|
||||
is4k: req.body.is4k,
|
||||
serverId: req.body.serverId,
|
||||
profileId: req.body.profileId,
|
||||
@@ -323,19 +325,20 @@ requestRoutes.post(
|
||||
(sn) =>
|
||||
new SeasonRequest({
|
||||
seasonNumber: sn,
|
||||
status:
|
||||
req.user?.hasPermission(
|
||||
status: req.user?.hasPermission(
|
||||
[
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K
|
||||
: Permission.AUTO_APPROVE
|
||||
) ||
|
||||
req.user?.hasPermission(
|
||||
: Permission.AUTO_APPROVE,
|
||||
req.body.is4k
|
||||
? Permission.AUTO_APPROVE_4K_TV
|
||||
: Permission.AUTO_APPROVE_TV
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
: Permission.AUTO_APPROVE_TV,
|
||||
Permission.MANAGE_REQUESTS,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? MediaRequestStatus.APPROVED
|
||||
: MediaRequestStatus.PENDING,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import PlexAPI from '../../api/plexapi';
|
||||
import PlexTvAPI from '../../api/plextv';
|
||||
import { jobPlexFullSync } from '../../job/plexsync';
|
||||
import { scheduledJobs } from '../../job/schedule';
|
||||
import { Permission } from '../../lib/permissions';
|
||||
import { isAuthenticated } from '../../middleware/auth';
|
||||
@@ -17,6 +16,7 @@ import notificationRoutes from './notifications';
|
||||
import sonarrRoutes from './sonarr';
|
||||
import radarrRoutes from './radarr';
|
||||
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
|
||||
import { plexFullScanner } from '../../lib/scanners/plex';
|
||||
|
||||
const settingsRoutes = Router();
|
||||
|
||||
@@ -211,16 +211,16 @@ settingsRoutes.get('/plex/library', async (req, res) => {
|
||||
});
|
||||
|
||||
settingsRoutes.get('/plex/sync', (_req, res) => {
|
||||
return res.status(200).json(jobPlexFullSync.status());
|
||||
return res.status(200).json(plexFullScanner.status());
|
||||
});
|
||||
|
||||
settingsRoutes.post('/plex/sync', (req, res) => {
|
||||
if (req.body.cancel) {
|
||||
jobPlexFullSync.cancel();
|
||||
plexFullScanner.cancel();
|
||||
} else if (req.body.start) {
|
||||
jobPlexFullSync.run();
|
||||
plexFullScanner.run();
|
||||
}
|
||||
return res.status(200).json(jobPlexFullSync.status());
|
||||
return res.status(200).json(plexFullScanner.status());
|
||||
});
|
||||
|
||||
settingsRoutes.get('/jobs', (_req, res) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ notificationRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings().notifications;
|
||||
return res.status(200).json({
|
||||
enabled: settings.enabled,
|
||||
autoapprovalEnabled: settings.autoapprovalEnabled,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,13 +23,11 @@ notificationRoutes.post('/', (req, res) => {
|
||||
|
||||
Object.assign(settings.notifications, {
|
||||
enabled: req.body.enabled,
|
||||
autoapprovalEnabled: req.body.autoapprovalEnabled,
|
||||
});
|
||||
settings.save();
|
||||
|
||||
return res.status(200).json({
|
||||
enabled: settings.notifications.enabled,
|
||||
autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -167,7 +167,10 @@ router.get<{ id: string }, UserRequestsResponse>(
|
||||
}
|
||||
);
|
||||
|
||||
const canMakePermissionsChange = (permissions: number, user?: User) =>
|
||||
export const canMakePermissionsChange = (
|
||||
permissions: number,
|
||||
user?: User
|
||||
): boolean =>
|
||||
// Only let the owner grant admin privileges
|
||||
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
|
||||
// Only let users with the manage settings permission, grant the same permission
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
import { User } from '../../entity/User';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import { UserSettings } from '../../entity/UserSettings';
|
||||
import {
|
||||
UserSettingsGeneralResponse,
|
||||
@@ -21,6 +23,7 @@ const isOwnProfileOrAdmin = (): Middleware => {
|
||||
message: "You do not have permission to view this user's settings.",
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
return authMiddleware;
|
||||
@@ -137,7 +140,19 @@ userSettingsRoutes.post<
|
||||
if (req.body.newPassword.length < 8) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Password must be at least 8 characters',
|
||||
message: 'Password must be at least 8 characters.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(user.id === 1 && req.user?.id !== 1) ||
|
||||
(user.hasPermission(Permission.ADMIN) &&
|
||||
user.id !== req.user?.id &&
|
||||
req.user?.id !== 1)
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to modify this user's password.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +199,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
const settings = getSettings();
|
||||
|
||||
try {
|
||||
const user = await userRepository.findOne({
|
||||
@@ -196,7 +212,11 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
|
||||
return res.status(200).json({
|
||||
enableNotifications: user.settings?.enableNotifications ?? true,
|
||||
telegramBotUsername:
|
||||
settings?.notifications.agents.telegram.options.botUsername,
|
||||
discordId: user.settings?.discordId,
|
||||
telegramChatId: user.settings?.telegramChatId,
|
||||
telegramSendSilently: user?.settings?.telegramSendSilently,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
@@ -225,10 +245,14 @@ userSettingsRoutes.post<
|
||||
user: req.user,
|
||||
enableNotifications: req.body.enableNotifications,
|
||||
discordId: req.body.discordId,
|
||||
telegramChatId: req.body.telegramChatId,
|
||||
telegramSendSilently: req.body.telegramSendSilently,
|
||||
});
|
||||
} else {
|
||||
user.settings.enableNotifications = req.body.enableNotifications;
|
||||
user.settings.discordId = req.body.discordId;
|
||||
user.settings.telegramChatId = req.body.telegramChatId;
|
||||
user.settings.telegramSendSilently = req.body.telegramSendSilently;
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
@@ -236,6 +260,8 @@ userSettingsRoutes.post<
|
||||
return res.status(200).json({
|
||||
enableNotifications: user.settings.enableNotifications,
|
||||
discordId: user.settings.discordId,
|
||||
telegramChatId: user.settings.telegramChatId,
|
||||
telegramSendSilently: user.settings.telegramSendSilently,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
@@ -283,13 +309,20 @@ userSettingsRoutes.post<
|
||||
return next({ status: 404, message: 'User not found.' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
// Only let the owner user modify themselves
|
||||
if (user.id === 1 && req.user?.id !== 1) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Permissions for user with ID 1 cannot be modified',
|
||||
status: 403,
|
||||
message: 'You do not have permission to modify this user',
|
||||
});
|
||||
}
|
||||
|
||||
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'You do not have permission to grant this level of access',
|
||||
});
|
||||
}
|
||||
user.permissions = req.body.permissions;
|
||||
|
||||
await userRepository.save(user);
|
||||
|
||||
@@ -11,7 +11,7 @@ confinement: strict
|
||||
parts:
|
||||
overseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: "12.18.4"
|
||||
nodejs-version: "14.16.0"
|
||||
nodejs-package-manager: "yarn"
|
||||
nodejs-yarn-version: v1.22.5
|
||||
build-packages:
|
||||
|
||||
@@ -38,7 +38,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<ul className="cardList">
|
||||
{items?.map((title) => {
|
||||
{items?.map((title, index) => {
|
||||
let titleCard: React.ReactNode;
|
||||
|
||||
switch (title.mediaType) {
|
||||
@@ -90,7 +90,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
return <li key={title.id}>{titleCard}</li>;
|
||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||
})}
|
||||
{isLoading &&
|
||||
!isReachingEnd &&
|
||||
|
||||
48
src/components/CompanyCard/index.tsx
Normal file
48
src/components/CompanyCard/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CompanyCardProps {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={url}>
|
||||
<a
|
||||
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||
} rounded-xl`}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 max-w-full max-h-full"
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyCard;
|
||||
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
genreMovies: '{genre} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieGenre: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<MovieResult, { genre: { id: number; name: string } }>(
|
||||
`/api/v1/discover/movies/genre/${router.query.genreId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.genreMovies, {
|
||||
genre: firstResultData?.genre.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieGenre;
|
||||
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
languageMovies: '{language} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieLanguage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<
|
||||
MovieResult,
|
||||
{
|
||||
originalLanguage: {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(`/api/v1/discover/movies/language/${router.query.language}`);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.languageMovies, {
|
||||
language: intl.formatDisplayName(router.query.language as string, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieLanguage;
|
||||
@@ -1,80 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Popular Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const DiscoverMovies: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discovermovies)} />
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovermovies} />
|
||||
</Header>
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
import { TvNetwork } from '../../../../server/models/common';
|
||||
|
||||
const messages = defineMessages({
|
||||
networkSeries: '{network} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvNetwork: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<TvResult, { network: TvNetwork }>(
|
||||
`/api/v1/discover/tv/network/${router.query.networkId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.networkSeries, {
|
||||
network: firstResultData?.network.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.network.logoPath ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||
alt={firstResultData.network.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvNetwork;
|
||||
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
import { ProductionCompany } from '../../../../server/models/common';
|
||||
|
||||
const messages = defineMessages({
|
||||
studioMovies: '{studio} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieStudio: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<MovieResult, { studio: ProductionCompany }>(
|
||||
`/api/v1/discover/movies/studio/${router.query.studioId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.studioMovies, {
|
||||
studio: firstResultData?.studio.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.studio.logoPath ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||
alt={firstResultData.studio.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieStudio;
|
||||
@@ -1,79 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Popular Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
const DiscoverTv: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as TvResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discovertv)} />
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovertv} />
|
||||
</Header>
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
genreSeries: '{genre} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvGenre: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<TvResult, { genre: { id: number; name: string } }>(
|
||||
`/api/v1/discover/tv/genre/${router.query.genreId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.genreSeries, {
|
||||
genre: firstResultData?.genre.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvGenre;
|
||||
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
languageSeries: '{language} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvLanguage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<
|
||||
TvResult,
|
||||
{
|
||||
originalLanguage: {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(`/api/v1/discover/tv/language/${router.query.language}`);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.languageSeries, {
|
||||
language: intl.formatDisplayName(router.query.language as string, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvLanguage;
|
||||
@@ -1,81 +1,38 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingtv: 'Upcoming Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
const DiscoverTvUpcoming: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/tv/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv/upcoming');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as TvResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingtv} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.upcomingtv)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
149
src/components/Discover/NetworkSlider/index.tsx
Normal file
149
src/components/Discover/NetworkSlider/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import CompanyCard from '../../CompanyCard';
|
||||
import Slider from '../../Slider';
|
||||
|
||||
const messages = defineMessages({
|
||||
networks: 'Networks',
|
||||
});
|
||||
|
||||
interface Network {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const networks: Network[] = [
|
||||
{
|
||||
name: 'Netflix',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
|
||||
url: '/discover/tv/network/213',
|
||||
},
|
||||
{
|
||||
name: 'Disney+',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
|
||||
url: '/discover/tv/network/2739',
|
||||
},
|
||||
{
|
||||
name: 'Prime Video',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
|
||||
url: '/discover/tv/network/1024',
|
||||
},
|
||||
{
|
||||
name: 'HBO',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
|
||||
url: '/discover/tv/network/49',
|
||||
},
|
||||
{
|
||||
name: 'ABC',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
|
||||
url: '/discover/tv/network/2',
|
||||
},
|
||||
{
|
||||
name: 'FOX',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
|
||||
url: '/discover/tv/network/19',
|
||||
},
|
||||
{
|
||||
name: 'Cinemax',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
|
||||
url: '/discover/tv/network/359',
|
||||
},
|
||||
{
|
||||
name: 'AMC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png',
|
||||
url: '/discover/tv/network/174',
|
||||
},
|
||||
{
|
||||
name: 'Showtime',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
|
||||
url: '/discover/tv/network/67',
|
||||
},
|
||||
{
|
||||
name: 'Starz',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
|
||||
url: '/discover/tv/network/318',
|
||||
},
|
||||
{
|
||||
name: 'The CW',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
|
||||
url: '/discover/tv/network/71',
|
||||
},
|
||||
{
|
||||
name: 'NBC',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
|
||||
url: '/discover/tv/network/6',
|
||||
},
|
||||
{
|
||||
name: 'CBS',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
|
||||
url: '/discover/tv/network/16',
|
||||
},
|
||||
{
|
||||
name: 'BBC One',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
|
||||
url: '/discover/tv/network/4',
|
||||
},
|
||||
{
|
||||
name: 'Cartoon Network',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
|
||||
url: '/discover/tv/network/56',
|
||||
},
|
||||
{
|
||||
name: 'Adult Swim',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
|
||||
url: '/discover/tv/network/80',
|
||||
},
|
||||
{
|
||||
name: 'Nickelodeon',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||
url: '/discover/tv/network/13',
|
||||
},
|
||||
];
|
||||
|
||||
const NetworkSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.networks)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="networks"
|
||||
isLoading={false}
|
||||
isEmpty={false}
|
||||
items={networks.map((network, index) => (
|
||||
<CompanyCard
|
||||
key={`network-${index}`}
|
||||
name={network.name}
|
||||
image={network.image}
|
||||
url={network.url}
|
||||
/>
|
||||
))}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkSlider;
|
||||
107
src/components/Discover/StudioSlider/index.tsx
Normal file
107
src/components/Discover/StudioSlider/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import CompanyCard from '../../CompanyCard';
|
||||
import Slider from '../../Slider';
|
||||
|
||||
const messages = defineMessages({
|
||||
studios: 'Studios',
|
||||
});
|
||||
|
||||
interface Studio {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const studios: Studio[] = [
|
||||
{
|
||||
name: 'Disney',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png',
|
||||
url: '/discover/movies/studio/2',
|
||||
},
|
||||
{
|
||||
name: '20th Century Fox',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
|
||||
url: '/discover/movies/studio/25',
|
||||
},
|
||||
{
|
||||
name: 'Sony Pictures',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png',
|
||||
url: '/discover/movies/studio/34',
|
||||
},
|
||||
{
|
||||
name: 'Warner Bros. Pictures',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png',
|
||||
url: '/discover/movies/studio/174',
|
||||
},
|
||||
{
|
||||
name: 'Universal',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
|
||||
url: '/discover/movies/studio/33',
|
||||
},
|
||||
{
|
||||
name: 'Paramount',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png',
|
||||
url: '/discover/movies/studio/4',
|
||||
},
|
||||
{
|
||||
name: 'Pixar',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
|
||||
url: '/discover/movies/studio/3',
|
||||
},
|
||||
{
|
||||
name: 'Dreamworks',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
|
||||
url: '/discover/movies/studio/521',
|
||||
},
|
||||
{
|
||||
name: 'Marvel Studios',
|
||||
image:
|
||||
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
|
||||
url: '/discover/movies/studio/420',
|
||||
},
|
||||
{
|
||||
name: 'DC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png',
|
||||
url: '/discover/movies/studio/9993',
|
||||
},
|
||||
];
|
||||
|
||||
const StudioSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.studios)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="studios"
|
||||
isLoading={false}
|
||||
isEmpty={false}
|
||||
items={studios.map((studio, index) => (
|
||||
<CompanyCard
|
||||
key={`studio-${index}`}
|
||||
name={studio.name}
|
||||
image={studio.image}
|
||||
url={studio.url}
|
||||
/>
|
||||
))}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioSlider;
|
||||
@@ -1,86 +1,43 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
trending: 'Trending',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (MovieResult | TvResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Trending: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/trending?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||
'/api/v1/discover/trending'
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.trending)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.trending} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.trending)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
@@ -1,81 +1,38 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingmovies: 'Upcoming Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const UpcomingMovies: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/movies/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies/upcoming');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingmovies} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.upcomingmovies)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
@@ -3,12 +3,14 @@ import useSWR from 'swr';
|
||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||
import Slider from '../Slider';
|
||||
import Link from 'next/link';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import RequestCard from '../RequestCard';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import StudioSlider from './StudioSlider';
|
||||
import NetworkSlider from './NetworkSlider';
|
||||
|
||||
const messages = defineMessages({
|
||||
discover: 'Discover',
|
||||
@@ -39,13 +41,9 @@ const Discover: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentlyAdded} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -60,30 +58,26 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/requests">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentrequests} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<Link href="/requests">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
@@ -116,6 +110,7 @@ const Discover: React.FC = () => {
|
||||
linkUrl="/discover/movies/upcoming"
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
/>
|
||||
<StudioSlider />
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(messages.populartv)}
|
||||
@@ -128,6 +123,7 @@ const Discover: React.FC = () => {
|
||||
url="/api/v1/discover/tv/upcoming"
|
||||
linkUrl="/discover/tv/upcoming"
|
||||
/>
|
||||
<NetworkSlider />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,7 +89,9 @@ const LanguagePicker: React.FC = () => {
|
||||
<div className="relative">
|
||||
<div>
|
||||
<button
|
||||
className="p-1 text-gray-400 rounded-full hover:bg-gray-600 hover:text-white focus:outline-none focus:ring focus:text-white"
|
||||
className={`p-1 rounded-full sm:p-2 hover:bg-gray-600 hover:text-white focus:outline-none focus:bg-gray-600 focus:ring-1 focus:ring-gray-500 focus:text-white ${
|
||||
isDropdownOpen ? 'bg-gray-600 text-white' : 'text-gray-400'
|
||||
}`}
|
||||
aria-label="Language Picker"
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ const SearchInput: React.FC = () => {
|
||||
<input
|
||||
id="search_field"
|
||||
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
|
||||
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
type="search"
|
||||
value={searchValue}
|
||||
|
||||
@@ -31,13 +31,17 @@ const UserDropdown: React.FC = () => {
|
||||
<div className="relative ml-3">
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center max-w-xs text-sm rounded-full focus:outline-none focus:ring"
|
||||
className="flex items-center max-w-xs text-sm rounded-full ring-1 ring-gray-700 focus:outline-none focus:ring-gray-500 hover:ring-gray-500"
|
||||
id="user-menu"
|
||||
aria-label="User menu"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
>
|
||||
<img className="w-8 h-8 rounded-full" src={user?.avatar} alt="" />
|
||||
<img
|
||||
className="w-8 h-8 rounded-full sm:w-10 sm:h-10"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Transition
|
||||
|
||||
@@ -72,7 +72,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
</button>
|
||||
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
||||
<SearchInput />
|
||||
<div className="flex items-center ml-2 md:ml-4">
|
||||
<div className="flex items-center ml-2">
|
||||
<LanguagePicker />
|
||||
<UserDropdown />
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
@@ -79,7 +79,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
|
||||
@@ -32,8 +32,10 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
||||
>
|
||||
<div
|
||||
className={`relative w-36 sm:w-36 md:w-44
|
||||
rounded-lg text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
||||
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
|
||||
rounded-xl text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-600 ring-gray-500 scale-105'
|
||||
: 'bg-gray-800 ring-gray-700 scale-100'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
|
||||
@@ -135,34 +135,32 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{linkUrl ? (
|
||||
<Link href={linkUrl}>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{title}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<div className="slider-header">
|
||||
{linkUrl ? (
|
||||
<Link href={linkUrl}>
|
||||
<a className="slider-title">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="slider-title">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey={sliderKey}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR, { useSWRInfinite } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -7,75 +7,38 @@ import Header from '../Common/Header';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
recommendationssubtext: 'If you liked {title}, you might also like…',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const MovieRecommendations: React.FC = () => {
|
||||
const settings = useSettings();
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/movie/${router.query.movieId}/recommendations?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>(
|
||||
`/api/v1/movie/${router.query.movieId}/recommendations`
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR, { useSWRInfinite } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -7,75 +7,36 @@ import Header from '../Common/Header';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
similar: 'Similar Titles',
|
||||
similarsubtext: 'Other movies similar to {title}',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const MovieSimilar: React.FC = () => {
|
||||
const settings = useSettings();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/movie/${router.query.movieId}/similar?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>(`/api/v1/movie/${router.query.movieId}/similar`);
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
|
||||
@@ -60,10 +60,11 @@ const messages = defineMessages({
|
||||
manageModalNoRequests: 'No Requests',
|
||||
manageModalClearMedia: 'Clear All Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.',
|
||||
'This will irreversibly remove all data for this movie, including any requests.\
|
||||
If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
approve: 'Approve',
|
||||
decline: 'Decline',
|
||||
studio: 'Studio',
|
||||
studio: '{studioCount, plural, one {Studio} other {Studios}}',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
view: 'View',
|
||||
areyousure: 'Are you sure?',
|
||||
@@ -187,7 +188,19 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
}
|
||||
|
||||
if (data.genres.length) {
|
||||
movieAttributes.push(data.genres.map((g) => g.name).join(', '));
|
||||
movieAttributes.push(
|
||||
data.genres
|
||||
.map((g) => (
|
||||
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
|
||||
<a className="hover:underline">{g.name}</a>
|
||||
</Link>
|
||||
))
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev}, {curr}
|
||||
</>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -638,29 +651,46 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.spokenLanguages.some(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
) && (
|
||||
{data.originalLanguage && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.originallanguage)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name
|
||||
}
|
||||
<Link
|
||||
href={`/discover/movies/language/${data.originalLanguage}`}
|
||||
>
|
||||
<a className="hover:underline">
|
||||
{intl.formatDisplayName(data.originalLanguage, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ??
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCompanies[0] && (
|
||||
{data.productionCompanies.length > 0 && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.studio)}
|
||||
{intl.formatMessage(messages.studio, {
|
||||
studioCount: data.productionCompanies.length,
|
||||
})}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{data.productionCompanies[0]?.name}
|
||||
{data.productionCompanies.map((s) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/discover/movies/studio/${s.id}`}
|
||||
key={`studio-${s.id}`}
|
||||
>
|
||||
<a className="block hover:underline">{s.name}</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,16 +8,19 @@ const messages = defineMessages({
|
||||
'Sends a notification when media is requested and requires approval.',
|
||||
mediaapproved: 'Media Approved',
|
||||
mediaapprovedDescription:
|
||||
'Sends a notification when media is approved.\
|
||||
By default, automatically approved requests will not trigger notifications.',
|
||||
'Sends a notification when requested media is manually approved.',
|
||||
mediaAutoApproved: 'Media Automatically Approved',
|
||||
mediaAutoApprovedDescription:
|
||||
'Sends a notification when requested media is automatically approved.',
|
||||
mediaavailable: 'Media Available',
|
||||
mediaavailableDescription:
|
||||
'Sends a notification when media becomes available.',
|
||||
'Sends a notification when requested media becomes available.',
|
||||
mediafailed: 'Media Failed',
|
||||
mediafailedDescription:
|
||||
'Sends a notification when media fails to be added to Radarr or Sonarr.',
|
||||
'Sends a notification when requested media fails to be added to Radarr or Sonarr.',
|
||||
mediadeclined: 'Media Declined',
|
||||
mediadeclinedDescription: 'Sends a notification when a request is declined.',
|
||||
mediadeclinedDescription:
|
||||
'Sends a notification when a media request is declined.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -46,6 +49,7 @@ export enum Notification {
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
@@ -74,6 +78,12 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
description: intl.formatMessage(messages.mediarequestedDescription),
|
||||
value: Notification.MEDIA_PENDING,
|
||||
},
|
||||
{
|
||||
id: 'media-auto-approved',
|
||||
name: intl.formatMessage(messages.mediaAutoApproved),
|
||||
description: intl.formatMessage(messages.mediaAutoApprovedDescription),
|
||||
value: Notification.MEDIA_AUTO_APPROVED,
|
||||
},
|
||||
{
|
||||
id: 'media-approved',
|
||||
name: intl.formatMessage(messages.mediaapproved),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import { Permission, User } from '../../hooks/useUser';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
|
||||
export interface PermissionItem {
|
||||
id: string;
|
||||
@@ -33,6 +34,8 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
onUpdate,
|
||||
parent,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
|
||||
const autoApprovePermissions = [
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MOVIE,
|
||||
@@ -42,34 +45,70 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
Permission.AUTO_APPROVE_4K_TV,
|
||||
];
|
||||
|
||||
let disabled = false;
|
||||
let checked = hasPermission(option.permission, currentPermission);
|
||||
|
||||
if (
|
||||
// Permissions for user ID 1 (Plex server owner) cannot be changed
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
// Admin permission automatically bypasses/grants all other permissions
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
// Manage Requests permission automatically grants all Auto-Approve permissions
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
// Selecting a parent permission automatically selects all children
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission))
|
||||
) {
|
||||
disabled = true;
|
||||
checked = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// Non-Admin users cannot modify the Admin permission
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
// Users without the Manage Settings permission cannot modify/grant that permission
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// Some permissions are dependent on others; check requirements are fulfilled
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
)) ||
|
||||
// Request 4K and Auto-Approve 4K require both 4K movie & 4K series requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K) &&
|
||||
(!settings.currentSettings.movie4kEnabled ||
|
||||
!settings.currentSettings.series4kEnabled)) ||
|
||||
// Request 4K Movie and Auto-Approve 4K Movie require 4K movie requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K_MOVIE ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K_MOVIE) &&
|
||||
!settings.currentSettings.movie4kEnabled) ||
|
||||
// Request 4K Series and Auto-Approve 4K Series require 4K series requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K_TV ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K_TV) &&
|
||||
!settings.currentSettings.series4kEnabled)
|
||||
) {
|
||||
disabled = true;
|
||||
checked = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(actingUser &&
|
||||
!hasPermission(
|
||||
Permission.MANAGE_SETTINGS,
|
||||
actingUser.permissions
|
||||
) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS) ||
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-6">
|
||||
@@ -77,30 +116,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
id={option.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
disabled={
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(actingUser &&
|
||||
!hasPermission(
|
||||
Permission.MANAGE_SETTINGS,
|
||||
actingUser.permissions
|
||||
) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS) ||
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onUpdate(
|
||||
hasPermission(option.permission, currentPermission)
|
||||
@@ -108,22 +124,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
: currentPermission + option.permission
|
||||
);
|
||||
}}
|
||||
checked={
|
||||
(hasPermission(option.permission, currentPermission) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
currentPermission
|
||||
))) &&
|
||||
(!option.requires ||
|
||||
option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
|
||||
@@ -37,8 +37,10 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
||||
isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100'
|
||||
} rounded-xl text-white shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
@@ -47,7 +49,7 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
{profilePath ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
|
||||
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full ring-1 ring-gray-700"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
@@ -79,7 +81,11 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
{subName}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-700" />
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { countryCodeEmoji } from 'country-code-emoji';
|
||||
import useSWR from 'swr';
|
||||
import type { Region } from '../../../server/lib/settings';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { hasFlag } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
regionDefault: 'All Regions',
|
||||
regionServerDefault: 'Default ({region})',
|
||||
});
|
||||
|
||||
interface RegionSelectorProps {
|
||||
value: string;
|
||||
name: string;
|
||||
isUserSetting?: boolean;
|
||||
onChange?: (fieldName: string, region: string) => void;
|
||||
}
|
||||
|
||||
const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
name,
|
||||
value,
|
||||
isUserSetting = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const { currentSettings } = useSettings();
|
||||
const intl = useIntl();
|
||||
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
|
||||
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
||||
|
||||
const allRegion: Region = useMemo(
|
||||
() => ({
|
||||
iso_3166_1: 'all',
|
||||
english_name: 'All',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const defaultRegionNameFallback =
|
||||
regions?.find((region) => region.iso_3166_1 === currentSettings.region)
|
||||
?.english_name ?? currentSettings.region;
|
||||
|
||||
useEffect(() => {
|
||||
if (regions && value) {
|
||||
const matchedRegion = regions.find(
|
||||
(region) => region.iso_3166_1 === value
|
||||
);
|
||||
setSelectedRegion(matchedRegion ?? null);
|
||||
if (value === 'all') {
|
||||
setSelectedRegion(allRegion);
|
||||
} else {
|
||||
const matchedRegion = regions.find(
|
||||
(region) => region.iso_3166_1 === value
|
||||
);
|
||||
setSelectedRegion(matchedRegion ?? null);
|
||||
}
|
||||
}
|
||||
}, [value, regions]);
|
||||
}, [value, regions, allRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && regions) {
|
||||
@@ -47,15 +69,35 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
<div className="relative">
|
||||
<span className="inline-block w-full rounded-md shadow-sm">
|
||||
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
|
||||
{selectedRegion && (
|
||||
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
|
||||
{countryCodeEmoji(selectedRegion.iso_3166_1)}
|
||||
{((selectedRegion && hasFlag(selectedRegion?.iso_3166_1)) ||
|
||||
(isUserSetting &&
|
||||
!selectedRegion &&
|
||||
currentSettings.region &&
|
||||
hasFlag(currentSettings.region))) && (
|
||||
<span className="h-4 mr-2 overflow-hidden text-base leading-4">
|
||||
<span
|
||||
className={`flag:${
|
||||
selectedRegion
|
||||
? selectedRegion.iso_3166_1
|
||||
: currentSettings.region
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="block truncate">
|
||||
{selectedRegion
|
||||
{selectedRegion && selectedRegion.iso_3166_1 !== 'all'
|
||||
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? selectedRegion.english_name
|
||||
: isUserSetting && selectedRegion?.iso_3166_1 !== 'all'
|
||||
? intl.formatMessage(messages.regionServerDefault, {
|
||||
region: currentSettings.region
|
||||
? intl.formatDisplayName(currentSettings.region, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? defaultRegionNameFallback
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})
|
||||
: intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
@@ -89,7 +131,67 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
static
|
||||
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
|
||||
>
|
||||
<Listbox.Option value={null}>
|
||||
{isUserSetting && (
|
||||
<Listbox.Option value={null}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
active
|
||||
? 'text-white bg-indigo-600'
|
||||
: 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
|
||||
>
|
||||
<span className="mr-2 text-base">
|
||||
<span
|
||||
className={
|
||||
hasFlag(currentSettings.region)
|
||||
? `flag:${currentSettings.region}`
|
||||
: 'pr-6'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionServerDefault, {
|
||||
region: currentSettings.region
|
||||
? intl.formatDisplayName(
|
||||
currentSettings.region,
|
||||
{
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}
|
||||
) ?? defaultRegionNameFallback
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)}
|
||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
@@ -99,7 +201,7 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
} block truncate pl-8`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
@@ -136,8 +238,14 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
: 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
|
||||
>
|
||||
<span className="mr-2 text-lg">
|
||||
{countryCodeEmoji(region.iso_3166_1)}
|
||||
<span className="mr-2 text-base">
|
||||
<span
|
||||
className={
|
||||
hasFlag(region.iso_3166_1)
|
||||
? `flag:${region.iso_3166_1}`
|
||||
: 'pr-6'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
|
||||
@@ -10,7 +10,7 @@ import RequestModal from '../RequestModal';
|
||||
import useRequestOverride from '../../hooks/useRequestOverride';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
requestoverrides: 'Request Overrides',
|
||||
server: 'Server',
|
||||
profilechanged: 'Profile Changed',
|
||||
@@ -65,12 +65,12 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="px-4 py-4">
|
||||
<div className="px-4 py-4 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
|
||||
<div className="flex mb-1 flex-nowrap white">
|
||||
<svg
|
||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -88,7 +88,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
{request.modifiedBy && (
|
||||
<div className="flex flex-nowrap">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -191,7 +191,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="sm:flex">
|
||||
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
|
||||
<div className="flex items-center mr-6 text-sm leading-5">
|
||||
{request.is4k && (
|
||||
<span className="mr-1">
|
||||
<Badge badgeType="warning">4K</Badge>
|
||||
@@ -214,9 +214,9 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
|
||||
<div className="flex items-center mt-2 text-sm leading-5 sm:mt-0">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@@ -234,7 +234,11 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
{(request.seasons ?? []).length > 0 && (
|
||||
<div className="flex flex-col mt-2 text-sm">
|
||||
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
|
||||
<div className="mb-1 font-medium">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount: request.seasons.length,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{request.seasons.map((season) => (
|
||||
<span
|
||||
|
||||
@@ -5,7 +5,10 @@ import type { TvDetails } from '../../../server/models/Tv';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import useSWR from 'swr';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import Badge from '../Common/Badge';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import axios from 'axios';
|
||||
@@ -17,7 +20,8 @@ import globalMessages from '../../i18n/globalMessages';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
status: 'Status',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
all: 'All',
|
||||
});
|
||||
|
||||
@@ -27,7 +31,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
|
||||
const RequestCardPlaceholder: React.FC = () => {
|
||||
return (
|
||||
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
|
||||
<div className="relative p-4 bg-gray-700 rounded-xl w-72 sm:w-96 animate-pulse">
|
||||
<div className="w-20 sm:w-28">
|
||||
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
||||
</div>
|
||||
@@ -94,60 +98,49 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
|
||||
className="relative flex p-4 text-gray-400 bg-gray-700 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
|
||||
backgroundImage: `linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 pr-4">
|
||||
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
||||
<Link
|
||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
||||
as={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="pb-0.5 sm:pb-1 overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
|
||||
/>
|
||||
<span className="text-xs truncate sm:text-sm group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
{requestData.media.status && (
|
||||
<div className="mt-1 sm:mt-2">
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.is4k
|
||||
? requestData.media.status4k
|
||||
: requestData.media.status
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{request.seasons.length > 0 && (
|
||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
||||
{!isMovie(title) &&
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
<div className="card-field">
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm"
|
||||
/>
|
||||
<span className="truncate group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{!isMovie(title) && request.seasons.length > 0 && (
|
||||
<div className="sm:flex items-center my-0.5 sm:my-1 text-sm hidden">
|
||||
<span className="mr-2 font-medium">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||
@@ -163,6 +156,34 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center mt-2 text-sm sm:mt-1">
|
||||
<span className="hidden mr-2 font-medium sm:block">
|
||||
{intl.formatMessage(messages.status)}
|
||||
</span>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="flex items-end flex-1">
|
||||
@@ -215,15 +236,14 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 sm:w-28">
|
||||
<Link
|
||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
||||
as={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="flex-shrink-0 w-20 sm:w-28">
|
||||
<img
|
||||
src={
|
||||
title.posterPath
|
||||
@@ -233,8 +253,8 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
alt=""
|
||||
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
||||
import {
|
||||
useIntl,
|
||||
FormattedDate,
|
||||
FormattedRelativeTime,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
import { useIntl, FormattedRelativeTime, defineMessages } from 'react-intl';
|
||||
import { useUser, Permission } from '../../../hooks/useUser';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import type { MovieDetails } from '../../../../server/models/Movie';
|
||||
@@ -14,7 +9,6 @@ import type { TvDetails } from '../../../../server/models/Tv';
|
||||
import useSWR from 'swr';
|
||||
import Badge from '../../Common/Badge';
|
||||
import StatusBadge from '../../StatusBadge';
|
||||
import Table from '../../Common/Table';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -25,11 +19,18 @@ import globalMessages from '../../../i18n/globalMessages';
|
||||
import Link from 'next/link';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import RequestModal from '../../RequestModal';
|
||||
import ConfirmButton from '../../Common/ConfirmButton';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
all: 'All',
|
||||
notavailable: 'N/A',
|
||||
failedretry: 'Something went wrong while retrying the request.',
|
||||
areyousure: 'Are you sure?',
|
||||
status: 'Status',
|
||||
requested: 'Requested',
|
||||
modified: 'Modified',
|
||||
modifieduserdate: '{date} by {user}',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -101,22 +102,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
<tr className="w-full h-24 animate-pulse" ref={ref}>
|
||||
<td colSpan={6}></td>
|
||||
</tr>
|
||||
<div
|
||||
className="w-full h-64 bg-gray-800 rounded-xl lg:h-32 animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title || !requestData) {
|
||||
return (
|
||||
<tr className="w-full h-24 animate-pulse">
|
||||
<td colSpan={6}></td>
|
||||
</tr>
|
||||
<div
|
||||
className="w-full h-64 bg-gray-800 rounded-xl lg:h-32 animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="relative w-full h-24 p-2">
|
||||
<>
|
||||
<RequestModal
|
||||
show={showEditModal}
|
||||
tmdbId={request.media.tmdbId}
|
||||
@@ -129,28 +132,17 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<Table.TD>
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="flex-shrink-0 hidden mr-4 sm:block">
|
||||
<img
|
||||
src={
|
||||
title.posterPath
|
||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex-shrink overflow-hidden">
|
||||
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl lg:h-32 lg:flex-row">
|
||||
<div
|
||||
className="absolute inset-0 z-0 w-full bg-center bg-cover lg:w-2/3"
|
||||
style={{
|
||||
backgroundImage: title.backdropPath
|
||||
? `linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
|
||||
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden lg:w-1/2 xl:w-7/12 2xl:w-2/3 sm:pr-0">
|
||||
<Link
|
||||
href={
|
||||
requestData.type === 'movie'
|
||||
@@ -158,219 +150,282 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center mt-1">
|
||||
<a className="flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md lg:w-14 transform-gpu hover:scale-105">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
src={
|
||||
title.posterPath
|
||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-5 mr-2 rounded-full"
|
||||
className="object-cover"
|
||||
/>
|
||||
<span className="text-sm hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
{requestData.seasons.length > 0 && (
|
||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.seasons)}
|
||||
</span>
|
||||
{requestData.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex flex-col justify-center pl-2 overflow-hidden lg:pl-4">
|
||||
<div className="card-field">
|
||||
<Link
|
||||
href={
|
||||
requestData.type === 'movie'
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-lg text-white truncate lg:text-xl hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-field">
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300 truncate group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{!isMovie(title) && request.seasons.length > 0 && (
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
title.seasons.filter(
|
||||
(season) => season.seasonNumber !== 0
|
||||
).length === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex overflow-x-scroll hide-scrollbar flex-nowrap">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-between w-full pr-4 mt-4 ml-4 text-sm sm:ml-2 sm:mt-0 lg:flex-1 lg:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.status)}
|
||||
</span>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
plexUrl={requestData.media.plexUrl}
|
||||
plexUrl4k={requestData.media.plexUrl4k}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.requested)}
|
||||
</span>
|
||||
<span className="text-gray-300">
|
||||
{intl.formatDate(requestData.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.modified)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{requestData.modifiedBy ? (
|
||||
<span className="flex text-sm text-gray-300">
|
||||
{intl.formatMessage(messages.modifieduserdate, {
|
||||
date: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.updatedAt).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
),
|
||||
user: (
|
||||
<Link href={`/users/${requestData.modifiedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
{requestData.modifiedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-300">N/A</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
/>
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-300">
|
||||
<FormattedDate value={requestData.createdAt} />
|
||||
</span>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<div className="flex flex-col">
|
||||
{requestData.modifiedBy ? (
|
||||
<span className="text-sm text-gray-300">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="w-5 mr-2 rounded-full"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{requestData.modifiedBy.displayName} (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
)
|
||||
<div className="z-10 flex flex-col justify-between w-full pl-4 pr-4 mt-4 space-y-2 lg:mt-0 lg:items-end lg:justify-around lg:w-96 lg:pl-0">
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="primary"
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="18px"
|
||||
height="18px"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
||||
</svg>
|
||||
<span className="block">
|
||||
{intl.formatMessage(globalMessages.retry)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-300">N/A</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(messages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="block">
|
||||
{intl.formatMessage(globalMessages.delete)}
|
||||
</span>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<div className="flex flex-row w-full space-x-2">
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="block">
|
||||
{intl.formatMessage(globalMessages.approve)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="danger"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="block">
|
||||
{intl.formatMessage(globalMessages.decline)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="primary"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
<span className="block">
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
className="mr-2"
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="18px"
|
||||
height="18px"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.retry)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => deleteRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.delete)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="success"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.approve)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.decline)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import RequestItem from './RequestItem';
|
||||
import Header from '../Common/Header';
|
||||
import Table from '../Common/Table';
|
||||
import Button from '../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const messages = defineMessages({
|
||||
requests: 'Requests',
|
||||
mediaInfo: 'Media Info',
|
||||
status: 'Status',
|
||||
requestedAt: 'Requested At',
|
||||
modifiedBy: 'Last Modified By',
|
||||
showingresults:
|
||||
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
|
||||
resultsperpage: 'Display {pageSize} results per page',
|
||||
@@ -35,17 +31,46 @@ type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available';
|
||||
type Sort = 'added' | 'modified';
|
||||
|
||||
const RequestList: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
|
||||
const [currentSort, setCurrentSort] = useState<Sort>('added');
|
||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
const pageIndex = page - 1;
|
||||
|
||||
const { data, error, revalidate } = useSWR<RequestResultsResponse>(
|
||||
`/api/v1/request?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}&sort=${currentSort}`
|
||||
);
|
||||
|
||||
// Restore last set filter values on component mount
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('rl-filter-settings');
|
||||
|
||||
if (filterString) {
|
||||
const filterSettings = JSON.parse(filterString);
|
||||
|
||||
setCurrentFilter(filterSettings.currentFilter);
|
||||
setCurrentSort(filterSettings.currentSort);
|
||||
setCurrentPageSize(filterSettings.currentPageSize);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set fitler values to local storage any time they are changed
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'rl-filter-settings',
|
||||
JSON.stringify({
|
||||
currentFilter,
|
||||
currentSort,
|
||||
currentPageSize,
|
||||
})
|
||||
);
|
||||
}, [currentFilter, currentSort, currentPageSize]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -60,7 +85,7 @@ const RequestList: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.requests)} />
|
||||
<div className="flex flex-col justify-between lg:items-end lg:flex-row">
|
||||
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||
<Header>{intl.formatMessage(messages.requests)}</Header>
|
||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
@@ -82,8 +107,8 @@ const RequestList: React.FC = () => {
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
@@ -120,12 +145,8 @@ const RequestList: React.FC = () => {
|
||||
id="sort"
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentSort}
|
||||
className="rounded-r-only"
|
||||
@@ -140,114 +161,109 @@ const RequestList: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
|
||||
<Table.TH></Table.TH>
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{data.results.map((request) => {
|
||||
return (
|
||||
<RequestItem
|
||||
request={request}
|
||||
key={`request-list-${request.id}`}
|
||||
revalidateList={() => revalidate()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{data.results.map((request) => {
|
||||
return (
|
||||
<div className="py-2" key={`request-list-${request.id}`}>
|
||||
<RequestItem
|
||||
request={request}
|
||||
revalidateList={() => revalidate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.results.length === 0 && (
|
||||
<tr className="relative h-24 p-2 text-white">
|
||||
<Table.TD colSpan={6} noPadding>
|
||||
<div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
|
||||
<span className="text-base">
|
||||
{intl.formatMessage(messages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== 'all' && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter('all')}
|
||||
>
|
||||
{intl.formatMessage(messages.showallrequests)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="bg-gray-700">
|
||||
<Table.TD colSpan={6} noPadding>
|
||||
<nav
|
||||
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
|
||||
aria-label="Pagination"
|
||||
{data.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
|
||||
<span className="text-2xl text-gray-400">
|
||||
{intl.formatMessage(messages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== 'all' && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter('all')}
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(messages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
|
||||
{intl.formatMessage(messages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => setPageIndex((current) => current - 1)}
|
||||
{intl.formatMessage(messages.showallrequests)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<nav
|
||||
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(messages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
|
||||
{intl.formatMessage(messages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push(router.pathname)
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
{intl.formatMessage(messages.previous)}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => setPageIndex((current) => current + 1)}
|
||||
>
|
||||
{intl.formatMessage(messages.next)}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() =>
|
||||
router
|
||||
.push(`${router.pathname}?page=${page - 1}`, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0))
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.previous)}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() =>
|
||||
router
|
||||
.push(`${router.pathname}?page=${page + 1}`, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0))
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.next)}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
|
||||
@@ -119,7 +119,7 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
@@ -141,7 +141,7 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.confirmpassword)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
|
||||
@@ -1,71 +1,46 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
TvResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import Error from '../../pages/_error';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: 'Search',
|
||||
searchresults: 'Search Results',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (MovieResult | TvResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Search: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/search/?query=${router.query.query}&page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||
`/api/v1/search`,
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
query: router.query.query,
|
||||
},
|
||||
{ hideAvailable: false }
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={error.code} />;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.search)} />
|
||||
|
||||
@@ -13,6 +13,8 @@ const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
botUsername: 'Bot Username',
|
||||
botAvatarUrl: 'Bot Avatar URL',
|
||||
webhookUrl: 'Webhook URL',
|
||||
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
|
||||
discordsettingssaved: 'Discord notification settings saved successfully!',
|
||||
@@ -20,7 +22,7 @@ const messages = defineMessages({
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
notificationtypes: 'Notification Types',
|
||||
validationWebhookUrl: 'You must provide a valid URL',
|
||||
validationUrl: 'You must provide a valid URL',
|
||||
});
|
||||
|
||||
const NotificationsDiscord: React.FC = () => {
|
||||
@@ -31,9 +33,12 @@ const NotificationsDiscord: React.FC = () => {
|
||||
);
|
||||
|
||||
const NotificationsDiscordSchema = Yup.object().shape({
|
||||
botAvatarUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationUrl)),
|
||||
webhookUrl: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl))
|
||||
.url(intl.formatMessage(messages.validationWebhookUrl)),
|
||||
.required(intl.formatMessage(messages.validationUrl))
|
||||
.url(intl.formatMessage(messages.validationUrl)),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -45,6 +50,8 @@ const NotificationsDiscord: React.FC = () => {
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
botUsername: data?.options.botUsername,
|
||||
botAvatarUrl: data?.options.botAvatarUrl,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
}}
|
||||
validationSchema={NotificationsDiscordSchema}
|
||||
@@ -54,6 +61,8 @@ const NotificationsDiscord: React.FC = () => {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
@@ -77,6 +86,8 @@ const NotificationsDiscord: React.FC = () => {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
@@ -97,12 +108,48 @@ const NotificationsDiscord: React.FC = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botUsername" className="text-label">
|
||||
{intl.formatMessage(messages.botUsername)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="botUsername"
|
||||
name="botUsername"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.botUsername)}
|
||||
/>
|
||||
</div>
|
||||
{errors.botUsername && touched.botUsername && (
|
||||
<div className="error">{errors.botUsername}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botAvatarUrl" className="text-label">
|
||||
{intl.formatMessage(messages.botAvatarUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="botAvatarUrl"
|
||||
name="botAvatarUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.botAvatarUrl)}
|
||||
/>
|
||||
</div>
|
||||
{errors.botAvatarUrl && touched.botAvatarUrl && (
|
||||
<div className="error">{errors.botAvatarUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
|
||||
@@ -32,10 +32,13 @@ const messages = defineMessages({
|
||||
senderName: 'Sender Name',
|
||||
notificationtypes: 'Notification Types',
|
||||
validationEmail: 'You must provide a valid email address',
|
||||
emailNotificationTypesAlert: 'Notification Email Recipients',
|
||||
emailNotificationTypesAlert: 'Email Notification Recipients',
|
||||
emailNotificationTypesAlertDescription:
|
||||
'For the "Media Requested" and "Media Failed" notification types,\
|
||||
notifications will only be sent to users with the "Manage Requests" permission.',
|
||||
'<strong>Media Requested</strong>, <strong>Media Automatically Approved</strong>, and <strong>Media Failed</strong>\
|
||||
email notifications are sent to all users with the <strong>Manage Requests</strong> permission.',
|
||||
emailNotificationTypesAlertDescriptionPt2:
|
||||
'<strong>Media Approved</strong>, <strong>Media Declined</strong>, and <strong>Media Available</strong>\
|
||||
email notifications are sent to the user who submitted the request.',
|
||||
});
|
||||
|
||||
const NotificationsEmail: React.FC = () => {
|
||||
@@ -134,9 +137,34 @@ const NotificationsEmail: React.FC = () => {
|
||||
title={intl.formatMessage(messages.emailNotificationTypesAlert)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
messages.emailNotificationTypesAlertDescription
|
||||
)}
|
||||
<p className="mb-2">
|
||||
{intl.formatMessage(
|
||||
messages.emailNotificationTypesAlertDescription,
|
||||
{
|
||||
strong: function strong(msg) {
|
||||
return (
|
||||
<strong className="font-normal text-indigo-100">
|
||||
{msg}
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
messages.emailNotificationTypesAlertDescriptionPt2,
|
||||
{
|
||||
strong: function strong(msg) {
|
||||
return (
|
||||
<strong className="font-normal text-indigo-100">
|
||||
{msg}
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
@@ -152,7 +180,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.emailsender)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="emailFrom"
|
||||
name="emailFrom"
|
||||
@@ -170,7 +198,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.senderName)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="senderName"
|
||||
name="senderName"
|
||||
@@ -185,7 +213,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.smtpHost)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="smtpHost"
|
||||
name="smtpHost"
|
||||
@@ -203,15 +231,13 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.smtpPort)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<Field
|
||||
id="smtpPort"
|
||||
name="smtpPort"
|
||||
type="text"
|
||||
placeholder="465"
|
||||
className="short"
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
id="smtpPort"
|
||||
name="smtpPort"
|
||||
type="text"
|
||||
placeholder="465"
|
||||
className="short"
|
||||
/>
|
||||
{errors.smtpPort && touched.smtpPort && (
|
||||
<div className="error">{errors.smtpPort}</div>
|
||||
)}
|
||||
@@ -245,7 +271,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.authUser)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field id="authUser" name="authUser" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +281,7 @@ const NotificationsEmail: React.FC = () => {
|
||||
{intl.formatMessage(messages.authPass)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="authPass"
|
||||
name="authPass"
|
||||
|
||||
@@ -126,7 +126,7 @@ const NotificationsPushbullet: React.FC = () => {
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
|
||||
@@ -24,9 +24,9 @@ const messages = defineMessages({
|
||||
test: 'Test',
|
||||
settinguppushover: 'Setting Up Pushover Notifications',
|
||||
settinguppushoverDescription:
|
||||
'To configure Pushover notifications, you will need to <RegisterApplicationLink>register an application</RegisterApplicationLink> and enter the API key below.\
|
||||
'To configure Pushover notifications, you will need to <RegisterApplicationLink>register an application</RegisterApplicationLink> and enter the API token below.\
|
||||
(You can use one of our <IconLink>official icons on GitHub</IconLink>.)\
|
||||
You will need also need your user key.',
|
||||
You will also need your user key.',
|
||||
notificationtypes: 'Notification Types',
|
||||
});
|
||||
|
||||
@@ -153,7 +153,7 @@ const NotificationsPushover: React.FC = () => {
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
@@ -171,7 +171,7 @@ const NotificationsPushover: React.FC = () => {
|
||||
{intl.formatMessage(messages.userToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="userToken"
|
||||
name="userToken"
|
||||
|
||||
@@ -129,7 +129,7 @@ const NotificationsSlack: React.FC = () => {
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field id="webhookUrl" name="webhookUrl" type="text" />
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
|
||||
@@ -14,10 +14,11 @@ const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
botUsername: 'Bot Username',
|
||||
botAPI: 'Bot Authentication Token',
|
||||
chatId: 'Chat ID',
|
||||
validationBotAPIRequired: 'You must provide a bot authentication token',
|
||||
validationChatIdRequired: 'You must provide a valid chat ID',
|
||||
validationChatIdRequired: 'You must provide a chat ID',
|
||||
telegramsettingssaved: 'Telegram notification settings saved successfully!',
|
||||
telegramsettingsfailed: 'Telegram notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
@@ -26,7 +27,7 @@ const messages = defineMessages({
|
||||
settinguptelegramDescription:
|
||||
'To configure Telegram notifications, you will need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key.\
|
||||
Additionally, you will need the chat ID for the chat to which you would like to send notifications.\
|
||||
You can get this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat.',
|
||||
You can find this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat and issuing the <code>/my_id</code> command.',
|
||||
notificationtypes: 'Notification Types',
|
||||
sendSilently: 'Send Silently',
|
||||
sendSilentlyTip: 'Send notifications with no sound',
|
||||
@@ -45,7 +46,10 @@ const NotificationsTelegram: React.FC = () => {
|
||||
),
|
||||
chatId: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationChatIdRequired))
|
||||
.matches(/^\d+$/, intl.formatMessage(messages.validationChatIdRequired)),
|
||||
.matches(
|
||||
/^[-]?\d+$/,
|
||||
intl.formatMessage(messages.validationChatIdRequired)
|
||||
),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -57,6 +61,7 @@ const NotificationsTelegram: React.FC = () => {
|
||||
initialValues={{
|
||||
enabled: data?.enabled,
|
||||
types: data?.types,
|
||||
botUsername: data?.options.botUsername,
|
||||
botAPI: data?.options.botAPI,
|
||||
chatId: data?.options.chatId,
|
||||
sendSilently: data?.options.sendSilently,
|
||||
@@ -71,6 +76,7 @@ const NotificationsTelegram: React.FC = () => {
|
||||
botAPI: values.botAPI,
|
||||
chatId: values.chatId,
|
||||
sendSilently: values.sendSilently,
|
||||
botUsername: values.botUsername,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.telegramsettingssaved), {
|
||||
@@ -96,6 +102,7 @@ const NotificationsTelegram: React.FC = () => {
|
||||
botAPI: values.botAPI,
|
||||
chatId: values.chatId,
|
||||
sendSilently: values.sendSilently,
|
||||
botUsername: values.botUsername,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -136,6 +143,9 @@ const NotificationsTelegram: React.FC = () => {
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
@@ -147,12 +157,30 @@ const NotificationsTelegram: React.FC = () => {
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botUsername" className="text-label">
|
||||
{intl.formatMessage(messages.botUsername)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="botUsername"
|
||||
name="botUsername"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.botUsername)}
|
||||
/>
|
||||
</div>
|
||||
{errors.botUsername && touched.botUsername && (
|
||||
<div className="error">{errors.botUsername}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botAPI" className="text-label">
|
||||
{intl.formatMessage(messages.botAPI)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="botAPI"
|
||||
name="botAPI"
|
||||
@@ -170,7 +198,7 @@ const NotificationsTelegram: React.FC = () => {
|
||||
{intl.formatMessage(messages.chatId)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="chatId"
|
||||
name="chatId"
|
||||
|
||||
@@ -175,7 +175,7 @@ const NotificationsWebhook: React.FC = () => {
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field id="webhookUrl" name="webhookUrl" type="text" />
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
@@ -188,7 +188,7 @@ const NotificationsWebhook: React.FC = () => {
|
||||
{intl.formatMessage(messages.authheader)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field id="authHeader" name="authHeader" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ const NotificationsWebhook: React.FC = () => {
|
||||
{intl.formatMessage(messages.customJson)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<JSONEditor
|
||||
name="webhook-json-payload"
|
||||
onUpdate={(value) => setFieldValue('jsonPayload', value)}
|
||||
|
||||
@@ -35,7 +35,7 @@ const messages = defineMessages({
|
||||
apiKeyPlaceholder: 'Your Radarr API key',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlPlaceholder: 'Example: /radarr',
|
||||
syncEnabled: 'Enable Sync',
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
externalUrlPlaceholder: 'External URL pointing to your Radarr server',
|
||||
qualityprofile: 'Quality Profile',
|
||||
@@ -345,7 +345,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
@@ -370,7 +370,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<span className="protocol">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
@@ -435,7 +435,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
@@ -459,7 +459,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{intl.formatMessage(messages.baseUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="baseUrl"
|
||||
name="baseUrl"
|
||||
@@ -484,7 +484,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
@@ -522,7 +522,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
@@ -558,7 +558,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="minimumAvailability"
|
||||
@@ -583,7 +583,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="externalUrl"
|
||||
name="externalUrl"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
FormattedRelativeTime,
|
||||
defineMessages,
|
||||
useIntl,
|
||||
MessageDescriptor,
|
||||
} from 'react-intl';
|
||||
import Button from '../../Common/Button';
|
||||
import Table from '../../Common/Table';
|
||||
import Spinner from '../../../assets/spinner.svg';
|
||||
@@ -11,10 +16,12 @@ import Badge from '../../Common/Badge';
|
||||
import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import { formatBytes } from '../../../utils/numberHelpers';
|
||||
|
||||
const messages = defineMessages({
|
||||
const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
||||
jobs: 'Jobs',
|
||||
jobsDescription:
|
||||
'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.',
|
||||
'Overseerr performs certain maintenance tasks as regularly-scheduled jobs,\
|
||||
but they can also be manually triggered below.\
|
||||
Manually running a job will not alter its schedule.',
|
||||
jobname: 'Job Name',
|
||||
jobtype: 'Type',
|
||||
nextexecution: 'Next Execution',
|
||||
@@ -35,6 +42,13 @@ const messages = defineMessages({
|
||||
cacheksize: 'Key Size',
|
||||
cachevsize: 'Value Size',
|
||||
flushcache: 'Flush Cache',
|
||||
unknownJob: 'Unknown Job',
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
'plex-full-scan': 'Plex Full Library Scan',
|
||||
'radarr-scan': 'Radarr Scan',
|
||||
'sonarr-scan': 'Sonarr Scan',
|
||||
'download-sync': 'Download Sync',
|
||||
'download-sync-reset': 'Download Sync Reset',
|
||||
});
|
||||
|
||||
interface Job {
|
||||
@@ -66,7 +80,7 @@ const SettingsJobs: React.FC = () => {
|
||||
await axios.post(`/api/v1/settings/jobs/${job.id}/run`);
|
||||
addToast(
|
||||
intl.formatMessage(messages.jobstarted, {
|
||||
jobname: job.name,
|
||||
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
|
||||
}),
|
||||
{
|
||||
appearance: 'success',
|
||||
@@ -78,10 +92,15 @@ const SettingsJobs: React.FC = () => {
|
||||
|
||||
const cancelJob = async (job: Job) => {
|
||||
await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`);
|
||||
addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
addToast(
|
||||
intl.formatMessage(messages.jobcancelled, {
|
||||
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
|
||||
}),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
revalidate();
|
||||
};
|
||||
|
||||
@@ -121,7 +140,11 @@ const SettingsJobs: React.FC = () => {
|
||||
<Table.TD>
|
||||
<div className="flex items-center text-sm leading-5 text-white">
|
||||
{job.running && <Spinner className="w-5 h-5 mr-2" />}
|
||||
<span>{job.name}</span>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
messages[job.id] ?? messages.unknownJob
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
|
||||
@@ -7,6 +7,7 @@ import PageTitle from '../Common/PageTitle';
|
||||
const messages = defineMessages({
|
||||
settings: 'Settings',
|
||||
menuGeneralSettings: 'General Settings',
|
||||
menuUsers: 'Users',
|
||||
menuPlexSettings: 'Plex',
|
||||
menuServices: 'Services',
|
||||
menuNotifications: 'Notifications',
|
||||
@@ -31,6 +32,11 @@ const SettingsLayout: React.FC = ({ children }) => {
|
||||
route: '/settings/main',
|
||||
regex: /^\/settings(\/main)?$/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuUsers),
|
||||
route: '/settings/users',
|
||||
regex: /^\/settings\/users/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuPlexSettings),
|
||||
route: '/settings/plex',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user