Compare commits
85 Commits
fix-radarr
...
preview-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7529dc07 | ||
|
|
cbee8fd843 | ||
|
|
b9435427dc | ||
|
|
8ceec0f9c4 | ||
|
|
5a1040bb61 | ||
|
|
a97a3f3512 | ||
|
|
1dbacec4f9 | ||
|
|
049bc59d2d | ||
|
|
7c969f4235 | ||
|
|
bb95c7009f | ||
|
|
d4a6cb268a | ||
|
|
fb8677f29c | ||
|
|
c7284f473c | ||
|
|
0bdc8a0334 | ||
|
|
c0dd2e5e27 | ||
|
|
6b8c0bd8f3 | ||
|
|
ea7e68fc99 | ||
|
|
110adfaf66 | ||
|
|
515124bab4 | ||
|
|
52e85e1404 | ||
|
|
24e1e94747 | ||
|
|
b27dbd7a15 | ||
|
|
78afd02d3d | ||
|
|
8949edea7e | ||
|
|
e69649d71d | ||
|
|
d226dbb9b4 | ||
|
|
8da1c92923 | ||
|
|
70a28dd1e3 | ||
|
|
c067a03531 | ||
|
|
437bf0f4ee | ||
|
|
45f25408c6 | ||
|
|
123894b475 | ||
|
|
6b9aedb970 | ||
|
|
1651725665 | ||
|
|
cf9c33d124 | ||
|
|
e8f1edc062 | ||
|
|
d01f9a0580 | ||
|
|
c55da3da5f | ||
|
|
a19dcaf5e5 | ||
|
|
1870e637e4 | ||
|
|
185167a0a7 | ||
|
|
2e4d14698f | ||
|
|
149d79e540 | ||
|
|
e5b77b2688 | ||
|
|
fc4db7fa00 | ||
|
|
48dea32bd0 | ||
|
|
806cd9013a | ||
|
|
8a42fe16b5 | ||
|
|
236d431fa3 | ||
|
|
5fd65eb1ba | ||
|
|
c3b8574515 | ||
|
|
7c2444a65f | ||
|
|
355b76de5c | ||
|
|
b7b05d31a5 | ||
|
|
f3a895fa7d | ||
|
|
4a5ac3cc42 | ||
|
|
a488f850f3 | ||
|
|
21400cecdc | ||
|
|
5a6ff61f64 | ||
|
|
14ee52e93e | ||
|
|
4cf799d6eb | ||
|
|
bea57c330a | ||
|
|
7d36dc182b | ||
|
|
5865478a3b | ||
|
|
90c58de9b2 | ||
|
|
2f6be955b5 | ||
|
|
85bbc85714 | ||
|
|
8dc1d8196c | ||
|
|
63dc27d400 | ||
|
|
29034b350d | ||
|
|
7438042757 | ||
|
|
0b0b76e58c | ||
|
|
a5cb505609 | ||
|
|
7cb127ec3f | ||
|
|
1635932375 | ||
|
|
c1aeab9538 | ||
|
|
70fb1f2b00 | ||
|
|
4cd02babba | ||
|
|
f5b3a526cb | ||
|
|
e5ab847547 | ||
|
|
40539cc4b1 | ||
|
|
0bd6d57834 | ||
|
|
f884ac9c66 | ||
|
|
c2d9d00b41 | ||
|
|
77a36f9714 |
@@ -249,7 +249,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4",
|
||||||
"profile": "http://www.piribisoft.com",
|
"profile": "http://www.piribisoft.com",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -276,7 +277,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/13810742?v=4",
|
||||||
"profile": "https://athfan.com",
|
"profile": "https://athfan.com",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -294,7 +296,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/13354331?v=4",
|
||||||
"profile": "https://github.com/xeruf",
|
"profile": "https://github.com/xeruf",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -379,33 +382,6 @@
|
|||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "mobihen",
|
"login": "mobihen",
|
||||||
"name": "Nir Israel Hen",
|
"name": "Nir Israel Hen",
|
||||||
@@ -451,69 +427,6 @@
|
|||||||
"security"
|
"security"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "demrich",
|
|
||||||
"name": "David Emrich",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
|
||||||
"profile": "https://github.com/demrich",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "maxnatamo",
|
|
||||||
"name": "Max T. Kristiansen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
|
||||||
"profile": "https://maxtrier.dk",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "DamsDev1",
|
|
||||||
"name": "Damien Fajole",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
|
||||||
"profile": "https://damsdev.me",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "AhmedNSidd",
|
|
||||||
"name": "Ahmed Siddiqui",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
|
||||||
"profile": "https://github.com/AhmedNSidd",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "Zariel",
|
"login": "Zariel",
|
||||||
"name": "Chris Bannister",
|
"name": "Chris Bannister",
|
||||||
@@ -622,87 +535,6 @@
|
|||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"login": "j0srisk",
|
|
||||||
"name": "Joseph Risk",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
|
||||||
"profile": "http://josephrisk.com",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Loetwiek",
|
|
||||||
"name": "Loetwiek",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
|
||||||
"profile": "https://github.com/Loetwiek",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "Fuochi",
|
|
||||||
"name": "Fuochi",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
|
||||||
"profile": "https://github.com/Fuochi",
|
|
||||||
"contributions": [
|
|
||||||
"doc"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "demrich",
|
|
||||||
"name": "David Emrich",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
|
||||||
"profile": "https://github.com/demrich",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "maxnatamo",
|
|
||||||
"name": "Max T. Kristiansen",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
|
||||||
"profile": "https://maxtrier.dk",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "DamsDev1",
|
|
||||||
"name": "Damien Fajole",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
|
||||||
"profile": "https://damsdev.me",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "AhmedNSidd",
|
|
||||||
"name": "Ahmed Siddiqui",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
|
||||||
"profile": "https://github.com/AhmedNSidd",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "JackW6809",
|
|
||||||
"name": "JackOXI",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
|
||||||
"profile": "https://github.com/JackW6809",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"login": "StancuFlorin",
|
|
||||||
"name": "Stancu Florin",
|
|
||||||
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
|
||||||
"profile": "http://indicus.ro",
|
|
||||||
"contributions": [
|
|
||||||
"code"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"login": "RankWeis",
|
"login": "RankWeis",
|
||||||
"name": "RankWeis",
|
"name": "RankWeis",
|
||||||
@@ -711,6 +543,105 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jessielw",
|
||||||
|
"name": "Jessie Wilson",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/48299282?v=4",
|
||||||
|
"profile": "http://www.linkedin.com/in/jessielwilson",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "brotaxt",
|
||||||
|
"name": "DominicKo",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/25477935?v=4",
|
||||||
|
"profile": "https://github.com/brotaxt",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "corentinnormand",
|
||||||
|
"name": "Corentin Normand",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30508927?v=4",
|
||||||
|
"profile": "https://doctolib.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "benbeauchamp7",
|
||||||
|
"name": "Ben Beauchamp",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/43358492?v=4",
|
||||||
|
"profile": "https://github.com/benbeauchamp7",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "vfaergestad",
|
||||||
|
"name": "vfaergestad",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/49147564?v=4",
|
||||||
|
"profile": "https://github.com/vfaergestad",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "wolffman122",
|
||||||
|
"name": "wolffman122",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/19178872?v=4",
|
||||||
|
"profile": "https://github.com/wolffman122",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Schrottfresser",
|
||||||
|
"name": "Schrottfresser",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/39998368?v=4",
|
||||||
|
"profile": "https://github.com/Schrottfresser",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DillionLowry",
|
||||||
|
"name": "Dillion",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/91228469?v=4",
|
||||||
|
"profile": "https://github.com/DillionLowry",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "JamsRepos",
|
||||||
|
"name": "Jam",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1347620?v=4",
|
||||||
|
"profile": "https://github.com/JamsRepos",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "joelowrance",
|
||||||
|
"name": "Joe Lowrance",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/63176?v=4",
|
||||||
|
"profile": "http://www.joelowrance.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "0xSysR3ll",
|
||||||
|
"name": "0xsysr3ll",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/31414959?v=4",
|
||||||
|
"profile": "https://github.com/0xSysR3ll",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -98,6 +98,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
BUILD_VERSION=develop
|
||||||
|
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||||
outputs: |
|
outputs: |
|
||||||
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
|
type=image,push-by-digest=true,name=fallenbagel/jellyseerr,push=true
|
||||||
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
|
type=image,push-by-digest=true,name=ghcr.io/${{ env.OWNER_LC }}/jellyseerr,push=true
|
||||||
|
|||||||
2
.github/workflows/preview.yml
vendored
2
.github/workflows/preview.yml
vendored
@@ -33,5 +33,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_TAG=${{ github.sha }}
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
BUILD_VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||||
|
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||||
tags: |
|
tags: |
|
||||||
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
fallenbagel/jellyseerr:${{ steps.get_version.outputs.VERSION }}
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -19,5 +19,6 @@
|
|||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"globals.css": "tailwindcss"
|
"globals.css": "tailwindcss"
|
||||||
}
|
},
|
||||||
|
"i18n-ally.localesPaths": ["src/i18n/locale"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,27 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
|
|
||||||
- Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines.
|
- 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`:
|
- Should you need to update your fork, you can do so by rebasing from `upstream`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
git rebase upstream/develop
|
git rebase upstream/develop
|
||||||
git push origin BRANCH_NAME -f
|
git push origin BRANCH_NAME -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Helm Chart
|
||||||
|
|
||||||
|
Tools Required:
|
||||||
|
|
||||||
|
- [Helm](https://helm.sh/docs/intro/install/)
|
||||||
|
- [helm-docs](https://github.com/norwoodj/helm-docs)
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. Make the necessary changes.
|
||||||
|
2. Test your changes.
|
||||||
|
3. Update the `version` in `charts/jellyseerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/).
|
||||||
|
4. Run the `helm-docs` command to regenerate the chart's README.
|
||||||
|
|
||||||
### Contributing Code
|
### Contributing Code
|
||||||
|
|
||||||
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
|
- If you are taking on an existing bug or feature ticket, please comment on the [issue](https://github.com/fallenbagel/jellyseerr/issues) to avoid multiple people working on the same thing.
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -38,8 +38,17 @@ RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
|
|||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
# Metadata for Github Package Registry
|
# OCI Meta information
|
||||||
LABEL org.opencontainers.image.source="https://github.com/Fallenbagel/jellyseerr"
|
ARG BUILD_DATE
|
||||||
|
ARG BUILD_VERSION
|
||||||
|
LABEL \
|
||||||
|
org.opencontainers.image.authors="Fallenbagel" \
|
||||||
|
org.opencontainers.image.source="https://github.com/fallenbagel/jellyseerr" \
|
||||||
|
org.opencontainers.image.created=${BUILD_DATE} \
|
||||||
|
org.opencontainers.image.version=${BUILD_VERSION} \
|
||||||
|
org.opencontainers.image.title="Jellyseerr" \
|
||||||
|
org.opencontainers.image.description="Open-source media request and discovery manager for Jellyfin, Plex, and Emby." \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -11,7 +11,7 @@
|
|||||||
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="http://translate.jellyseerr.dev/engage/jellyseerr/"><img src="http://translate.jellyseerr.dev/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- 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-77-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-69-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
**Jellyseerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||||
@@ -117,14 +117,14 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/EthanArmbrust"><img src="https://avatars.githubusercontent.com/u/22754714?v=4?s=100" width="100px;" alt="EthanArmbrust"/><br /><sub><b>EthanArmbrust</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=EthanArmbrust" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://www.piribisoft.com"><img src="https://avatars.githubusercontent.com/u/854646?v=4?s=100" width="100px;" alt="Eduardo"/><br /><sub><b>Eduardo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=SirMartin" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RickLuiken"><img src="https://avatars.githubusercontent.com/u/34110371?v=4?s=100" width="100px;" alt="RickLuiken"/><br /><sub><b>RickLuiken</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RickLuiken" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Br33ce"><img src="https://avatars.githubusercontent.com/u/124933490?v=4?s=100" width="100px;" alt="Br33ce"/><br /><sub><b>Br33ce</b></sub></a><br /><a href="#translation-Br33ce" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://athfan.com"><img src="https://avatars.githubusercontent.com/u/13810742?v=4?s=100" width="100px;" alt="Athfan Khaleel"/><br /><sub><b>Athfan Khaleel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=athphane" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mdll23"><img src="https://avatars.githubusercontent.com/u/142844478?v=4?s=100" width="100px;" alt="Michael Dallinger"/><br /><sub><b>Michael Dallinger</b></sub></a><br /><a href="#translation-mdll23" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xeruf"><img src="https://avatars.githubusercontent.com/u/13354331?v=4?s=100" width="100px;" alt="Janek"/><br /><sub><b>Janek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=xeruf" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://aleksasiriski.dev"><img src="https://avatars.githubusercontent.com/u/31509435?v=4?s=100" width="100px;" alt="Aleksa Siriški"/><br /><sub><b>Aleksa Siriški</b></sub></a><br /><a href="#infra-aleksasiriski" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://danishhumair.com"><img src="https://avatars.githubusercontent.com/u/121830048?v=4?s=100" width="100px;" alt="Danish Humair"/><br /><sub><b>Danish Humair</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Danish-H" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://arm0.red"><img src="https://avatars.githubusercontent.com/u/16858514?v=4?s=100" width="100px;" alt="Stephen Harris"/><br /><sub><b>Stephen Harris</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=trackmastersteve" title="Documentation">📖</a></td>
|
||||||
@@ -136,53 +136,43 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/M0NsTeRRR"><img src="https://avatars.githubusercontent.com/u/37785089?v=4?s=100" width="100px;" alt="Ludovic Ortega"/><br /><sub><b>Ludovic Ortega</b></sub></a><br /><a href="#security-M0NsTeRRR" title="Security">🛡️</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zariel"><img src="https://avatars.githubusercontent.com/u/2213?v=4?s=100" width="100px;" alt="Chris Bannister"/><br /><sub><b>Chris Bannister</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Zariel" title="Code">💻</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/C4J3"><img src="https://avatars.githubusercontent.com/u/13005453?v=4?s=100" width="100px;" alt="Joe"/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=C4J3" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://me.garnx.fr"><img src="https://avatars.githubusercontent.com/u/37373941?v=4?s=100" width="100px;" alt="Guillaume ARNOUX"/><br /><sub><b>Guillaume ARNOUX</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=guillaumearnx" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/dr-carrot"><img src="https://avatars.githubusercontent.com/u/17272571?v=4?s=100" width="100px;" alt="dr-carrot"/><br /><sub><b>dr-carrot</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dr-carrot" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gageorsburn"><img src="https://avatars.githubusercontent.com/u/4692734?v=4?s=100" width="100px;" alt="Gage Orsburn"/><br /><sub><b>Gage Orsburn</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gageorsburn" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/GkhnGRBZ"><img src="https://avatars.githubusercontent.com/u/127258824?v=4?s=100" width="100px;" alt="GkhnGRBZ"/><br /><sub><b>GkhnGRBZ</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=GkhnGRBZ" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://benhaney.com"><img src="https://avatars.githubusercontent.com/u/31331498?v=4?s=100" width="100px;" alt="Ben Haney"/><br /><sub><b>Ben Haney</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benhaney" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Wunderharke"><img src="https://avatars.githubusercontent.com/u/5105672?v=4?s=100" width="100px;" alt="Wunderharke"/><br /><sub><b>Wunderharke</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Wunderharke" title="Documentation">📖</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/methbkts"><img src="https://avatars.githubusercontent.com/u/30674934?v=4?s=100" width="100px;" alt="Metin Bektas"/><br /><sub><b>Metin Bektas</b></sub></a><br /><a href="#infra-methbkts" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrewkolda"><img src="https://avatars.githubusercontent.com/u/158614532?v=4?s=100" width="100px;" alt="andrewkolda"/><br /><sub><b>andrewkolda</b></sub></a><br /><a href="#design-andrewkolda" title="Design">🎨</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://ishanjain.me"><img src="https://avatars.githubusercontent.com/u/7921368?v=4?s=100" width="100px;" alt="Ishan Jain"/><br /><sub><b>Ishan Jain</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=ishanjain28" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.linkedin.com/in/jessielwilson"><img src="https://avatars.githubusercontent.com/u/48299282?v=4?s=100" width="100px;" alt="Jessie Wilson"/><br /><sub><b>Jessie Wilson</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jessielw" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brotaxt"><img src="https://avatars.githubusercontent.com/u/25477935?v=4?s=100" width="100px;" alt="DominicKo"/><br /><sub><b>DominicKo</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=brotaxt" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://doctolib.com"><img src="https://avatars.githubusercontent.com/u/30508927?v=4?s=100" width="100px;" alt="Corentin Normand"/><br /><sub><b>Corentin Normand</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=corentinnormand" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/benbeauchamp7"><img src="https://avatars.githubusercontent.com/u/43358492?v=4?s=100" width="100px;" alt="Ben Beauchamp"/><br /><sub><b>Ben Beauchamp</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=benbeauchamp7" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=vfaergestad" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wolffman122"><img src="https://avatars.githubusercontent.com/u/19178872?v=4?s=100" width="100px;" alt="wolffman122"/><br /><sub><b>wolffman122</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=wolffman122" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Schrottfresser"><img src="https://avatars.githubusercontent.com/u/39998368?v=4?s=100" width="100px;" alt="Schrottfresser"/><br /><sub><b>Schrottfresser</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Schrottfresser" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DillionLowry"><img src="https://avatars.githubusercontent.com/u/91228469?v=4?s=100" width="100px;" alt="Dillion"/><br /><sub><b>Dillion</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DillionLowry" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JamsRepos"><img src="https://avatars.githubusercontent.com/u/1347620?v=4?s=100" width="100px;" alt="Jam"/><br /><sub><b>Jam</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JamsRepos" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://www.joelowrance.com"><img src="https://avatars.githubusercontent.com/u/63176?v=4?s=100" width="100px;" alt="Joe Lowrance"/><br /><sub><b>Joe Lowrance</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joelowrance" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xSysR3ll"><img src="https://avatars.githubusercontent.com/u/31414959?v=4?s=100" width="100px;" alt="0xsysr3ll"/><br /><sub><b>0xsysr3ll</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=0xSysR3ll" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -317,7 +307,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://izaacj.me"><img src="https://avatars.githubusercontent.com/u/711323?v=4?s=100" width="100px;" alt="Izaac Brånn"/><br /><sub><b>Izaac Brånn</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=IzaacJ" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/SalmanTariq"><img src="https://avatars.githubusercontent.com/u/13284494?v=4?s=100" width="100px;" alt="Salman Tariq"/><br /><sub><b>Salman Tariq</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=SalmanTariq" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrew-kennedy"><img src="https://avatars.githubusercontent.com/u/2387159?v=4?s=100" width="100px;" alt="Andrew Kennedy"/><br /><sub><b>Andrew Kennedy</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=andrew-kennedy" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Jellyseerr">🪼⌨️</a> <a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Fallenbagel" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://aidoge.xyz"><img src="https://avatars.githubusercontent.com/u/9427639?v=4?s=100" width="100px;" alt="Anton K. (ai Doge)"/><br /><sub><b>Anton K. (ai Doge)</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=scorp200" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://marcofaggian.com"><img src="https://avatars.githubusercontent.com/u/19221001?v=4?s=100" width="100px;" alt="Marco Faggian"/><br /><sub><b>Marco Faggian</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=marcofaggian" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://nemchik.com/"><img src="https://avatars.githubusercontent.com/u/725456?v=4?s=100" width="100px;" alt="Eric Nemchik"/><br /><sub><b>Eric Nemchik</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=nemchik" title="Code">💻</a></td>
|
||||||
@@ -335,6 +325,9 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vfaergestad"><img src="https://avatars.githubusercontent.com/u/49147564?v=4?s=100" width="100px;" alt="vfaergestad"/><br /><sub><b>vfaergestad</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=vfaergestad" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
|||||||
name: jellyseerr-chart
|
name: jellyseerr-chart
|
||||||
description: Jellyseerr helm chart for Kubernetes
|
description: Jellyseerr helm chart for Kubernetes
|
||||||
type: application
|
type: application
|
||||||
version: 2.3.0
|
version: 2.5.0
|
||||||
appVersion: "2.5.0"
|
appVersion: "2.6.0"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: Jellyseerr
|
- name: Jellyseerr
|
||||||
url: https://github.com/Fallenbagel/jellyseerr
|
url: https://github.com/Fallenbagel/jellyseerr
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# jellyseerr-chart
|
# jellyseerr-chart
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
Jellyseerr helm chart for Kubernetes
|
Jellyseerr helm chart for Kubernetes
|
||||||
|
|
||||||
@@ -52,6 +52,9 @@ Kubernetes: `>=1.23.0-0`
|
|||||||
| podAnnotations | object | `{}` | |
|
| podAnnotations | object | `{}` | |
|
||||||
| podLabels | object | `{}` | |
|
| podLabels | object | `{}` | |
|
||||||
| podSecurityContext | object | `{}` | |
|
| podSecurityContext | object | `{}` | |
|
||||||
|
| probes.livenessProbe | object | `{}` | Configure liveness probe |
|
||||||
|
| probes.readinessProbe | object | `{}` | Configure readiness probe |
|
||||||
|
| probes.startupProbe | string | `nil` | Configure startup probe |
|
||||||
| replicaCount | int | `1` | |
|
| replicaCount | int | `1` | |
|
||||||
| resources | object | `{}` | |
|
| resources | object | `{}` | |
|
||||||
| securityContext | object | `{}` | |
|
| securityContext | object | `{}` | |
|
||||||
|
|||||||
@@ -48,10 +48,44 @@ spec:
|
|||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: http
|
port: http
|
||||||
|
{{- if .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.periodSeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.timeoutSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.livenessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.successThreshold }}
|
||||||
|
successThreshold: {{ .Values.probes.livenessProbe.successThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.livenessProbe.failureThreshold }}
|
||||||
|
failureThreshold: {{ .Values.probes.livenessProbe.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: http
|
port: http
|
||||||
|
{{- if .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.periodSeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.timeoutSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.readinessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.successThreshold }}
|
||||||
|
successThreshold: {{ .Values.probes.readinessProbe.successThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readinessProbe.failureThreshold }}
|
||||||
|
failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.startupProbe }}
|
||||||
|
startupProbe:
|
||||||
|
{{- toYaml .Values.probes.startupProbe | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.resources | nindent 12 }}
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
{{- with .Values.extraEnv }}
|
{{- with .Values.extraEnv }}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ metadata:
|
|||||||
name: {{ include "jellyseerr.configPersistenceName" . }}
|
name: {{ include "jellyseerr.configPersistenceName" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "jellyseerr.labels" . | nindent 4 }}
|
{{- include "jellyseerr.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.config.persistence.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
{{- with .Values.config.persistence.accessModes }}
|
{{- with .Values.config.persistence.accessModes }}
|
||||||
accessModes:
|
accessModes:
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ fullnameOverride: ""
|
|||||||
strategy:
|
strategy:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
|
|
||||||
|
# Liveness / Readiness / Startup Probes
|
||||||
|
probes:
|
||||||
|
# -- Configure liveness probe
|
||||||
|
livenessProbe: {}
|
||||||
|
# initialDelaySeconds: 60
|
||||||
|
# periodSeconds: 30
|
||||||
|
# timeoutSeconds: 5
|
||||||
|
# successThreshold: 1
|
||||||
|
# failureThreshold: 5
|
||||||
|
# -- Configure readiness probe
|
||||||
|
readinessProbe: {}
|
||||||
|
# initialDelaySeconds: 60
|
||||||
|
# periodSeconds: 30
|
||||||
|
# timeoutSeconds: 5
|
||||||
|
# successThreshold: 1
|
||||||
|
# failureThreshold: 5
|
||||||
|
# -- Configure startup probe
|
||||||
|
startupProbe: null
|
||||||
|
# tcpSocket:
|
||||||
|
# port: http
|
||||||
|
|
||||||
# -- Environment variables to add to the jellyseerr pods
|
# -- Environment variables to add to the jellyseerr pods
|
||||||
extraEnv: []
|
extraEnv: []
|
||||||
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
# -- Environment variables from secrets or configmaps to add to the jellyseerr pods
|
||||||
@@ -36,15 +57,15 @@ podAnnotations: {}
|
|||||||
podLabels: {}
|
podLabels: {}
|
||||||
|
|
||||||
podSecurityContext: {}
|
podSecurityContext: {}
|
||||||
# fsGroup: 2000
|
# fsGroup: 2000
|
||||||
|
|
||||||
securityContext: {}
|
securityContext: {}
|
||||||
# capabilities:
|
# capabilities:
|
||||||
# drop:
|
# drop:
|
||||||
# - ALL
|
# - ALL
|
||||||
# readOnlyRootFilesystem: true
|
# readOnlyRootFilesystem: true
|
||||||
# runAsNonRoot: true
|
# runAsNonRoot: true
|
||||||
# runAsUser: 1000
|
# runAsUser: 1000
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
@@ -70,8 +91,8 @@ ingress:
|
|||||||
enabled: false
|
enabled: false
|
||||||
ingressClassName: ""
|
ingressClassName: ""
|
||||||
annotations: {}
|
annotations: {}
|
||||||
# kubernetes.io/ingress.class: nginx
|
# kubernetes.io/ingress.class: nginx
|
||||||
# kubernetes.io/tls-acme: "true"
|
# kubernetes.io/tls-acme: "true"
|
||||||
hosts:
|
hosts:
|
||||||
- host: chart-example.local
|
- host: chart-example.local
|
||||||
paths:
|
paths:
|
||||||
@@ -83,16 +104,16 @@ ingress:
|
|||||||
# - chart-example.local
|
# - chart-example.local
|
||||||
|
|
||||||
resources: {}
|
resources: {}
|
||||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||||
# choice for the user. This also increases chances charts run on environments with little
|
# choice for the user. This also increases chances charts run on environments with little
|
||||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||||
# limits:
|
# limits:
|
||||||
# cpu: 100m
|
# cpu: 100m
|
||||||
# memory: 128Mi
|
# memory: 128Mi
|
||||||
# requests:
|
# requests:
|
||||||
# cpu: 100m
|
# cpu: 100m
|
||||||
# memory: 128Mi
|
# memory: 128Mi
|
||||||
|
|
||||||
# -- Additional volumes on the output Deployment definition.
|
# -- Additional volumes on the output Deployment definition.
|
||||||
volumes: []
|
volumes: []
|
||||||
|
|||||||
@@ -19,11 +19,12 @@
|
|||||||
"discoverRegion": "",
|
"discoverRegion": "",
|
||||||
"streamingRegion": "",
|
"streamingRegion": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
|
"blacklistedTags": "",
|
||||||
|
"blacklistedTagsLimit": 50,
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"mediaServerType": 1,
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"enableSpecialEpisodes": false,
|
"enableSpecialEpisodes": false,
|
||||||
"forceIpv4First": false,
|
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
@@ -138,7 +139,16 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": ""
|
"token": "",
|
||||||
|
"priority": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"types": 0,
|
||||||
|
"options": {
|
||||||
|
"url": "",
|
||||||
|
"topic": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe('User List', () => {
|
|||||||
cy.get('#email').type(testUser.emailAddress);
|
cy.get('#email').type(testUser.emailAddress);
|
||||||
cy.get('#password').type(testUser.password);
|
cy.get('#password').type(testUser.password);
|
||||||
|
|
||||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
cy.intercept('/api/v1/user*').as('user');
|
||||||
|
|
||||||
cy.get('[data-testid=modal-ok-button]').click();
|
cy.get('[data-testid=modal-ok-button]').click();
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ describe('User List', () => {
|
|||||||
|
|
||||||
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||||
|
|
||||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
cy.intercept('/api/v1/user*').as('user');
|
||||||
|
|
||||||
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ docker run -d \
|
|||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
fallenbagel/jellyseerr
|
fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
:::tip
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
|
|
||||||
`-e JELLYFIN_TYPE=emby`
|
|
||||||
:::
|
|
||||||
|
|
||||||
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
||||||
|
|
||||||
@@ -90,9 +85,6 @@ services:
|
|||||||
- /path/to/appdata/config:/app/config
|
- /path/to/appdata/config:/app/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
:::tip
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Then, start all services defined in the Compose file:
|
Then, start all services defined in the Compose file:
|
||||||
```bash
|
```bash
|
||||||
@@ -121,8 +113,7 @@ You may alternatively use a third-party mechanism like [dockge](https://github.c
|
|||||||
2. Inside the **Community Applications** app store, search for **Jellyseerr**.
|
2. Inside the **Community Applications** app store, search for **Jellyseerr**.
|
||||||
3. Click the **Install Button**.
|
3. Click the **Install Button**.
|
||||||
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
|
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
|
||||||
5. If you want to use emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`. Otherwise, remove the variable.
|
5. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
|
||||||
6. Click apply and access "Jellyseerr" at your `<ServerIP:HostPort>` in a web browser.
|
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
|
|
||||||
@@ -193,12 +184,6 @@ docker compose up -d
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
:::tip
|
|
||||||
If you are using a named volume, then you can safely **ignore** the warning about the `/app/config` folder being incorrectly mounted.
|
|
||||||
|
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
|
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
|
|||||||
@@ -97,37 +97,7 @@ You can try them all and see which one works for your network.
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
### Option 2: Force IPV4 resolution first
|
### Option 2: Use Jellyseerr through a proxy
|
||||||
|
|
||||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
|
||||||
|
|
||||||
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting. You can also add the environment variable, `FORCE_IPV4_FIRST=true`:
|
|
||||||
|
|
||||||
<Tabs groupId="methods" queryString>
|
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
|
||||||
|
|
||||||
Add the following to your `docker run` command:
|
|
||||||
```bash
|
|
||||||
-e "FORCE_IPV4_FIRST=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<TabItem value="docker-compose" label="Docker Compose">
|
|
||||||
|
|
||||||
Add the following to your `compose.yaml`:
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
services:
|
|
||||||
jellyseerr:
|
|
||||||
environment:
|
|
||||||
- FORCE_IPV4_FIRST=true
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
### Option 3: Use Jellyseerr through a proxy
|
|
||||||
|
|
||||||
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
|
If you can't change your DNS servers or force IPV4 resolution, you can use Jellyseerr through a proxy.
|
||||||
|
|
||||||
@@ -176,3 +146,26 @@ In a PowerShell window:
|
|||||||
|
|
||||||
If you can't get a response, then your server can't reach the TMDB API.
|
If you can't get a response, then your server can't reach the TMDB API.
|
||||||
This is usually due to a network configuration issue or a firewall blocking the connection.
|
This is usually due to a network configuration issue or a firewall blocking the connection.
|
||||||
|
|
||||||
|
## Account does not have admin privileges
|
||||||
|
|
||||||
|
If your admin account no longer has admin privileges, this is typically because your Jellyfin/Emby user ID has changed on the server side.
|
||||||
|
|
||||||
|
This can happen if you have a new installation of Jellyfin/Emby or if you have changed the user ID of your admin account.
|
||||||
|
|
||||||
|
### Solution: Reset admin access
|
||||||
|
|
||||||
|
1. Back up your `settings.json` file (located in your Jellyseerr data directory)
|
||||||
|
2. Stop the Jellyseerr container/service
|
||||||
|
3. Delete the `settings.json` file
|
||||||
|
4. Start Jellyseerr again
|
||||||
|
5. This will force the setup page to appear
|
||||||
|
6. Go through the setup process with the same login details
|
||||||
|
7. You can skip the services setup
|
||||||
|
8. Once you reach the discover page, stop Jellyseerr
|
||||||
|
9. Restore your backed-up `settings.json` file
|
||||||
|
10. Start Jellyseerr again
|
||||||
|
|
||||||
|
This process should restore your admin privileges while preserving your settings.
|
||||||
|
|
||||||
|
If you still encounter issues, please reach out on our support channels.
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting
|
|||||||
|
|
||||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
||||||
|
|
||||||
|
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||||
|
|
||||||
|
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
|
||||||
|
|
||||||
|
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
|
||||||
|
|
||||||
|
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||||
|
|
||||||
## Hide Available Media
|
## Hide Available Media
|
||||||
|
|
||||||
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
||||||
@@ -70,6 +78,12 @@ Available media will still appear in search results, however, so it is possible
|
|||||||
|
|
||||||
This setting is **disabled** by default.
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
|
## Hide Blacklisted Items
|
||||||
|
|
||||||
|
When enabled, media that has been blacklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blacklisted when you have the "Manage Blacklist" permission.
|
||||||
|
|
||||||
|
This setting is **disabled** by default.
|
||||||
|
|
||||||
## Allow Partial Series Requests
|
## Allow Partial Series Requests
|
||||||
|
|
||||||
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
|
When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons.
|
||||||
|
|||||||
@@ -191,9 +191,6 @@ components:
|
|||||||
csrfProtection:
|
csrfProtection:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
forceIpv4First:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
trustProxy:
|
trustProxy:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: true
|
example: true
|
||||||
@@ -1160,7 +1157,7 @@ components:
|
|||||||
status:
|
status:
|
||||||
type: number
|
type: number
|
||||||
example: 0
|
example: 0
|
||||||
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`
|
description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED`
|
||||||
requests:
|
requests:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -1402,6 +1399,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
|
NtfySettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
|
types:
|
||||||
|
type: number
|
||||||
|
example: 2
|
||||||
|
options:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
topic:
|
||||||
|
type: string
|
||||||
|
authMethodUsernamePassword:
|
||||||
|
type: boolean
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
authMethodToken:
|
||||||
|
type: boolean
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
LunaSeaSettings:
|
LunaSeaSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1953,6 +1976,41 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
Certification:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certification:
|
||||||
|
type: string
|
||||||
|
example: 'PG-13'
|
||||||
|
meaning:
|
||||||
|
type: string
|
||||||
|
example: 'Some material may be inappropriate for children under 13.'
|
||||||
|
nullable: true
|
||||||
|
order:
|
||||||
|
type: number
|
||||||
|
example: 3
|
||||||
|
nullable: true
|
||||||
|
required:
|
||||||
|
- certification
|
||||||
|
|
||||||
|
CertificationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
certifications:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Certification'
|
||||||
|
example:
|
||||||
|
certifications:
|
||||||
|
US:
|
||||||
|
- certification: 'G'
|
||||||
|
meaning: 'All ages admitted'
|
||||||
|
order: 1
|
||||||
|
- certification: 'PG'
|
||||||
|
meaning: 'Some material may not be suitable for children under 10.'
|
||||||
|
order: 2
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -3252,6 +3310,52 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Test notification attempted
|
description: Test notification attempted
|
||||||
|
/settings/notifications/ntfy:
|
||||||
|
get:
|
||||||
|
summary: Get ntfy.sh notification settings
|
||||||
|
description: Returns current ntfy.sh notification settings in a JSON object.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Returned ntfy.sh settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
post:
|
||||||
|
summary: Update ntfy.sh notification settings
|
||||||
|
description: Update ntfy.sh notification settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were sucessfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
/settings/notifications/ntfy/test:
|
||||||
|
post:
|
||||||
|
summary: Test ntfy.sh settings
|
||||||
|
description: Sends a test notification to the ntfy.sh agent.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NtfySettings'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Test notification attempted
|
||||||
/settings/notifications/slack:
|
/settings/notifications/slack:
|
||||||
get:
|
get:
|
||||||
summary: Get Slack notification settings
|
summary: Get Slack notification settings
|
||||||
@@ -3805,8 +3909,14 @@ paths:
|
|||||||
name: sort
|
name: sort
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [created, updated, requests, displayname]
|
enum: [created, updated, requests, displayname, usertype, role]
|
||||||
default: created
|
default: created
|
||||||
|
- in: query
|
||||||
|
name: sortDirection
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
default: desc
|
||||||
- in: query
|
- in: query
|
||||||
name: q
|
name: q
|
||||||
required: false
|
required: false
|
||||||
@@ -3965,6 +4075,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
p256dh:
|
p256dh:
|
||||||
type: string
|
type: string
|
||||||
|
userAgent:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- endpoint
|
- endpoint
|
||||||
- auth
|
- auth
|
||||||
@@ -3972,6 +4084,88 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Successfully registered push subscription
|
description: Successfully registered push subscription
|
||||||
|
/user/{userId}/pushSubscriptions:
|
||||||
|
get:
|
||||||
|
summary: Get all web push notification settings for a user
|
||||||
|
description: |
|
||||||
|
Returns all web push notification settings for a user in a JSON object.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User web push notification settings in JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
endpoint:
|
||||||
|
type: string
|
||||||
|
p256dh:
|
||||||
|
type: string
|
||||||
|
auth:
|
||||||
|
type: string
|
||||||
|
userAgent:
|
||||||
|
type: string
|
||||||
|
/user/{userId}/pushSubscription/{endpoint}:
|
||||||
|
get:
|
||||||
|
summary: Get web push notification settings for a user
|
||||||
|
description: |
|
||||||
|
Returns web push notification settings for a user in a JSON object.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- in: path
|
||||||
|
name: endpoint
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User web push notification settings in JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
endpoint:
|
||||||
|
type: string
|
||||||
|
p256dh:
|
||||||
|
type: string
|
||||||
|
auth:
|
||||||
|
type: string
|
||||||
|
userAgent:
|
||||||
|
type: string
|
||||||
|
delete:
|
||||||
|
summary: Delete user push subscription by key
|
||||||
|
description: Deletes the user push subscription with the provided key.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- in: path
|
||||||
|
name: endpoint
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Successfully removed user push subscription
|
||||||
/user/{userId}:
|
/user/{userId}:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get user by ID
|
||||||
@@ -4158,6 +4352,12 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: dune
|
example: dune
|
||||||
|
- in: query
|
||||||
|
name: filter
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [all, manual, blacklistedTags]
|
||||||
|
default: manual
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Blacklisted items returned
|
description: Blacklisted items returned
|
||||||
@@ -4867,6 +5067,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 8|9
|
example: 8|9
|
||||||
|
- in: query
|
||||||
|
name: certification
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: PG-13
|
||||||
|
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||||
|
- in: query
|
||||||
|
name: certificationGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: G
|
||||||
|
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: PG-13
|
||||||
|
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationCountry
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
description: Country code for the certification system (e.g., US, GB, CA)
|
||||||
|
- in: query
|
||||||
|
name: certificationMode
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [exact, range]
|
||||||
|
example: exact
|
||||||
|
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5161,6 +5392,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: 3|4
|
example: 3|4
|
||||||
|
- in: query
|
||||||
|
name: certification
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-14
|
||||||
|
description: Exact certification to filter by (used when certificationMode is 'exact')
|
||||||
|
- in: query
|
||||||
|
name: certificationGte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-PG
|
||||||
|
description: Minimum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationLte
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: TV-MA
|
||||||
|
description: Maximum certification to filter by (used when certificationMode is 'range')
|
||||||
|
- in: query
|
||||||
|
name: certificationCountry
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: US
|
||||||
|
description: Country code for the certification system (e.g., US, GB, CA)
|
||||||
|
- in: query
|
||||||
|
name: certificationMode
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [exact, range]
|
||||||
|
example: exact
|
||||||
|
description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Results
|
description: Results
|
||||||
@@ -5589,6 +5851,8 @@ paths:
|
|||||||
processing,
|
processing,
|
||||||
unavailable,
|
unavailable,
|
||||||
failed,
|
failed,
|
||||||
|
deleted,
|
||||||
|
completed,
|
||||||
]
|
]
|
||||||
- in: query
|
- in: query
|
||||||
name: sort
|
name: sort
|
||||||
@@ -5609,6 +5873,13 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
example: 1
|
example: 1
|
||||||
|
- in: query
|
||||||
|
name: mediaType
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [movie, tv, all]
|
||||||
|
nullable: true
|
||||||
|
default: all
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Requests returned
|
description: Requests returned
|
||||||
@@ -6335,7 +6606,16 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
enum: [all, available, partial, allavailable, processing, pending]
|
enum:
|
||||||
|
[
|
||||||
|
all,
|
||||||
|
available,
|
||||||
|
partial,
|
||||||
|
allavailable,
|
||||||
|
processing,
|
||||||
|
pending,
|
||||||
|
deleted,
|
||||||
|
]
|
||||||
- in: query
|
- in: query
|
||||||
name: sort
|
name: sort
|
||||||
schema:
|
schema:
|
||||||
@@ -6411,7 +6691,7 @@ paths:
|
|||||||
example: available
|
example: available
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [available, partial, processing, pending, unknown]
|
enum: [available, partial, processing, pending, unknown, deleted]
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@@ -7123,6 +7403,64 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WatchProviderDetails'
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/certifications/movie:
|
||||||
|
get:
|
||||||
|
summary: Get movie certifications
|
||||||
|
description: Returns list of movie certifications from TMDB.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
- apiKey: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Movie certifications returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CertificationResponse'
|
||||||
|
'500':
|
||||||
|
description: Unable to retrieve movie certifications
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: number
|
||||||
|
example: 500
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Unable to retrieve movie certifications.
|
||||||
|
/certifications/tv:
|
||||||
|
get:
|
||||||
|
summary: Get TV certifications
|
||||||
|
description: Returns list of TV show certifications from TMDB.
|
||||||
|
tags:
|
||||||
|
- other
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
- apiKey: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: TV certifications returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CertificationResponse'
|
||||||
|
'500':
|
||||||
|
description: Unable to retrieve TV certifications
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: number
|
||||||
|
example: 500
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Unable to retrieve TV certifications.
|
||||||
/overrideRule:
|
/overrideRule:
|
||||||
get:
|
get:
|
||||||
summary: Get override rules
|
summary: Get override rules
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
commitTag: process.env.COMMIT_TAG || 'local',
|
commitTag: process.env.COMMIT_TAG || 'local',
|
||||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -43,8 +43,11 @@
|
|||||||
"@supercharge/request-ip": "1.2.0",
|
"@supercharge/request-ip": "1.2.0",
|
||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.30",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@types/wink-jaro-distance": "^2.0.2",
|
"@types/wink-jaro-distance": "^2.0.2",
|
||||||
"ace-builds": "1.15.2",
|
"ace-builds": "1.15.2",
|
||||||
|
"axios": "1.3.4",
|
||||||
|
"axios-rate-limit": "1.3.0",
|
||||||
"bcrypt": "5.1.0",
|
"bcrypt": "5.1.0",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"connect-typeorm": "1.1.4",
|
"connect-typeorm": "1.1.4",
|
||||||
@@ -62,9 +65,11 @@
|
|||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"formik": "^2.4.6",
|
"formik": "^2.4.6",
|
||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.24",
|
"next": "^14.2.25",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-gyp": "9.3.1",
|
"node-gyp": "9.3.1",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
@@ -97,7 +102,8 @@
|
|||||||
"swagger-ui-express": "4.6.2",
|
"swagger-ui-express": "4.6.2",
|
||||||
"swr": "2.2.5",
|
"swr": "2.2.5",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.12",
|
||||||
|
"ua-parser-js": "^1.0.35",
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"wink-jaro-distance": "^2.0.0",
|
"wink-jaro-distance": "^2.0.0",
|
||||||
|
|||||||
229
pnpm-lock.yaml
generated
229
pnpm-lock.yaml
generated
@@ -41,12 +41,21 @@ importers:
|
|||||||
'@tanem/react-nprogress':
|
'@tanem/react-nprogress':
|
||||||
specifier: 5.0.30
|
specifier: 5.0.30
|
||||||
version: 5.0.30(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 5.0.30(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@types/ua-parser-js':
|
||||||
|
specifier: ^0.7.36
|
||||||
|
version: 0.7.39
|
||||||
'@types/wink-jaro-distance':
|
'@types/wink-jaro-distance':
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
ace-builds:
|
ace-builds:
|
||||||
specifier: 1.15.2
|
specifier: 1.15.2
|
||||||
version: 1.15.2
|
version: 1.15.2
|
||||||
|
axios:
|
||||||
|
specifier: 1.3.4
|
||||||
|
version: 1.3.4
|
||||||
|
axios-rate-limit:
|
||||||
|
specifier: 1.3.0
|
||||||
|
version: 1.3.0(axios@1.3.4)
|
||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: 5.1.0
|
specifier: 5.1.0
|
||||||
version: 5.1.0(encoding@0.1.13)
|
version: 5.1.0(encoding@0.1.13)
|
||||||
@@ -55,7 +64,7 @@ importers:
|
|||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
connect-typeorm:
|
connect-typeorm:
|
||||||
specifier: 1.1.4
|
specifier: 1.1.4
|
||||||
version: 1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
version: 1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)))
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: 1.4.7
|
specifier: 1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
@@ -98,6 +107,12 @@ importers:
|
|||||||
gravatar-url:
|
gravatar-url:
|
||||||
specifier: 3.1.0
|
specifier: 3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
|
http-proxy-agent:
|
||||||
|
specifier: ^7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
https-proxy-agent:
|
||||||
|
specifier: ^7.0.6
|
||||||
|
version: 7.0.6
|
||||||
lodash:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -105,8 +120,8 @@ importers:
|
|||||||
specifier: '3'
|
specifier: '3'
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.24
|
specifier: ^14.2.25
|
||||||
version: 14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
node-cache:
|
node-cache:
|
||||||
specifier: 5.1.2
|
specifier: 5.1.2
|
||||||
version: 5.1.2
|
version: 5.1.2
|
||||||
@@ -204,8 +219,11 @@ importers:
|
|||||||
specifier: ^2.6.0
|
specifier: ^2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
typeorm:
|
typeorm:
|
||||||
specifier: 0.3.11
|
specifier: 0.3.12
|
||||||
version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
version: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||||
|
ua-parser-js:
|
||||||
|
specifier: ^1.0.35
|
||||||
|
version: 1.0.40
|
||||||
undici:
|
undici:
|
||||||
specifier: ^7.3.0
|
specifier: ^7.3.0
|
||||||
version: 7.3.0
|
version: 7.3.0
|
||||||
@@ -2133,62 +2151,62 @@ packages:
|
|||||||
'@messageformat/runtime@3.0.1':
|
'@messageformat/runtime@3.0.1':
|
||||||
resolution: {integrity: sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==}
|
resolution: {integrity: sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==}
|
||||||
|
|
||||||
'@next/env@14.2.24':
|
'@next/env@14.2.25':
|
||||||
resolution: {integrity: sha512-LAm0Is2KHTNT6IT16lxT+suD0u+VVfYNQqM+EJTKuFRRuY2z+zj01kueWXPCxbMBDt0B5vONYzabHGUNbZYAhA==}
|
resolution: {integrity: sha512-JnzQ2cExDeG7FxJwqAksZ3aqVJrHjFwZQAEJ9gQZSoEhIow7SNoKZzju/AwQ+PLIR4NY8V0rhcVozx/2izDO0w==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@14.2.4':
|
'@next/eslint-plugin-next@14.2.4':
|
||||||
resolution: {integrity: sha512-svSFxW9f3xDaZA3idQmlFw7SusOuWTpDTAeBlO3AEPDltrraV+lqs7mAc6A27YdnpQVVIA3sODqUAAHdWhVWsA==}
|
resolution: {integrity: sha512-svSFxW9f3xDaZA3idQmlFw7SusOuWTpDTAeBlO3AEPDltrraV+lqs7mAc6A27YdnpQVVIA3sODqUAAHdWhVWsA==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@14.2.24':
|
'@next/swc-darwin-arm64@14.2.25':
|
||||||
resolution: {integrity: sha512-7Tdi13aojnAZGpapVU6meVSpNzgrFwZ8joDcNS8cJVNuP3zqqrLqeory9Xec5TJZR/stsGJdfwo8KeyloT3+rQ==}
|
resolution: {integrity: sha512-09clWInF1YRd6le00vt750s3m7SEYNehz9C4PUcSu3bAdCTpjIV4aTYQZ25Ehrr83VR1rZeqtKUPWSI7GfuKZQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@14.2.24':
|
'@next/swc-darwin-x64@14.2.25':
|
||||||
resolution: {integrity: sha512-lXR2WQqUtu69l5JMdTwSvQUkdqAhEWOqJEYUQ21QczQsAlNOW2kWZCucA6b3EXmPbcvmHB1kSZDua/713d52xg==}
|
resolution: {integrity: sha512-V+iYM/QR+aYeJl3/FWWU/7Ix4b07ovsQ5IbkwgUK29pTHmq+5UxeDr7/dphvtXEq5pLB/PucfcBNh9KZ8vWbug==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@14.2.24':
|
'@next/swc-linux-arm64-gnu@14.2.25':
|
||||||
resolution: {integrity: sha512-nxvJgWOpSNmzidYvvGDfXwxkijb6hL9+cjZx1PVG6urr2h2jUqBALkKjT7kpfurRWicK6hFOvarmaWsINT1hnA==}
|
resolution: {integrity: sha512-LFnV2899PJZAIEHQ4IMmZIgL0FBieh5keMnriMY1cK7ompR+JUd24xeTtKkcaw8QmxmEdhoE5Mu9dPSuDBgtTg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@14.2.24':
|
'@next/swc-linux-arm64-musl@14.2.25':
|
||||||
resolution: {integrity: sha512-PaBgOPhqa4Abxa3y/P92F3kklNPsiFjcjldQGT7kFmiY5nuFn8ClBEoX8GIpqU1ODP2y8P6hio6vTomx2Vy0UQ==}
|
resolution: {integrity: sha512-QC5y5PPTmtqFExcKWKYgUNkHeHE/z3lUsu83di488nyP0ZzQ3Yse2G6TCxz6nNsQwgAx1BehAJTZez+UQxzLfw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@14.2.24':
|
'@next/swc-linux-x64-gnu@14.2.25':
|
||||||
resolution: {integrity: sha512-vEbyadiRI7GOr94hd2AB15LFVgcJZQWu7Cdi9cWjCMeCiUsHWA0U5BkGPuoYRnTxTn0HacuMb9NeAmStfBCLoQ==}
|
resolution: {integrity: sha512-y6/ML4b9eQ2D/56wqatTJN5/JR8/xdObU2Fb1RBidnrr450HLCKr6IJZbPqbv7NXmje61UyxjF5kvSajvjye5w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@14.2.24':
|
'@next/swc-linux-x64-musl@14.2.25':
|
||||||
resolution: {integrity: sha512-df0FC9ptaYsd8nQCINCzFtDWtko8PNRTAU0/+d7hy47E0oC17tI54U/0NdGk7l/76jz1J377dvRjmt6IUdkpzQ==}
|
resolution: {integrity: sha512-sPX0TSXHGUOZFvv96GoBXpB3w4emMqKeMgemrSxI7A6l55VBJp/RKYLwZIB9JxSqYPApqiREaIIap+wWq0RU8w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@14.2.24':
|
'@next/swc-win32-arm64-msvc@14.2.25':
|
||||||
resolution: {integrity: sha512-ZEntbLjeYAJ286eAqbxpZHhDFYpYjArotQ+/TW9j7UROh0DUmX7wYDGtsTPpfCV8V+UoqHBPU7q9D4nDNH014Q==}
|
resolution: {integrity: sha512-ReO9S5hkA1DU2cFCsGoOEp7WJkhFzNbU/3VUF6XxNGUCQChyug6hZdYL/istQgfT/GWE6PNIg9cm784OI4ddxQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-ia32-msvc@14.2.24':
|
'@next/swc-win32-ia32-msvc@14.2.25':
|
||||||
resolution: {integrity: sha512-9KuS+XUXM3T6v7leeWU0erpJ6NsFIwiTFD5nzNg8J5uo/DMIPvCp3L1Ao5HjbHX0gkWPB1VrKoo/Il4F0cGK2Q==}
|
resolution: {integrity: sha512-DZ/gc0o9neuCDyD5IumyTGHVun2dCox5TfPQI/BJTYwpSNYM3CZDI4i6TOdjeq1JMo+Ug4kPSMuZdwsycwFbAw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@14.2.24':
|
'@next/swc-win32-x64-msvc@14.2.25':
|
||||||
resolution: {integrity: sha512-cXcJ2+x0fXQ2CntaE00d7uUH+u1Bfp/E0HsNQH79YiLaZE5Rbm7dZzyAYccn3uICM7mw+DxoMqEfGXZtF4Fgaw==}
|
resolution: {integrity: sha512-KSznmS6eFjQ9RJ1nEc66kJvtGIL1iZMYmGEXsZPh2YtnLtqrgdVvKXJY2ScjjoFnG6nGLyPFR0UiEvDwVah4Tw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -3412,6 +3430,9 @@ packages:
|
|||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
|
'@types/ua-parser-js@0.7.39':
|
||||||
|
resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
|
||||||
|
|
||||||
'@types/unist@2.0.10':
|
'@types/unist@2.0.10':
|
||||||
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
|
||||||
|
|
||||||
@@ -3611,6 +3632,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
|
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
agent-base@7.1.3:
|
||||||
|
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
agentkeepalive@4.5.0:
|
agentkeepalive@4.5.0:
|
||||||
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
|
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
@@ -3864,6 +3889,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
|
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
axios-rate-limit@1.3.0:
|
||||||
|
resolution: {integrity: sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==}
|
||||||
|
peerDependencies:
|
||||||
|
axios: '*'
|
||||||
|
|
||||||
|
axios@1.3.4:
|
||||||
|
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
|
||||||
|
|
||||||
axobject-query@3.1.1:
|
axobject-query@3.1.1:
|
||||||
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
||||||
|
|
||||||
@@ -5376,6 +5409,15 @@ packages:
|
|||||||
fn.name@1.1.0:
|
fn.name@1.1.0:
|
||||||
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.9:
|
||||||
|
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
for-each@0.3.3:
|
for-each@0.3.3:
|
||||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||||
|
|
||||||
@@ -5756,8 +5798,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@1.1.1:
|
human-signals@1.1.1:
|
||||||
@@ -6941,6 +6983,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mkdirp@2.1.6:
|
||||||
|
resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
modify-values@1.0.1:
|
modify-values@1.0.1:
|
||||||
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -7017,8 +7064,8 @@ packages:
|
|||||||
nerf-dart@1.0.0:
|
nerf-dart@1.0.0:
|
||||||
resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==}
|
resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==}
|
||||||
|
|
||||||
next@14.2.24:
|
next@14.2.25:
|
||||||
resolution: {integrity: sha512-En8VEexSJ0Py2FfVnRRh8gtERwDRaJGNvsvad47ShkC2Yi8AXQPXEA2vKoDJlGFSj5WE5SyF21zNi4M5gyi+SQ==}
|
resolution: {integrity: sha512-N5M7xMc4wSb4IkPvEV5X2BRRXUmhVHNyaXwEM86+voXthSZz8ZiRyQW4p9mwAoAPIm6OzuVZtn7idgEJeAJN3Q==}
|
||||||
engines: {node: '>=18.17.0'}
|
engines: {node: '>=18.17.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7840,6 +7887,9 @@ packages:
|
|||||||
proxy-from-env@1.0.0:
|
proxy-from-env@1.0.0:
|
||||||
resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
|
resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
psl@1.15.0:
|
psl@1.15.0:
|
||||||
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
|
||||||
|
|
||||||
@@ -9155,8 +9205,8 @@ packages:
|
|||||||
typedarray@0.0.6:
|
typedarray@0.0.6:
|
||||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||||
|
|
||||||
typeorm@0.3.11:
|
typeorm@0.3.12:
|
||||||
resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==}
|
resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==}
|
||||||
engines: {node: '>= 12.9.0'}
|
engines: {node: '>= 12.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9167,7 +9217,7 @@ packages:
|
|||||||
ioredis: ^5.0.4
|
ioredis: ^5.0.4
|
||||||
mongodb: ^3.6.0
|
mongodb: ^3.6.0
|
||||||
mssql: ^7.3.0
|
mssql: ^7.3.0
|
||||||
mysql2: ^2.2.5
|
mysql2: ^2.2.5 || ^3.0.1
|
||||||
oracledb: ^5.1.0
|
oracledb: ^5.1.0
|
||||||
pg: ^8.5.1
|
pg: ^8.5.1
|
||||||
pg-native: ^3.0.0
|
pg-native: ^3.0.0
|
||||||
@@ -9223,6 +9273,10 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ua-parser-js@1.0.40:
|
||||||
|
resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
uc.micro@2.1.0:
|
uc.micro@2.1.0:
|
||||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
@@ -11078,7 +11132,7 @@ snapshots:
|
|||||||
'@babel/helper-split-export-declaration': 7.24.7
|
'@babel/helper-split-export-declaration': 7.24.7
|
||||||
'@babel/parser': 7.24.7
|
'@babel/parser': 7.24.7
|
||||||
'@babel/types': 7.24.7
|
'@babel/types': 7.24.7
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
globals: 11.12.0
|
globals: 11.12.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -11860,37 +11914,37 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
make-plural: 7.4.0
|
make-plural: 7.4.0
|
||||||
|
|
||||||
'@next/env@14.2.24': {}
|
'@next/env@14.2.25': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@14.2.4':
|
'@next/eslint-plugin-next@14.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 10.3.10
|
glob: 10.3.10
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@14.2.24':
|
'@next/swc-darwin-arm64@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@14.2.24':
|
'@next/swc-darwin-x64@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@14.2.24':
|
'@next/swc-linux-arm64-gnu@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@14.2.24':
|
'@next/swc-linux-arm64-musl@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@14.2.24':
|
'@next/swc-linux-x64-gnu@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@14.2.24':
|
'@next/swc-linux-x64-musl@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@14.2.24':
|
'@next/swc-win32-arm64-msvc@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-ia32-msvc@14.2.24':
|
'@next/swc-win32-ia32-msvc@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@14.2.24':
|
'@next/swc-win32-x64-msvc@14.2.25':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -13295,7 +13349,7 @@ snapshots:
|
|||||||
fs-extra: 11.2.0
|
fs-extra: 11.2.0
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.5
|
https-proxy-agent: 7.0.6
|
||||||
issue-parser: 6.0.0
|
issue-parser: 6.0.0
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
mime: 3.0.0
|
mime: 3.0.0
|
||||||
@@ -13506,7 +13560,7 @@ snapshots:
|
|||||||
'@swc/helpers@0.5.5':
|
'@swc/helpers@0.5.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
tslib: 2.6.3
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@swc/types@0.1.17':
|
'@swc/types@0.1.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13778,6 +13832,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
|
'@types/ua-parser-js@0.7.39': {}
|
||||||
|
|
||||||
'@types/unist@2.0.10': {}
|
'@types/unist@2.0.10': {}
|
||||||
|
|
||||||
'@types/web-push@3.3.2':
|
'@types/web-push@3.3.2':
|
||||||
@@ -13915,7 +13971,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 7.2.0
|
'@typescript-eslint/types': 7.2.0
|
||||||
'@typescript-eslint/visitor-keys': 7.2.0
|
'@typescript-eslint/visitor-keys': 7.2.0
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
globby: 11.1.0
|
globby: 11.1.0
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
minimatch: 9.0.3
|
minimatch: 9.0.3
|
||||||
@@ -14006,10 +14062,12 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.1:
|
agent-base@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
agent-base@7.1.3: {}
|
||||||
|
|
||||||
agentkeepalive@4.5.0:
|
agentkeepalive@4.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
@@ -14270,6 +14328,18 @@ snapshots:
|
|||||||
|
|
||||||
axe-core@4.9.1: {}
|
axe-core@4.9.1: {}
|
||||||
|
|
||||||
|
axios-rate-limit@1.3.0(axios@1.3.4):
|
||||||
|
dependencies:
|
||||||
|
axios: 1.3.4
|
||||||
|
|
||||||
|
axios@1.3.4:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.9
|
||||||
|
form-data: 4.0.2
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
axobject-query@3.1.1:
|
axobject-query@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-equal: 2.2.3
|
deep-equal: 2.2.3
|
||||||
@@ -14860,13 +14930,13 @@ snapshots:
|
|||||||
ini: 1.3.8
|
ini: 1.3.8
|
||||||
proto-list: 1.2.4
|
proto-list: 1.2.4
|
||||||
|
|
||||||
connect-typeorm@1.1.4(typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
connect-typeorm@1.1.4(typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/debug': 0.0.31
|
'@types/debug': 0.0.31
|
||||||
'@types/express-session': 1.17.6
|
'@types/express-session': 1.17.6
|
||||||
debug: 4.4.0(supports-color@5.5.0)
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
express-session: 1.17.3
|
express-session: 1.17.3
|
||||||
typeorm: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
typeorm: 0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -15632,7 +15702,7 @@ snapshots:
|
|||||||
es-get-iterator@1.1.3:
|
es-get-iterator@1.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.7
|
call-bind: 1.0.7
|
||||||
get-intrinsic: 1.2.4
|
get-intrinsic: 1.3.0
|
||||||
has-symbols: 1.0.3
|
has-symbols: 1.0.3
|
||||||
is-arguments: 1.1.1
|
is-arguments: 1.1.1
|
||||||
is-map: 2.0.3
|
is-map: 2.0.3
|
||||||
@@ -16324,6 +16394,8 @@ snapshots:
|
|||||||
|
|
||||||
fn.name@1.1.0: {}
|
fn.name@1.1.0: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.9: {}
|
||||||
|
|
||||||
for-each@0.3.3:
|
for-each@0.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
@@ -16761,7 +16833,7 @@ snapshots:
|
|||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.1
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -16788,10 +16860,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
https-proxy-agent@7.0.5:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.1
|
agent-base: 7.1.3
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -17098,7 +17170,7 @@ snapshots:
|
|||||||
is-weakset@2.0.3:
|
is-weakset@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.7
|
call-bind: 1.0.7
|
||||||
get-intrinsic: 1.2.4
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
is-windows@1.0.2: {}
|
is-windows@1.0.2: {}
|
||||||
|
|
||||||
@@ -18227,6 +18299,8 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@1.0.4: {}
|
mkdirp@1.0.4: {}
|
||||||
|
|
||||||
|
mkdirp@2.1.6: {}
|
||||||
|
|
||||||
modify-values@1.0.1: {}
|
modify-values@1.0.1: {}
|
||||||
|
|
||||||
moment@2.30.1: {}
|
moment@2.30.1: {}
|
||||||
@@ -18289,27 +18363,27 @@ snapshots:
|
|||||||
|
|
||||||
nerf-dart@1.0.0: {}
|
nerf-dart@1.0.0: {}
|
||||||
|
|
||||||
next@14.2.24(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
next@14.2.25(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 14.2.24
|
'@next/env': 14.2.25
|
||||||
'@swc/helpers': 0.5.5
|
'@swc/helpers': 0.5.5
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
caniuse-lite: 1.0.30001636
|
caniuse-lite: 1.0.30001700
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.3.1)
|
styled-jsx: 5.1.1(@babel/core@7.24.7)(react@18.3.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 14.2.24
|
'@next/swc-darwin-arm64': 14.2.25
|
||||||
'@next/swc-darwin-x64': 14.2.24
|
'@next/swc-darwin-x64': 14.2.25
|
||||||
'@next/swc-linux-arm64-gnu': 14.2.24
|
'@next/swc-linux-arm64-gnu': 14.2.25
|
||||||
'@next/swc-linux-arm64-musl': 14.2.24
|
'@next/swc-linux-arm64-musl': 14.2.25
|
||||||
'@next/swc-linux-x64-gnu': 14.2.24
|
'@next/swc-linux-x64-gnu': 14.2.25
|
||||||
'@next/swc-linux-x64-musl': 14.2.24
|
'@next/swc-linux-x64-musl': 14.2.25
|
||||||
'@next/swc-win32-arm64-msvc': 14.2.24
|
'@next/swc-win32-arm64-msvc': 14.2.25
|
||||||
'@next/swc-win32-ia32-msvc': 14.2.24
|
'@next/swc-win32-ia32-msvc': 14.2.25
|
||||||
'@next/swc-win32-x64-msvc': 14.2.24
|
'@next/swc-win32-x64-msvc': 14.2.25
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
@@ -19000,6 +19074,8 @@ snapshots:
|
|||||||
|
|
||||||
proxy-from-env@1.0.0: {}
|
proxy-from-env@1.0.0: {}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
psl@1.15.0:
|
psl@1.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
@@ -19510,7 +19586,7 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-abstract: 1.23.3
|
es-abstract: 1.23.3
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
get-intrinsic: 1.2.4
|
get-intrinsic: 1.3.0
|
||||||
globalthis: 1.0.4
|
globalthis: 1.0.4
|
||||||
which-builtin-type: 1.1.3
|
which-builtin-type: 1.1.3
|
||||||
|
|
||||||
@@ -20642,7 +20718,7 @@ snapshots:
|
|||||||
|
|
||||||
typedarray@0.0.6: {}
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typeorm@0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
typeorm@0.3.12(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sqltools/formatter': 1.2.5
|
'@sqltools/formatter': 1.2.5
|
||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
@@ -20650,15 +20726,15 @@ snapshots:
|
|||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cli-highlight: 2.1.11
|
cli-highlight: 2.1.11
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
debug: 4.3.5
|
debug: 4.4.0(supports-color@5.5.0)
|
||||||
dotenv: 16.4.5
|
dotenv: 16.4.5
|
||||||
glob: 7.2.3
|
glob: 8.1.0
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
mkdirp: 1.0.4
|
mkdirp: 2.1.6
|
||||||
reflect-metadata: 0.1.13
|
reflect-metadata: 0.1.13
|
||||||
sha.js: 2.4.11
|
sha.js: 2.4.11
|
||||||
tslib: 2.6.3
|
tslib: 2.8.1
|
||||||
uuid: 8.3.2
|
uuid: 9.0.1
|
||||||
xml2js: 0.4.23
|
xml2js: 0.4.23
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -20672,6 +20748,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.5.2: {}
|
typescript@5.5.2: {}
|
||||||
|
|
||||||
|
ua-parser-js@1.0.40: {}
|
||||||
|
|
||||||
uc.micro@2.1.0:
|
uc.micro@2.1.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -20825,8 +20903,7 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1:
|
uuid@9.0.1: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
uvu@0.5.6:
|
uvu@0.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import fs, { promises as fsp } from 'node:fs';
|
import axios from 'axios';
|
||||||
import path from 'node:path';
|
import fs, { promises as fsp } from 'fs';
|
||||||
import { Readable } from 'node:stream';
|
import path from 'path';
|
||||||
import type { ReadableStream } from 'node:stream/web';
|
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
|
||||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||||
@@ -162,18 +161,14 @@ class AnimeListMapping {
|
|||||||
label: 'Anime-List Sync',
|
label: 'Anime-List Sync',
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(MAPPING_URL);
|
const response = await axios.get(MAPPING_URL, {
|
||||||
if (!response.ok) {
|
responseType: 'stream',
|
||||||
throw new Error(`Failed to fetch: ${response.statusText}`);
|
});
|
||||||
}
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const writer = fs.createWriteStream(LOCAL_PATH);
|
const writer = fs.createWriteStream(LOCAL_PATH);
|
||||||
writer.on('finish', resolve);
|
writer.on('finish', resolve);
|
||||||
writer.on('error', reject);
|
writer.on('error', reject);
|
||||||
if (!response.body) return reject();
|
response.data.pipe(writer);
|
||||||
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
|
|
||||||
writer
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import axios from 'axios';
|
||||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
import rateLimit from 'axios-rate-limit';
|
||||||
import rateLimit from '@server/utils/rateLimit';
|
|
||||||
import type NodeCache from 'node-cache';
|
import type NodeCache from 'node-cache';
|
||||||
|
|
||||||
// 5 minute default TTL (in seconds)
|
// 5 minute default TTL (in seconds)
|
||||||
@@ -13,109 +12,75 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
|||||||
interface ExternalAPIOptions {
|
interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
rateLimit?: RateLimitOptions;
|
rateLimit?: {
|
||||||
|
maxRPS: number;
|
||||||
|
maxRequests: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExternalAPI {
|
class ExternalAPI {
|
||||||
protected fetch: typeof fetch;
|
protected axios: AxiosInstance;
|
||||||
protected params: Record<string, string>;
|
|
||||||
protected defaultHeaders: { [key: string]: string };
|
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private cache?: NodeCache;
|
private cache?: NodeCache;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
params: Record<string, string> = {},
|
params: Record<string, unknown>,
|
||||||
options: ExternalAPIOptions = {}
|
options: ExternalAPIOptions = {}
|
||||||
) {
|
) {
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
params,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (options.rateLimit) {
|
if (options.rateLimit) {
|
||||||
this.fetch = rateLimit(fetch, options.rateLimit);
|
this.axios = rateLimit(this.axios, {
|
||||||
} else {
|
maxRequests: options.rateLimit.maxRequests,
|
||||||
this.fetch = fetch;
|
maxRPS: options.rateLimit.maxRPS,
|
||||||
}
|
});
|
||||||
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
|
|
||||||
const settings = getSettings();
|
|
||||||
|
|
||||||
this.defaultHeaders = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
...((url.username || url.password) && {
|
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${url.username}:${url.password}`
|
|
||||||
).toString('base64')}`,
|
|
||||||
}),
|
|
||||||
...(settings.main.mediaServerType === MediaServerType.EMBY && {
|
|
||||||
'Accept-Encoding': 'gzip',
|
|
||||||
}),
|
|
||||||
...options.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (url.username || url.password) {
|
|
||||||
url.username = '';
|
|
||||||
url.password = '';
|
|
||||||
baseUrl = url.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.params = params;
|
|
||||||
this.cache = options.nodeCache;
|
this.cache = options.nodeCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async get<T>(
|
protected async get<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...config?.params,
|
||||||
...params,
|
headers: config?.headers,
|
||||||
headers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
if (cachedItem) {
|
if (cachedItem) {
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
const response = await this.axios.get<T>(endpoint, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
...config,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async post<T>(
|
protected async post<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
config: { ...this.params, ...params },
|
config: config?.params,
|
||||||
headers,
|
...(data ? { data } : {}),
|
||||||
data,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
@@ -123,115 +88,23 @@ class ExternalAPI {
|
|||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
const response = await this.axios.post<T>(endpoint, data, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
...config,
|
|
||||||
headers,
|
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resData;
|
return response.data;
|
||||||
}
|
|
||||||
|
|
||||||
protected async put<T>(
|
|
||||||
endpoint: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
ttl?: number,
|
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
|
||||||
config: { ...this.params, ...params },
|
|
||||||
data,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
|
||||||
if (cachedItem) {
|
|
||||||
return cachedItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
...config,
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const resData = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache && ttl !== 0) {
|
|
||||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resData;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async delete<T>(
|
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
config?: RequestInit
|
|
||||||
): Promise<T> {
|
|
||||||
const url = this.formatUrl(endpoint, params);
|
|
||||||
const response = await this.fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...this.defaultHeaders,
|
|
||||||
...config?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getRolling<T>(
|
protected async getRolling<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
params?: Record<string, string>,
|
config?: AxiosRequestConfig,
|
||||||
ttl?: number,
|
ttl?: number
|
||||||
config?: RequestInit,
|
|
||||||
overwriteBaseUrl?: string
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = { ...this.defaultHeaders, ...config?.headers };
|
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
...config?.params,
|
||||||
...params,
|
headers: config?.headers,
|
||||||
headers,
|
|
||||||
});
|
});
|
||||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||||
|
|
||||||
@@ -243,82 +116,29 @@ class ExternalAPI {
|
|||||||
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
||||||
Date.now() - DEFAULT_ROLLING_BUFFER
|
Date.now() - DEFAULT_ROLLING_BUFFER
|
||||||
) {
|
) {
|
||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
this.axios.get<T>(endpoint, config).then((response) => {
|
||||||
this.fetch(url, {
|
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
...config,
|
|
||||||
headers,
|
|
||||||
}).then(async (response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${
|
|
||||||
text ? ': ' + text : ''
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return cachedItem;
|
return cachedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
const response = await this.axios.get<T>(endpoint, config);
|
||||||
const response = await this.fetch(url, {
|
|
||||||
...config,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
|
||||||
{
|
|
||||||
cause: response,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = await this.getDataFromResponse(response);
|
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache && ttl !== 0) {
|
||||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected removeCache(endpoint: string, options?: Record<string, string>) {
|
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
|
||||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||||
...this.params,
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
this.cache?.del(cacheKey);
|
this.cache?.del(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatUrl(
|
|
||||||
endpoint: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
overwriteBaseUrl?: string
|
|
||||||
): string {
|
|
||||||
const baseUrl = overwriteBaseUrl || this.baseUrl;
|
|
||||||
const href =
|
|
||||||
baseUrl +
|
|
||||||
(baseUrl.endsWith('/') ? '' : '/') +
|
|
||||||
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
...this.params,
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
href +
|
|
||||||
(searchParams.toString().length
|
|
||||||
? '?' + searchParams.toString()
|
|
||||||
: searchParams.toString())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: Record<string, unknown>
|
options?: Record<string, unknown>
|
||||||
@@ -329,29 +149,6 @@ class ExternalAPI {
|
|||||||
|
|
||||||
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
|
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDataFromResponse(response: Response) {
|
|
||||||
const contentType = response.headers.get('Content-Type');
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
return await response.json();
|
|
||||||
} else if (
|
|
||||||
contentType?.includes('application/xml') ||
|
|
||||||
contentType?.includes('text/html') ||
|
|
||||||
contentType?.includes('text/plain')
|
|
||||||
) {
|
|
||||||
return await response.text();
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
return await response.blob();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ExternalAPI;
|
export default ExternalAPI;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -67,6 +67,10 @@ class GithubAPI extends ExternalAPI {
|
|||||||
'https://api.github.com',
|
'https://api.github.com',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
nodeCache: cacheManager.getCache('github').data,
|
nodeCache: cacheManager.getCache('github').data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -81,7 +85,9 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GitHubRelease[]>(
|
const data = await this.get<GitHubRelease[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/releases',
|
'/repos/fallenbagel/jellyseerr/releases',
|
||||||
{
|
{
|
||||||
per_page: take.toString(),
|
params: {
|
||||||
|
per_page: take,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,8 +112,10 @@ class GithubAPI extends ExternalAPI {
|
|||||||
const data = await this.get<GithubCommit[]>(
|
const data = await this.get<GithubCommit[]>(
|
||||||
'/repos/fallenbagel/jellyseerr/commits',
|
'/repos/fallenbagel/jellyseerr/commits',
|
||||||
{
|
{
|
||||||
per_page: take.toString(),
|
params: {
|
||||||
branch,
|
per_page: take,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ export interface JellyfinUserResponse {
|
|||||||
PrimaryImageTag?: string;
|
PrimaryImageTag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinDevice {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
LastUserName: string;
|
||||||
|
AppName: string;
|
||||||
|
AppVersion: string;
|
||||||
|
LastUserId: string;
|
||||||
|
DateLastActivity: string;
|
||||||
|
Capabilities: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JellyfinDevicesResponse {
|
||||||
|
Items: JellyfinDevice[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
StartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinLoginResponse {
|
export interface JellyfinLoginResponse {
|
||||||
User: JellyfinUserResponse;
|
User: JellyfinUserResponse;
|
||||||
AccessToken: string;
|
AccessToken: string;
|
||||||
@@ -110,11 +127,16 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
deviceId?: string | null
|
deviceId?: string | null
|
||||||
) {
|
) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
const safeDeviceId =
|
||||||
|
deviceId && deviceId.length > 0
|
||||||
|
? deviceId
|
||||||
|
: Buffer.from('BOT_jellyseerr').toString('base64');
|
||||||
|
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||||
} else {
|
} else {
|
||||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}"`;
|
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
super(
|
super(
|
||||||
@@ -123,6 +145,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': authHeaderVal,
|
'X-Emby-Authorization': authHeaderVal,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -136,7 +160,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
ClientIP?: string
|
ClientIP?: string
|
||||||
): Promise<JellyfinLoginResponse> {
|
): Promise<JellyfinLoginResponse> {
|
||||||
const authenticate = async (useHeaders: boolean) => {
|
const authenticate = async (useHeaders: boolean) => {
|
||||||
const headers: { [key: string]: string } =
|
const headers =
|
||||||
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||||
|
|
||||||
return this.post<JellyfinLoginResponse>(
|
return this.post<JellyfinLoginResponse>(
|
||||||
@@ -145,8 +169,6 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
},
|
},
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -156,36 +178,36 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Failed to authenticate with headers', {
|
logger.debug('Failed to authenticate with headers', {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.statusText,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!e.cause.status) {
|
if (!e.response?.status) {
|
||||||
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.cause.status === 401) {
|
if (e.response?.status === 401) {
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.cause.status === 401) {
|
if (e.response?.status === 401) {
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while authenticating with the Jellyfin server',
|
`Something went wrong while authenticating with the Jellyfin server: ${e.message}`,
|
||||||
{
|
{
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.status,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +222,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,11 +235,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the server name from the Jellyfin server',
|
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +250,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,11 +266,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the account from the Jellyfin server',
|
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,10 +290,10 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting libraries from the Jellyfin server',
|
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
||||||
{
|
{
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
error: e.cause.message ?? e.cause.statusText,
|
error: e.response?.status,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,26 +330,20 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const libraryItemsResponse = await this.get<any>(`/Items`, {
|
const libraryItemsResponse = await this.get<any>(
|
||||||
SortBy: 'SortName',
|
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||||
SortOrder: 'Ascending',
|
);
|
||||||
IncludeItemTypes: 'Series,Movie,Others',
|
|
||||||
Recursive: 'true',
|
|
||||||
StartIndex: '0',
|
|
||||||
ParentId: id,
|
|
||||||
collapseBoxSetItems: 'false',
|
|
||||||
});
|
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
(item: JellyfinLibraryItem) => item.LocationType !== 'Virtual'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e?.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,27 +353,22 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
this.mediaServerType === MediaServerType.JELLYFIN
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? `/Items/Latest`
|
? `/Items/Latest`
|
||||||
: `/Users/${this.userId}/Items/Latest`;
|
: `/Users/${this.userId}/Items/Latest`;
|
||||||
|
const itemResponse = await this.get<any>(
|
||||||
const baseParams = {
|
`${endpoint}?Limit=12&ParentId=${id}${
|
||||||
Limit: '12',
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
ParentId: id,
|
? `&userId=${this.userId ?? 'Me'}`
|
||||||
};
|
: ''
|
||||||
|
}`
|
||||||
const params =
|
);
|
||||||
this.mediaServerType === MediaServerType.JELLYFIN
|
|
||||||
? { ...baseParams, userId: this.userId ?? `Me` }
|
|
||||||
: baseParams;
|
|
||||||
|
|
||||||
const itemResponse = await this.get<any>(endpoint, params);
|
|
||||||
|
|
||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,23 +377,25 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||||
try {
|
try {
|
||||||
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
||||||
ids: id,
|
params: {
|
||||||
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
ids: id,
|
||||||
|
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return itemResponse.Items?.[0];
|
return itemResponse.Items?.[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.cause?.status === 500) {
|
if (e.response?.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting library content from the Jellyfin server',
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,11 +406,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,10 +420,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
): Promise<JellyfinLibraryItem[]> {
|
): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
const episodeResponse = await this.get<any>(
|
const episodeResponse = await this.get<any>(
|
||||||
`/Shows/${seriesID}/Episodes`,
|
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||||
{
|
|
||||||
seasonId: seasonID,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return episodeResponse.Items.filter(
|
return episodeResponse.Items.filter(
|
||||||
@@ -418,11 +428,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,8 +445,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
).AccessToken;
|
).AccessToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong while creating an API key from the Jellyfin server',
|
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
{ label: 'Jellyfin API', error: e.response?.status }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import xml2js from 'xml2js';
|
import xml2js from 'xml2js';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PlexAccountResponse {
|
interface PlexAccountResponse {
|
||||||
user: PlexUser;
|
user: PlexUser;
|
||||||
@@ -143,6 +143,8 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Plex-Token': authToken,
|
'X-Plex-Token': authToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('plextv').data,
|
nodeCache: cacheManager.getCache('plextv').data,
|
||||||
}
|
}
|
||||||
@@ -153,11 +155,15 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getDevices(): Promise<PlexDevice[]> {
|
public async getDevices(): Promise<PlexDevice[]> {
|
||||||
try {
|
try {
|
||||||
const devicesResp = await this.get('/api/resources', {
|
const devicesResp = await this.axios.get(
|
||||||
includeHttps: '1',
|
'/api/resources?includeHttps=1',
|
||||||
});
|
{
|
||||||
|
transformResponse: [],
|
||||||
|
responseType: 'text',
|
||||||
|
}
|
||||||
|
);
|
||||||
const parsedXml = await xml2js.parseStringPromise(
|
const parsedXml = await xml2js.parseStringPromise(
|
||||||
devicesResp as DeviceResponse
|
devicesResp.data as DeviceResponse
|
||||||
);
|
);
|
||||||
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
||||||
name: pxml.$.name,
|
name: pxml.$.name,
|
||||||
@@ -205,11 +211,11 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async getUser(): Promise<PlexUser> {
|
public async getUser(): Promise<PlexUser> {
|
||||||
try {
|
try {
|
||||||
const account = await this.get<PlexAccountResponse>(
|
const account = await this.axios.get<PlexAccountResponse>(
|
||||||
'/users/account.json'
|
'/users/account.json'
|
||||||
);
|
);
|
||||||
|
|
||||||
return account.user;
|
return account.data.user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
||||||
@@ -249,10 +255,13 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUsers(): Promise<UsersResponse> {
|
public async getUsers(): Promise<UsersResponse> {
|
||||||
const data = await this.get('/api/users');
|
const response = await this.axios.get('/api/users', {
|
||||||
|
transformResponse: [],
|
||||||
|
responseType: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
const parsedXml = (await xml2js.parseStringPromise(
|
const parsedXml = (await xml2js.parseStringPromise(
|
||||||
data as string
|
response.data
|
||||||
)) as UsersResponse;
|
)) as UsersResponse;
|
||||||
return parsedXml;
|
return parsedXml;
|
||||||
}
|
}
|
||||||
@@ -272,28 +281,26 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
this.authToken
|
this.authToken
|
||||||
);
|
);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const response = await this.axios.get<WatchlistResponse>(
|
||||||
'X-Plex-Container-Start': offset.toString(),
|
'/library/sections/watchlist/all',
|
||||||
'X-Plex-Container-Size': size.toString(),
|
|
||||||
});
|
|
||||||
const response = await this.fetch(
|
|
||||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
|
||||||
{
|
{
|
||||||
headers: {
|
params: {
|
||||||
...this.defaultHeaders,
|
'X-Plex-Container-Start': offset,
|
||||||
...(cachedWatchlist?.etag
|
'X-Plex-Container-Size': size,
|
||||||
? { 'If-None-Match': cachedWatchlist.etag }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'If-None-Match': cachedWatchlist?.etag,
|
||||||
|
},
|
||||||
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
|
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as WatchlistResponse;
|
|
||||||
|
|
||||||
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
cachedWatchlist = {
|
cachedWatchlist = {
|
||||||
etag: response.headers.get('etag') ?? '',
|
etag: response.headers.etag,
|
||||||
response: data,
|
response: response.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
watchlistCache.data.set<PlexWatchlistCache>(
|
watchlistCache.data.set<PlexWatchlistCache>(
|
||||||
@@ -307,10 +314,9 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
async (watchlistItem) => {
|
async (watchlistItem) => {
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
{},
|
{
|
||||||
undefined,
|
baseURL: 'https://metadata.provider.plex.tv',
|
||||||
{},
|
}
|
||||||
'https://metadata.provider.plex.tv'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
@@ -361,17 +367,12 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async pingToken() {
|
public async pingToken() {
|
||||||
try {
|
try {
|
||||||
const data: { pong: unknown } = await this.get(
|
const response = await this.axios.get('/api/v2/ping', {
|
||||||
'/api/v2/ping',
|
headers: {
|
||||||
{},
|
'X-Plex-Client-Identifier': randomUUID(),
|
||||||
undefined,
|
},
|
||||||
{
|
});
|
||||||
headers: {
|
if (!response?.data?.pong) {
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.pong) {
|
|
||||||
throw new Error('No pong response');
|
throw new Error('No pong response');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
interface PushoverSoundsResponse {
|
interface PushoverSoundsResponse {
|
||||||
sounds: {
|
sounds: {
|
||||||
@@ -26,13 +26,24 @@ export const mapSounds = (sounds: {
|
|||||||
|
|
||||||
class PushoverAPI extends ExternalAPI {
|
class PushoverAPI extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('https://api.pushover.net/1');
|
super(
|
||||||
|
'https://api.pushover.net/1',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||||
token: appToken,
|
params: {
|
||||||
|
token: appToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapSounds(data.sounds);
|
return mapSounds(data.sounds);
|
||||||
|
|||||||
@@ -155,13 +155,13 @@ export interface IMDBRating {
|
|||||||
*/
|
*/
|
||||||
class IMDBRadarrProxy extends ExternalAPI {
|
class IMDBRadarrProxy extends ExternalAPI {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super('https://api.radarr.video/v1', {
|
||||||
'https://api.radarr.video/v1',
|
headers: {
|
||||||
{},
|
'Content-Type': 'application/json',
|
||||||
{
|
Accept: 'application/json',
|
||||||
nodeCache: cacheManager.getCache('imdb').data,
|
},
|
||||||
}
|
nodeCache: cacheManager.getCache('imdb').data,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -105,12 +105,15 @@ class RottenTomatoes extends ExternalAPI {
|
|||||||
super(
|
super(
|
||||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||||
{
|
{
|
||||||
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
|
'x-algolia-agent':
|
||||||
|
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||||
'x-algolia-application-id': '79FRDP12PN',
|
'x-algolia-application-id': '79FRDP12PN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
'x-algolia-usertoken': settings.clientId,
|
'x-algolia-usertoken': settings.clientId,
|
||||||
},
|
},
|
||||||
nodeCache: cacheManager.getCache('rt').data,
|
nodeCache: cacheManager.getCache('rt').data,
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getSystemStatus = async (): Promise<SystemStatus> => {
|
public getSystemStatus = async (): Promise<SystemStatus> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SystemStatus>('/system/status');
|
const response = await this.axios.get<SystemStatus>('/system/status');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
||||||
@@ -157,15 +157,16 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(
|
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||||
`/queue`,
|
`/queue`,
|
||||||
{
|
{
|
||||||
includeEpisode: 'true',
|
params: {
|
||||||
},
|
includeEpisode: true,
|
||||||
0
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.records;
|
return response.data.records;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
||||||
@@ -175,9 +176,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public getTags = async (): Promise<Tag[]> => {
|
public getTags = async (): Promise<Tag[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<Tag[]>(`/tag`);
|
const response = await this.axios.get<Tag[]>(`/tag`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
||||||
@@ -187,11 +188,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
|
|
||||||
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.post<Tag>(`/tag`, {
|
const response = await this.axios.post<Tag>(`/tag`, {
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -206,15 +207,10 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
|||||||
options: Record<string, unknown>
|
options: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.post(
|
await this.axios.post(`/command`, {
|
||||||
`/command`,
|
name: commandName,
|
||||||
{
|
...options,
|
||||||
name: commandName,
|
});
|
||||||
...options,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,39 @@ export interface RadarrMovie {
|
|||||||
qualityProfileId: number;
|
qualityProfileId: number;
|
||||||
added: string;
|
added: string;
|
||||||
hasFile: boolean;
|
hasFile: boolean;
|
||||||
|
tags: number[];
|
||||||
|
movieFile?: {
|
||||||
|
id: number;
|
||||||
|
movieId: number;
|
||||||
|
relativePath?: string;
|
||||||
|
path?: string;
|
||||||
|
size: number;
|
||||||
|
dateAdded: string;
|
||||||
|
sceneName?: string;
|
||||||
|
releaseGroup?: string;
|
||||||
|
edition?: string;
|
||||||
|
indexerFlags?: number;
|
||||||
|
mediaInfo: {
|
||||||
|
id: number;
|
||||||
|
audioBitrate: number;
|
||||||
|
audioChannels: number;
|
||||||
|
audioCodec?: string;
|
||||||
|
audioLanguages?: string;
|
||||||
|
audioStreamCount: number;
|
||||||
|
videoBitDepth: number;
|
||||||
|
videoBitrate: number;
|
||||||
|
videoCodec?: string;
|
||||||
|
videoFps: number;
|
||||||
|
videoDynamicRange?: string;
|
||||||
|
videoDynamicRangeType?: string;
|
||||||
|
resolution?: string;
|
||||||
|
runTime?: string;
|
||||||
|
scanType?: string;
|
||||||
|
subtitles?: string;
|
||||||
|
};
|
||||||
|
originalFilePath?: string;
|
||||||
|
qualityCutoffNotMet: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||||
@@ -37,9 +70,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie[]>('/movie');
|
const response = await this.axios.get<RadarrMovie[]>('/movie');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
|
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -47,9 +80,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
|
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie>(`/movie/${id}`);
|
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
|
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -57,15 +90,17 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
|
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||||
term: `tmdb:${id}`,
|
params: {
|
||||||
|
term: `tmdb:${id}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('Movie not found');
|
throw new Error('Movie not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data[0];
|
return response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving movie by TMDB ID', {
|
logger.error('Error retrieving movie by TMDB ID', {
|
||||||
label: 'Radarr API',
|
label: 'Radarr API',
|
||||||
@@ -95,7 +130,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
|
|
||||||
// movie exists in Radarr but is neither downloaded nor monitored
|
// movie exists in Radarr but is neither downloaded nor monitored
|
||||||
if (movie.id && !movie.monitored) {
|
if (movie.id && !movie.monitored) {
|
||||||
const data = await this.put<RadarrMovie>(`/movie`, {
|
const response = await this.axios.put<RadarrMovie>(`/movie`, {
|
||||||
...movie,
|
...movie,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
@@ -104,7 +139,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
minimumAvailability: options.minimumAvailability,
|
minimumAvailability: options.minimumAvailability,
|
||||||
tmdbId: options.tmdbId,
|
tmdbId: options.tmdbId,
|
||||||
year: options.year,
|
year: options.year,
|
||||||
tags: options.tags,
|
tags: Array.from(new Set([...movie.tags, ...options.tags])),
|
||||||
rootFolderPath: options.rootFolderPath,
|
rootFolderPath: options.rootFolderPath,
|
||||||
monitored: options.monitored,
|
monitored: options.monitored,
|
||||||
addOptions: {
|
addOptions: {
|
||||||
@@ -112,25 +147,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.monitored) {
|
if (response.data.monitored) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Found existing title in Radarr and set it to monitored.',
|
'Found existing title in Radarr and set it to monitored.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movieId: data.id,
|
movieId: response.data.id,
|
||||||
movieTitle: data.title,
|
movieTitle: response.data.title,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
logger.debug('Radarr update details', {
|
logger.debug('Radarr update details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: data,
|
movie: response.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchMovie(data.id);
|
this.searchMovie(response.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update existing movie in Radarr.', {
|
logger.error('Failed to update existing movie in Radarr.', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
@@ -148,7 +183,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
return movie;
|
return movie;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await this.post<RadarrMovie>(`/movie`, {
|
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||||
title: options.title,
|
title: options.title,
|
||||||
qualityProfileId: options.qualityProfileId,
|
qualityProfileId: options.qualityProfileId,
|
||||||
profileId: options.profileId,
|
profileId: options.profileId,
|
||||||
@@ -164,11 +199,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.id) {
|
if (response.data.id) {
|
||||||
logger.info('Radarr accepted request', { label: 'Radarr' });
|
logger.info('Radarr accepted request', { label: 'Radarr' });
|
||||||
logger.debug('Radarr add details', {
|
logger.debug('Radarr add details', {
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
movie: data,
|
movie: response.data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Radarr', {
|
logger.error('Failed to add movie to Radarr', {
|
||||||
@@ -177,22 +212,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
});
|
});
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
}
|
}
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error(
|
logger.error(
|
||||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
@@ -221,9 +249,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
public removeMovie = async (movieId: number): Promise<void> => {
|
public removeMovie = async (movieId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||||
await this.delete(`/movie/${id}`, {
|
await this.axios.delete(`/movie/${id}`, {
|
||||||
deleteFiles: 'true',
|
params: {
|
||||||
addImportExclusion: 'false',
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed movie ${title}`);
|
logger.info(`[Radarr] Removed movie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeries(): Promise<SonarrSeries[]> {
|
public async getSeries(): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series');
|
const response = await this.axios.get<SonarrSeries[]>('/series');
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
|
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -127,9 +127,9 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
public async getSeriesById(id: number): Promise<SonarrSeries> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries>(`/series/${id}`);
|
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||||
}
|
}
|
||||||
@@ -137,15 +137,17 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
term: title,
|
params: {
|
||||||
|
term: title,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('No series found');
|
throw new Error('No series found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving series by series title', {
|
logger.error('Error retrieving series by series title', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
@@ -158,15 +160,17 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||||
term: `tvdb:${id}`,
|
params: {
|
||||||
|
term: `tvdb:${id}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data[0]) {
|
if (!response.data[0]) {
|
||||||
throw new Error('Series not found');
|
throw new Error('Series not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return data[0];
|
return response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error retrieving series by tvdb ID', {
|
logger.error('Error retrieving series by tvdb ID', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
@@ -184,30 +188,32 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
// If the series already exists, we will simply just update it
|
// If the series already exists, we will simply just update it
|
||||||
if (series.id) {
|
if (series.id) {
|
||||||
series.monitored = options.monitored ?? series.monitored;
|
series.monitored = options.monitored ?? series.monitored;
|
||||||
series.tags = options.tags ?? series.tags;
|
series.tags = options.tags
|
||||||
|
? Array.from(new Set([...series.tags, ...options.tags]))
|
||||||
|
: series.tags;
|
||||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||||
|
|
||||||
const newSeriesData = await this.put<SonarrSeries>(
|
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||||
'/series',
|
'/series',
|
||||||
series as any
|
series
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newSeriesData.id) {
|
if (newSeriesResponse.data.id) {
|
||||||
logger.info('Updated existing series in Sonarr.', {
|
logger.info('Updated existing series in Sonarr.', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
seriesId: newSeriesData.id,
|
seriesId: newSeriesResponse.data.id,
|
||||||
seriesTitle: newSeriesData.title,
|
seriesTitle: newSeriesResponse.data.title,
|
||||||
});
|
});
|
||||||
logger.debug('Sonarr update details', {
|
logger.debug('Sonarr update details', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: newSeriesData,
|
series: newSeriesResponse.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.searchNow) {
|
if (options.searchNow) {
|
||||||
this.searchSeries(newSeriesData.id);
|
this.searchSeries(newSeriesResponse.data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSeriesData;
|
return newSeriesResponse.data;
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to update series in Sonarr', {
|
logger.error('Failed to update series in Sonarr', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
@@ -217,35 +223,38 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdSeriesData = await this.post<SonarrSeries>('/series', {
|
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||||
tvdbId: options.tvdbid,
|
'/series',
|
||||||
title: options.title,
|
{
|
||||||
qualityProfileId: options.profileId,
|
tvdbId: options.tvdbid,
|
||||||
languageProfileId: options.languageProfileId,
|
title: options.title,
|
||||||
seasons: this.buildSeasonList(
|
qualityProfileId: options.profileId,
|
||||||
options.seasons,
|
languageProfileId: options.languageProfileId,
|
||||||
series.seasons.map((season) => ({
|
seasons: this.buildSeasonList(
|
||||||
seasonNumber: season.seasonNumber,
|
options.seasons,
|
||||||
// We force all seasons to false if its the first request
|
series.seasons.map((season) => ({
|
||||||
monitored: false,
|
seasonNumber: season.seasonNumber,
|
||||||
}))
|
// We force all seasons to false if its the first request
|
||||||
),
|
monitored: false,
|
||||||
tags: options.tags,
|
}))
|
||||||
seasonFolder: options.seasonFolder,
|
),
|
||||||
monitored: options.monitored,
|
tags: options.tags,
|
||||||
rootFolderPath: options.rootFolderPath,
|
seasonFolder: options.seasonFolder,
|
||||||
seriesType: options.seriesType,
|
monitored: options.monitored,
|
||||||
addOptions: {
|
rootFolderPath: options.rootFolderPath,
|
||||||
ignoreEpisodesWithFiles: true,
|
seriesType: options.seriesType,
|
||||||
searchForMissingEpisodes: options.searchNow,
|
addOptions: {
|
||||||
},
|
ignoreEpisodesWithFiles: true,
|
||||||
} as Partial<SonarrSeries>);
|
searchForMissingEpisodes: options.searchNow,
|
||||||
|
},
|
||||||
|
} as Partial<SonarrSeries>
|
||||||
|
);
|
||||||
|
|
||||||
if (createdSeriesData.id) {
|
if (createdSeriesResponse.data.id) {
|
||||||
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
||||||
logger.debug('Sonarr add details', {
|
logger.debug('Sonarr add details', {
|
||||||
label: 'Sonarr',
|
label: 'Sonarr',
|
||||||
movie: createdSeriesData,
|
series: createdSeriesResponse.data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error('Failed to add movie to Sonarr', {
|
logger.error('Failed to add movie to Sonarr', {
|
||||||
@@ -255,20 +264,13 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
throw new Error('Failed to add series to Sonarr');
|
throw new Error('Failed to add series to Sonarr');
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdSeriesData;
|
return createdSeriesResponse.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to add series');
|
throw new Error('Failed to add series');
|
||||||
}
|
}
|
||||||
@@ -340,13 +342,14 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
return newSeasons;
|
return newSeasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeSerie = async (serieId: number): Promise<void> => {
|
public removeSerie = async (serieId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||||
await this.delete(`/series/${id}`, {
|
await this.axios.delete(`/series/${id}`, {
|
||||||
deleteFiles: 'true',
|
params: {
|
||||||
addImportExclusion: 'false',
|
deleteFiles: true,
|
||||||
|
addImportExclusion: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info(`[Radarr] Removed serie ${title}`);
|
logger.info(`[Radarr] Removed serie ${title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
|
||||||
import type { User } from '@server/entity/User';
|
import type { User } from '@server/entity/User';
|
||||||
import type { TautulliSettings } from '@server/lib/settings';
|
import type { TautulliSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
import { uniqWith } from 'lodash';
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
export interface TautulliHistoryRecord {
|
export interface TautulliHistoryRecord {
|
||||||
@@ -112,25 +113,25 @@ interface TautulliInfoResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class TautulliAPI extends ExternalAPI {
|
class TautulliAPI {
|
||||||
|
private axios: AxiosInstance;
|
||||||
|
|
||||||
constructor(settings: TautulliSettings) {
|
constructor(settings: TautulliSettings) {
|
||||||
super(
|
this.axios = axios.create({
|
||||||
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||||
settings.port
|
settings.port
|
||||||
}${settings.urlBase ?? ''}`,
|
}${settings.urlBase ?? ''}`,
|
||||||
{
|
params: { apikey: settings.apiKey },
|
||||||
apikey: settings.apiKey || '',
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getInfo(): Promise<TautulliInfo> {
|
public async getInfo(): Promise<TautulliInfo> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliInfoResponse>('/api/v2', {
|
await this.axios.get<TautulliInfoResponse>('/api/v2', {
|
||||||
cmd: 'get_tautulli_info',
|
params: { cmd: 'get_tautulli_info' },
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Something went wrong fetching Tautulli server info', {
|
logger.error('Something went wrong fetching Tautulli server info', {
|
||||||
label: 'Tautulli API',
|
label: 'Tautulli API',
|
||||||
@@ -147,12 +148,14 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
): Promise<TautulliWatchStats[]> {
|
): Promise<TautulliWatchStats[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
cmd: 'get_item_watch_time_stats',
|
params: {
|
||||||
rating_key: ratingKey,
|
cmd: 'get_item_watch_time_stats',
|
||||||
grouping: '1',
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching media watch stats from Tautulli',
|
'Something went wrong fetching media watch stats from Tautulli',
|
||||||
@@ -173,12 +176,14 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
): Promise<TautulliWatchUser[]> {
|
): Promise<TautulliWatchUser[]> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchUsersResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||||
cmd: 'get_item_user_stats',
|
params: {
|
||||||
rating_key: ratingKey,
|
cmd: 'get_item_user_stats',
|
||||||
grouping: '1',
|
rating_key: ratingKey,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data;
|
).data.response.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching media watch users from Tautulli',
|
'Something went wrong fetching media watch users from Tautulli',
|
||||||
@@ -201,13 +206,15 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||||
cmd: 'get_user_watch_time_stats',
|
params: {
|
||||||
user_id: user.plexId.toString(),
|
cmd: 'get_user_watch_time_stats',
|
||||||
query_days: '0',
|
user_id: user.plexId,
|
||||||
grouping: '1',
|
query_days: 0,
|
||||||
|
grouping: 1,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data[0];
|
).data.response.data[0];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong fetching user watch stats from Tautulli',
|
'Something went wrong fetching user watch stats from Tautulli',
|
||||||
@@ -238,17 +245,19 @@ class TautulliAPI extends ExternalAPI {
|
|||||||
|
|
||||||
while (results.length < 20) {
|
while (results.length < 20) {
|
||||||
const tautulliData = (
|
const tautulliData = (
|
||||||
await this.get<TautulliHistoryResponse>('/api/v2', {
|
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
||||||
cmd: 'get_history',
|
params: {
|
||||||
grouping: '1',
|
cmd: 'get_history',
|
||||||
order_column: 'date',
|
grouping: 1,
|
||||||
order_dir: 'desc',
|
order_column: 'date',
|
||||||
user_id: user.plexId.toString(),
|
order_dir: 'desc',
|
||||||
media_type: 'movie,episode',
|
user_id: user.plexId,
|
||||||
length: take.toString(),
|
media_type: 'movie,episode',
|
||||||
start: start.toString(),
|
length: take,
|
||||||
|
start,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
).response.data.data;
|
).data.response.data.data;
|
||||||
|
|
||||||
if (!tautulliData.length) {
|
if (!tautulliData.length) {
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
TmdbCollection,
|
TmdbCollection,
|
||||||
@@ -37,23 +38,36 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOptions =
|
export const SortOptionsIterable = [
|
||||||
| 'popularity.asc'
|
'popularity.desc',
|
||||||
| 'popularity.desc'
|
'popularity.asc',
|
||||||
| 'release_date.asc'
|
'release_date.desc',
|
||||||
| 'release_date.desc'
|
'release_date.asc',
|
||||||
| 'revenue.asc'
|
'revenue.desc',
|
||||||
| 'revenue.desc'
|
'revenue.asc',
|
||||||
| 'primary_release_date.asc'
|
'primary_release_date.desc',
|
||||||
| 'primary_release_date.desc'
|
'primary_release_date.asc',
|
||||||
| 'original_title.asc'
|
'original_title.asc',
|
||||||
| 'original_title.desc'
|
'original_title.desc',
|
||||||
| 'vote_average.asc'
|
'vote_average.desc',
|
||||||
| 'vote_average.desc'
|
'vote_average.asc',
|
||||||
| 'vote_count.asc'
|
'vote_count.desc',
|
||||||
| 'vote_count.desc'
|
'vote_count.asc',
|
||||||
| 'first_air_date.asc'
|
'first_air_date.desc',
|
||||||
| 'first_air_date.desc';
|
'first_air_date.asc',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SortOptions = (typeof SortOptionsIterable)[number];
|
||||||
|
|
||||||
|
export interface TmdbCertificationResponse {
|
||||||
|
certifications: {
|
||||||
|
[country: string]: {
|
||||||
|
certification: string;
|
||||||
|
meaning?: string;
|
||||||
|
order?: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -74,6 +88,10 @@ interface DiscoverMovieOptions {
|
|||||||
sortBy?: SortOptions;
|
sortBy?: SortOptions;
|
||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
certificationCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscoverTvOptions {
|
interface DiscoverTvOptions {
|
||||||
@@ -96,9 +114,14 @@ interface DiscoverTvOptions {
|
|||||||
watchRegion?: string;
|
watchRegion?: string;
|
||||||
watchProviders?: string;
|
watchProviders?: string;
|
||||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||||
|
certification?: string;
|
||||||
|
certificationGte?: string;
|
||||||
|
certificationLte?: string;
|
||||||
|
certificationCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI {
|
||||||
|
private locale: string;
|
||||||
private discoverRegion?: string;
|
private discoverRegion?: string;
|
||||||
private originalLanguage?: string;
|
private originalLanguage?: string;
|
||||||
constructor({
|
constructor({
|
||||||
@@ -113,11 +136,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
nodeCache: cacheManager.getCache('tmdb').data,
|
nodeCache: cacheManager.getCache('tmdb').data,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
|
maxRequests: 20,
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
id: 'tmdb',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
this.locale = getSettings().main?.locale || 'en';
|
||||||
this.discoverRegion = discoverRegion;
|
this.discoverRegion = discoverRegion;
|
||||||
this.originalLanguage = originalLanguage;
|
this.originalLanguage = originalLanguage;
|
||||||
}
|
}
|
||||||
@@ -126,14 +150,11 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
||||||
query,
|
params: { query, page, include_adult: includeAdult, language },
|
||||||
page: page.toString(),
|
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
|
||||||
language,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -151,16 +172,18 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
year,
|
year,
|
||||||
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
primary_release_year: year?.toString() || '',
|
language,
|
||||||
|
primary_release_year: year,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -178,16 +201,18 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
query,
|
query,
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
year,
|
year,
|
||||||
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
first_air_date_year: year?.toString() || '',
|
language,
|
||||||
|
first_air_date_year: year,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -203,14 +228,14 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getPerson = async ({
|
public getPerson = async ({
|
||||||
personId,
|
personId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
personId: number;
|
personId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
}): Promise<TmdbPersonDetails> => {
|
}): Promise<TmdbPersonDetails> => {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
||||||
language,
|
params: { language },
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -221,7 +246,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getPersonCombinedCredits = async ({
|
public getPersonCombinedCredits = async ({
|
||||||
personId,
|
personId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
personId: number;
|
personId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -230,7 +255,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbPersonCombinedCredits>(
|
const data = await this.get<TmdbPersonCombinedCredits>(
|
||||||
`/person/${personId}/combined_credits`,
|
`/person/${personId}/combined_credits`,
|
||||||
{
|
{
|
||||||
language,
|
params: { language },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +269,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getMovie = async ({
|
public getMovie = async ({
|
||||||
movieId,
|
movieId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -253,10 +278,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbMovieDetails>(
|
const data = await this.get<TmdbMovieDetails>(
|
||||||
`/movie/${movieId}`,
|
`/movie/${movieId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
append_to_response:
|
language,
|
||||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
append_to_response:
|
||||||
include_video_language: language + ', en',
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -269,7 +296,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getTvShow = async ({
|
public getTvShow = async ({
|
||||||
tvId,
|
tvId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -278,10 +305,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbTvDetails>(
|
const data = await this.get<TmdbTvDetails>(
|
||||||
`/tv/${tvId}`,
|
`/tv/${tvId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
append_to_response:
|
language,
|
||||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
append_to_response:
|
||||||
include_video_language: language + ', en',
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -305,8 +334,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSeasonWithEpisodes>(
|
const data = await this.get<TmdbSeasonWithEpisodes>(
|
||||||
`/tv/${tvId}/season/${seasonNumber}`,
|
`/tv/${tvId}/season/${seasonNumber}`,
|
||||||
{
|
{
|
||||||
language: language || '',
|
params: {
|
||||||
append_to_response: 'external_ids',
|
language,
|
||||||
|
append_to_response: 'external_ids',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +350,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMovieRecommendations({
|
public async getMovieRecommendations({
|
||||||
movieId,
|
movieId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -329,8 +360,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/recommendations`,
|
`/movie/${movieId}/recommendations`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -343,7 +376,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMovieSimilar({
|
public async getMovieSimilar({
|
||||||
movieId,
|
movieId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
movieId: number;
|
movieId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -353,8 +386,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/movie/${movieId}/similar`,
|
`/movie/${movieId}/similar`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -367,7 +402,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getMoviesByKeyword({
|
public async getMoviesByKeyword({
|
||||||
keywordId,
|
keywordId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
keywordId: number;
|
keywordId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -377,8 +412,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/keyword/${keywordId}/movies`,
|
`/keyword/${keywordId}/movies`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -391,7 +428,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getTvRecommendations({
|
public async getTvRecommendations({
|
||||||
tvId,
|
tvId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -401,8 +438,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/tv/${tvId}/recommendations`,
|
`/tv/${tvId}/recommendations`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -417,7 +456,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getTvSimilar({
|
public async getTvSimilar({
|
||||||
tvId,
|
tvId,
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvId: number;
|
tvId: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -425,8 +464,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}): Promise<TmdbSearchTvResponse> {
|
}): Promise<TmdbSearchTvResponse> {
|
||||||
try {
|
try {
|
||||||
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
|
language,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -439,7 +480,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
sortBy = 'popularity.desc',
|
sortBy = 'popularity.desc',
|
||||||
page = 1,
|
page = 1,
|
||||||
includeAdult = false,
|
includeAdult = false,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
primaryReleaseDateGte,
|
primaryReleaseDateGte,
|
||||||
primaryReleaseDateLte,
|
primaryReleaseDateLte,
|
||||||
originalLanguage,
|
originalLanguage,
|
||||||
@@ -454,6 +495,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
voteCountLte,
|
voteCountLte,
|
||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
certificationCountry,
|
||||||
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -467,38 +512,44 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
.split('T')[0];
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||||
sort_by: sortBy,
|
params: {
|
||||||
page: page.toString(),
|
sort_by: sortBy,
|
||||||
include_adult: includeAdult ? 'true' : 'false',
|
page,
|
||||||
language,
|
include_adult: includeAdult,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
with_original_language:
|
region: this.discoverRegion || '',
|
||||||
originalLanguage && originalLanguage !== 'all'
|
with_original_language:
|
||||||
? originalLanguage
|
originalLanguage && originalLanguage !== 'all'
|
||||||
: originalLanguage === 'all'
|
? originalLanguage
|
||||||
? ''
|
: originalLanguage === 'all'
|
||||||
: this.originalLanguage || '',
|
? undefined
|
||||||
// Set our release date values, but check if one is set and not the other,
|
: this.originalLanguage,
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'primary_release_date.gte':
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
'primary_release_date.gte':
|
||||||
? defaultPastDate
|
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||||
: primaryReleaseDateGte || '',
|
? defaultPastDate
|
||||||
'primary_release_date.lte':
|
: primaryReleaseDateGte,
|
||||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
'primary_release_date.lte':
|
||||||
? defaultFutureDate
|
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||||
: primaryReleaseDateLte || '',
|
? defaultFutureDate
|
||||||
with_genres: genre || '',
|
: primaryReleaseDateLte,
|
||||||
with_companies: studio || '',
|
with_genres: genre,
|
||||||
with_keywords: keywords || '',
|
with_companies: studio,
|
||||||
'with_runtime.gte': withRuntimeGte || '',
|
with_keywords: keywords,
|
||||||
'with_runtime.lte': withRuntimeLte || '',
|
'with_runtime.gte': withRuntimeGte,
|
||||||
'vote_average.gte': voteAverageGte || '',
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'vote_average.lte': voteAverageLte || '',
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_count.gte': voteCountGte || '',
|
'vote_average.lte': voteAverageLte,
|
||||||
'vote_count.lte': voteCountLte || '',
|
'vote_count.gte': voteCountGte,
|
||||||
watch_region: watchRegion || '',
|
'vote_count.lte': voteCountLte,
|
||||||
with_watch_providers: watchProviders || '',
|
watch_region: watchRegion,
|
||||||
|
with_watch_providers: watchProviders,
|
||||||
|
certification: certification,
|
||||||
|
'certification.gte': certificationGte,
|
||||||
|
'certification.lte': certificationLte,
|
||||||
|
certification_country: certificationCountry,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -510,7 +561,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public getDiscoverTv = async ({
|
public getDiscoverTv = async ({
|
||||||
sortBy = 'popularity.desc',
|
sortBy = 'popularity.desc',
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
firstAirDateGte,
|
firstAirDateGte,
|
||||||
firstAirDateLte,
|
firstAirDateLte,
|
||||||
includeEmptyReleaseDate = false,
|
includeEmptyReleaseDate = false,
|
||||||
@@ -527,6 +578,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
watchProviders,
|
watchProviders,
|
||||||
watchRegion,
|
watchRegion,
|
||||||
withStatus,
|
withStatus,
|
||||||
|
certification,
|
||||||
|
certificationGte,
|
||||||
|
certificationLte,
|
||||||
|
certificationCountry,
|
||||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||||
try {
|
try {
|
||||||
const defaultFutureDate = new Date(
|
const defaultFutureDate = new Date(
|
||||||
@@ -540,41 +595,45 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
.split('T')[0];
|
.split('T')[0];
|
||||||
|
|
||||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||||
sort_by: sortBy,
|
params: {
|
||||||
page: page.toString(),
|
sort_by: sortBy,
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
// Set our release date values, but check if one is set and not the other,
|
region: this.discoverRegion || '',
|
||||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
// Set our release date values, but check if one is set and not the other,
|
||||||
'first_air_date.gte':
|
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||||
!firstAirDateGte && firstAirDateLte
|
'first_air_date.gte':
|
||||||
? defaultPastDate
|
!firstAirDateGte && firstAirDateLte
|
||||||
: firstAirDateGte || '',
|
? defaultPastDate
|
||||||
'first_air_date.lte':
|
: firstAirDateGte,
|
||||||
!firstAirDateLte && firstAirDateGte
|
'first_air_date.lte':
|
||||||
? defaultFutureDate
|
!firstAirDateLte && firstAirDateGte
|
||||||
: firstAirDateLte || '',
|
? defaultFutureDate
|
||||||
with_original_language:
|
: firstAirDateLte,
|
||||||
originalLanguage && originalLanguage !== 'all'
|
with_original_language:
|
||||||
? originalLanguage
|
originalLanguage && originalLanguage !== 'all'
|
||||||
: originalLanguage === 'all'
|
? originalLanguage
|
||||||
? ''
|
: originalLanguage === 'all'
|
||||||
: this.originalLanguage || '',
|
? undefined
|
||||||
include_null_first_air_dates: includeEmptyReleaseDate
|
: this.originalLanguage,
|
||||||
? 'true'
|
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||||
: 'false',
|
with_genres: genre,
|
||||||
with_genres: genre || '',
|
with_networks: network,
|
||||||
with_networks: network?.toString() || '',
|
with_keywords: keywords,
|
||||||
with_keywords: keywords || '',
|
'with_runtime.gte': withRuntimeGte,
|
||||||
'with_runtime.gte': withRuntimeGte || '',
|
'with_runtime.lte': withRuntimeLte,
|
||||||
'with_runtime.lte': withRuntimeLte || '',
|
'vote_average.gte': voteAverageGte,
|
||||||
'vote_average.gte': voteAverageGte || '',
|
'vote_average.lte': voteAverageLte,
|
||||||
'vote_average.lte': voteAverageLte || '',
|
'vote_count.gte': voteCountGte,
|
||||||
'vote_count.gte': voteCountGte || '',
|
'vote_count.lte': voteCountLte,
|
||||||
'vote_count.lte': voteCountLte || '',
|
with_watch_providers: watchProviders,
|
||||||
with_watch_providers: watchProviders || '',
|
watch_region: watchRegion,
|
||||||
watch_region: watchRegion || '',
|
with_status: withStatus,
|
||||||
with_status: withStatus || '',
|
certification: certification,
|
||||||
|
'certification.gte': certificationGte,
|
||||||
|
'certification.lte': certificationLte,
|
||||||
|
certification_country: certificationCountry,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -585,7 +644,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public getUpcomingMovies = async ({
|
public getUpcomingMovies = async ({
|
||||||
page = 1,
|
page = 1,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
page: number;
|
page: number;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -594,10 +653,12 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
||||||
'/movie/upcoming',
|
'/movie/upcoming',
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
originalLanguage: this.originalLanguage || '',
|
region: this.discoverRegion,
|
||||||
|
originalLanguage: this.originalLanguage,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -610,7 +671,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public getAllTrending = async ({
|
public getAllTrending = async ({
|
||||||
page = 1,
|
page = 1,
|
||||||
timeWindow = 'day',
|
timeWindow = 'day',
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
page?: number;
|
page?: number;
|
||||||
timeWindow?: 'day' | 'week';
|
timeWindow?: 'day' | 'week';
|
||||||
@@ -620,9 +681,11 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMultiResponse>(
|
const data = await this.get<TmdbSearchMultiResponse>(
|
||||||
`/trending/all/${timeWindow}`,
|
`/trending/all/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
language,
|
page,
|
||||||
region: this.discoverRegion || '',
|
language,
|
||||||
|
region: this.discoverRegion,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -643,7 +706,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchMovieResponse>(
|
const data = await this.get<TmdbSearchMovieResponse>(
|
||||||
`/trending/movie/${timeWindow}`,
|
`/trending/movie/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
|
page,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -664,7 +729,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbSearchTvResponse>(
|
const data = await this.get<TmdbSearchTvResponse>(
|
||||||
`/trending/tv/${timeWindow}`,
|
`/trending/tv/${timeWindow}`,
|
||||||
{
|
{
|
||||||
page: page.toString(),
|
params: {
|
||||||
|
page,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -677,7 +744,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getByExternalId({
|
public async getByExternalId({
|
||||||
externalId,
|
externalId,
|
||||||
type,
|
type,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}:
|
}:
|
||||||
| {
|
| {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
@@ -693,8 +760,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbExternalIdResponse>(
|
const data = await this.get<TmdbExternalIdResponse>(
|
||||||
`/find/${externalId}`,
|
`/find/${externalId}`,
|
||||||
{
|
{
|
||||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
params: {
|
||||||
language,
|
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -706,7 +775,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getMediaByImdbId({
|
public async getMediaByImdbId({
|
||||||
imdbId,
|
imdbId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
imdbId: string;
|
imdbId: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -745,7 +814,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getShowByTvdbId({
|
public async getShowByTvdbId({
|
||||||
tvdbId,
|
tvdbId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
tvdbId: number;
|
tvdbId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -775,7 +844,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
|
|
||||||
public async getCollection({
|
public async getCollection({
|
||||||
collectionId,
|
collectionId,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
collectionId: number;
|
collectionId: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
@@ -784,7 +853,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCollection>(
|
const data = await this.get<TmdbCollection>(
|
||||||
`/collection/${collectionId}`,
|
`/collection/${collectionId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -849,7 +920,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getMovieGenres({
|
public async getMovieGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -857,7 +928,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -869,7 +942,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/movie/list',
|
'/genre/movie/list',
|
||||||
{
|
{
|
||||||
language: 'en',
|
params: {
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -896,7 +971,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getTvGenres({
|
public async getTvGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -904,7 +979,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbGenresResult>(
|
const data = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -916,7 +993,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const englishData = await this.get<TmdbGenresResult>(
|
const englishData = await this.get<TmdbGenresResult>(
|
||||||
'/genre/tv/list',
|
'/genre/tv/list',
|
||||||
{
|
{
|
||||||
language: 'en',
|
params: {
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -942,6 +1021,35 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMovieCertifications =
|
||||||
|
async (): Promise<TmdbCertificationResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCertificationResponse>(
|
||||||
|
'/certification/movie/list',
|
||||||
|
{},
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getTvCertifications = async (): Promise<TmdbCertificationResponse> => {
|
||||||
|
try {
|
||||||
|
const data = await this.get<TmdbCertificationResponse>(
|
||||||
|
'/certification/tv/list',
|
||||||
|
{},
|
||||||
|
604800 // 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch TV certifications: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public async getKeywordDetails({
|
public async getKeywordDetails({
|
||||||
keywordId,
|
keywordId,
|
||||||
}: {
|
}: {
|
||||||
@@ -971,8 +1079,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||||
'/search/keyword',
|
'/search/keyword',
|
||||||
{
|
{
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -994,8 +1104,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCompanySearchResponse>(
|
const data = await this.get<TmdbCompanySearchResponse>(
|
||||||
'/search/company',
|
'/search/company',
|
||||||
{
|
{
|
||||||
query,
|
params: {
|
||||||
page: page.toString(),
|
query,
|
||||||
|
page,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1015,7 +1127,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||||
'/watch/providers/regions',
|
'/watch/providers/regions',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
|
language: language ?? this.originalLanguage,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1039,8 +1153,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/movie',
|
'/watch/providers/movie',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
watch_region: watchRegion,
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
@@ -1064,8 +1180,10 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||||
'/watch/providers/tv',
|
'/watch/providers/tv',
|
||||||
{
|
{
|
||||||
language: language ? this.originalLanguage || '' : '',
|
params: {
|
||||||
watch_region: watchRegion,
|
language: language ?? this.originalLanguage,
|
||||||
|
watch_region: watchRegion,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
86400 // 24 hours
|
86400 // 24 hours
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export enum MediaRequestStatus {
|
|||||||
APPROVED,
|
APPROVED,
|
||||||
DECLINED,
|
DECLINED,
|
||||||
FAILED,
|
FAILED,
|
||||||
|
COMPLETED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MediaType {
|
export enum MediaType {
|
||||||
@@ -17,4 +18,5 @@ export enum MediaStatus {
|
|||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLACKLISTED,
|
BLACKLISTED,
|
||||||
|
DELETED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { MediaStatus, type MediaType } from '@server/constants/media';
|
import { MediaStatus, type MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import dataSource from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
@@ -35,7 +36,7 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@ManyToOne(() => User, (user) => user.id, {
|
@ManyToOne(() => User, (user) => user.id, {
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
user: User;
|
user?: User;
|
||||||
|
|
||||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
@@ -43,34 +44,42 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
|
public blacklistedTags?: string;
|
||||||
|
|
||||||
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Blacklist>) {
|
constructor(init?: Partial<Blacklist>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async addToBlacklist({
|
public static async addToBlacklist(
|
||||||
blacklistRequest,
|
{
|
||||||
}: {
|
blacklistRequest,
|
||||||
blacklistRequest: {
|
}: {
|
||||||
mediaType: MediaType;
|
blacklistRequest: {
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
mediaType: MediaType;
|
||||||
tmdbId: ZodNumber['_output'];
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
};
|
tmdbId: ZodNumber['_output'];
|
||||||
}): Promise<void> {
|
blacklistedTags?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
entityManager?: EntityManager
|
||||||
|
): Promise<void> {
|
||||||
|
const em = entityManager ?? dataSource;
|
||||||
const blacklist = new this({
|
const blacklist = new this({
|
||||||
...blacklistRequest,
|
...blacklistRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = em.getRepository(Media);
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blacklistRepository = getRepository(this);
|
const blacklistRepository = em.getRepository(this);
|
||||||
|
|
||||||
await blacklistRepository.save(blacklist);
|
await blacklistRepository.save(blacklist);
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ import type { DiscoverSliderType } from '@server/constants/discover';
|
|||||||
import { defaultSliders } from '@server/constants/discover';
|
import { defaultSliders } from '@server/constants/discover';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class DiscoverSlider {
|
class DiscoverSlider {
|
||||||
@@ -55,10 +50,14 @@ class DiscoverSlider {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public data?: string;
|
public data?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<DiscoverSlider>) {
|
constructor(init?: Partial<DiscoverSlider>) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { IssueType } from '@server/constants/issue';
|
import type { IssueType } from '@server/constants/issue';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import {
|
||||||
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import IssueComment from './IssueComment';
|
import IssueComment from './IssueComment';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
@@ -55,12 +55,21 @@ class Issue {
|
|||||||
})
|
})
|
||||||
public comments: IssueComment[];
|
public comments: IssueComment[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
@AfterLoad()
|
||||||
|
sortComments() {
|
||||||
|
this.comments?.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(init?: Partial<Issue>) {
|
constructor(init?: Partial<Issue>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@@ -28,10 +22,14 @@ class IssueComment {
|
|||||||
@Column({ type: 'text' })
|
@Column({ type: 'text' })
|
||||||
public message: string;
|
public message: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<IssueComment>) {
|
constructor(init?: Partial<IssueComment>) {
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
@@ -108,7 +106,9 @@ class Media {
|
|||||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||||
public status4k: MediaStatus;
|
public status4k: MediaStatus;
|
||||||
|
|
||||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
@OneToMany(() => MediaRequest, (request) => request.media, {
|
||||||
|
cascade: ['insert', 'remove'],
|
||||||
|
})
|
||||||
public requests: MediaRequest[];
|
public requests: MediaRequest[];
|
||||||
|
|
||||||
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
@OneToMany(() => Watchlist, (watchlist) => watchlist.media)
|
||||||
@@ -126,10 +126,14 @@ class Media {
|
|||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||||
public blacklist: Promise<Blacklist>;
|
public blacklist: Promise<Blacklist>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
|
||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
|
||||||
import type {
|
|
||||||
AddSeriesOptions,
|
|
||||||
SonarrSeries,
|
|
||||||
} from '@server/api/servarr/sonarr';
|
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
@@ -20,19 +13,18 @@ import notificationManager, { Notification } from '@server/lib/notifications';
|
|||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isEqual, truncate } from 'lodash';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
|
import { truncate } from 'lodash';
|
||||||
import {
|
import {
|
||||||
AfterInsert,
|
AfterInsert,
|
||||||
AfterRemove,
|
AfterLoad,
|
||||||
AfterUpdate,
|
AfterUpdate,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import SeasonRequest from './SeasonRequest';
|
import SeasonRequest from './SeasonRequest';
|
||||||
@@ -181,7 +173,8 @@ export class MediaRequest {
|
|||||||
// If there is an existing movie request that isn't declined, don't allow a new one.
|
// If there is an existing movie request that isn't declined, don't allow a new one.
|
||||||
if (
|
if (
|
||||||
requestBody.mediaType === MediaType.MOVIE &&
|
requestBody.mediaType === MediaType.MOVIE &&
|
||||||
existing[0].status !== MediaRequestStatus.DECLINED
|
existing[0].status !== MediaRequestStatus.DECLINED &&
|
||||||
|
existing[0].status !== MediaRequestStatus.COMPLETED
|
||||||
) {
|
) {
|
||||||
logger.warn('Duplicate request for media blocked', {
|
logger.warn('Duplicate request for media blocked', {
|
||||||
tmdbId: tmdbMedia.id,
|
tmdbId: tmdbMedia.id,
|
||||||
@@ -388,7 +381,9 @@ export class MediaRequest {
|
|||||||
>;
|
>;
|
||||||
let requestedSeasons =
|
let requestedSeasons =
|
||||||
requestBody.seasons === 'all'
|
requestBody.seasons === 'all'
|
||||||
? tmdbMediaShow.seasons.map((season) => season.season_number)
|
? tmdbMediaShow.seasons
|
||||||
|
.filter((season) => season.season_number !== 0)
|
||||||
|
.map((season) => season.season_number)
|
||||||
: (requestBody.seasons as number[]);
|
: (requestBody.seasons as number[]);
|
||||||
if (!settings.main.enableSpecialEpisodes) {
|
if (!settings.main.enableSpecialEpisodes) {
|
||||||
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
requestedSeasons = requestedSeasons.filter((sn) => sn > 0);
|
||||||
@@ -404,7 +399,8 @@ export class MediaRequest {
|
|||||||
.filter(
|
.filter(
|
||||||
(request) =>
|
(request) =>
|
||||||
request.is4k === requestBody.is4k &&
|
request.is4k === requestBody.is4k &&
|
||||||
request.status !== MediaRequestStatus.DECLINED
|
request.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
request.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.reduce((seasons, request) => {
|
.reduce((seasons, request) => {
|
||||||
const combinedSeasons = request.seasons.map(
|
const combinedSeasons = request.seasons.map(
|
||||||
@@ -423,7 +419,9 @@ export class MediaRequest {
|
|||||||
.filter(
|
.filter(
|
||||||
(season) =>
|
(season) =>
|
||||||
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
MediaStatus.UNKNOWN
|
MediaStatus.UNKNOWN &&
|
||||||
|
season[requestBody.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.DELETED
|
||||||
)
|
)
|
||||||
.map((season) => season.seasonNumber),
|
.map((season) => season.seasonNumber),
|
||||||
];
|
];
|
||||||
@@ -537,10 +535,14 @@ export class MediaRequest {
|
|||||||
})
|
})
|
||||||
public modifiedBy?: User;
|
public modifiedBy?: User;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
@Column({ type: 'varchar' })
|
@Column({ type: 'varchar' })
|
||||||
@@ -608,12 +610,6 @@ export class MediaRequest {
|
|||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterUpdate()
|
|
||||||
@AfterInsert()
|
|
||||||
public async sendMedia(): Promise<void> {
|
|
||||||
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterInsert()
|
@AfterInsert()
|
||||||
public async notifyNewRequest(): Promise<void> {
|
public async notifyNewRequest(): Promise<void> {
|
||||||
if (this.status === MediaRequestStatus.PENDING) {
|
if (this.status === MediaRequestStatus.PENDING) {
|
||||||
@@ -630,10 +626,14 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_PENDING);
|
MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING);
|
||||||
|
|
||||||
if (this.isAutoRequest) {
|
if (this.isAutoRequest) {
|
||||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_AUTO_REQUESTED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,7 +671,8 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendNotification(
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
media,
|
media,
|
||||||
this.status === MediaRequestStatus.APPROVED
|
this.status === MediaRequestStatus.APPROVED
|
||||||
? autoApproved
|
? autoApproved
|
||||||
@@ -685,7 +686,11 @@ export class MediaRequest {
|
|||||||
autoApproved &&
|
autoApproved &&
|
||||||
this.isAutoRequest
|
this.isAutoRequest
|
||||||
) {
|
) {
|
||||||
this.sendNotification(media, Notification.MEDIA_AUTO_REQUESTED);
|
MediaRequest.sendNotification(
|
||||||
|
this,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_AUTO_REQUESTED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,699 +702,63 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterUpdate()
|
@AfterLoad()
|
||||||
@AfterInsert()
|
private sortSeasons() {
|
||||||
public async updateParentStatus(): Promise<void> {
|
if (Array.isArray(this.seasons)) {
|
||||||
const mediaRepository = getRepository(Media);
|
this.seasons.sort((a, b) => a.id - b.id);
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
if (!media) {
|
|
||||||
logger.error('Media data not found', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
// Do not update the status if the item is already partially available or available
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !==
|
|
||||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
|
|
||||||
await mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.PROCESSING }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.MOVIE &&
|
|
||||||
this.status === MediaRequestStatus.DECLINED
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
await mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.UNKNOWN }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the media type is TV, and we are declining a request,
|
|
||||||
* we must check if its the only pending request and that
|
|
||||||
* there the current media status is just pending (meaning no
|
|
||||||
* other requests have yet to be approved)
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.TV &&
|
|
||||||
this.status === MediaRequestStatus.DECLINED &&
|
|
||||||
media.requests.filter(
|
|
||||||
(request) => request.status === MediaRequestStatus.PENDING
|
|
||||||
).length === 0 &&
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
|
||||||
) {
|
|
||||||
const statusField = this.is4k ? 'status4k' : 'status';
|
|
||||||
mediaRepository.update(
|
|
||||||
{ id: this.media.id },
|
|
||||||
{ [statusField]: MediaStatus.UNKNOWN }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approve child seasons if parent is approved
|
|
||||||
if (
|
|
||||||
media.mediaType === MediaType.TV &&
|
|
||||||
this.status === MediaRequestStatus.APPROVED
|
|
||||||
) {
|
|
||||||
this.seasons.forEach((season) => {
|
|
||||||
season.status = MediaRequestStatus.APPROVED;
|
|
||||||
seasonRequestRepository.save(season);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterRemove()
|
static async sendNotification(
|
||||||
public async handleRemoveParentUpdate(): Promise<void> {
|
entity: MediaRequest,
|
||||||
const mediaRepository = getRepository(Media);
|
media: Media,
|
||||||
const fullMedia = await mediaRepository.findOneOrFail({
|
type: Notification
|
||||||
where: { id: this.media.id },
|
) {
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
|
||||||
fullMedia.status !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
fullMedia.status = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!fullMedia.requests.some((request) => request.is4k) &&
|
|
||||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRepository.save(fullMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendToRadarr(): Promise<void> {
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
this.type === MediaType.MOVIE
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
const settings = getSettings();
|
|
||||||
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
|
||||||
logger.info(
|
|
||||||
'No Radarr server configured, skipping request processing',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let radarrSettings = settings.radarr.find(
|
|
||||||
(radarr) => radarr.isDefault && radarr.is4k === this.is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.serverId !== null &&
|
|
||||||
this.serverId >= 0 &&
|
|
||||||
radarrSettings?.id !== this.serverId
|
|
||||||
) {
|
|
||||||
radarrSettings = settings.radarr.find(
|
|
||||||
(radarr) => radarr.id === this.serverId
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`Request has an override server: ${radarrSettings?.name}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!radarrSettings) {
|
|
||||||
logger.warn(
|
|
||||||
`There is no default ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Radarr server configured. Did you set any of your ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Radarr servers as default?`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
|
||||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== radarrSettings.activeDirectory
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.profileId &&
|
|
||||||
this.profileId !== radarrSettings.activeProfileId
|
|
||||||
) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const radarr = new RadarrAPI({
|
|
||||||
apiKey: radarrSettings.apiKey,
|
|
||||||
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
|
||||||
});
|
|
||||||
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
logger.error('Media data not found', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (radarrSettings.tagRequests) {
|
|
||||||
let userTag = (await radarr.getTags()).find((v) =>
|
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
|
||||||
);
|
|
||||||
if (!userTag) {
|
|
||||||
logger.info(`Requester has no active tag. Creating new`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
newTag:
|
|
||||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
userTag = await radarr.createTag({
|
|
||||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (userTag.id) {
|
|
||||||
if (!tags?.find((v) => v === userTag?.id)) {
|
|
||||||
tags?.push(userTag.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Requester has no tag and failed to add one`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
logger.warn('Media already exists, marking request as APPROVED', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.APPROVED,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radarrMovieOptions: RadarrMovieOptions = {
|
|
||||||
profileId: qualityProfile,
|
|
||||||
qualityProfileId: qualityProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
minimumAvailability: radarrSettings.minimumAvailability,
|
|
||||||
title: movie.title,
|
|
||||||
tmdbId: movie.id,
|
|
||||||
year: Number(movie.release_date.slice(0, 4)),
|
|
||||||
monitored: true,
|
|
||||||
tags,
|
|
||||||
searchNow: !radarrSettings.preventSearch,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
|
||||||
radarr
|
|
||||||
.addMovie(radarrMovieOptions)
|
|
||||||
.then(async (radarrMovie) => {
|
|
||||||
// We grab media again here to make sure we have the latest version of it
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFields = {
|
|
||||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
|
||||||
radarrMovie.id,
|
|
||||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
|
||||||
radarrMovie.titleSlug,
|
|
||||||
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrMovie?.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.FAILED,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
radarrMovieOptions,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
radarr.clearCache({
|
|
||||||
tmdbId: movie.id,
|
|
||||||
externalId: this.is4k
|
|
||||||
? media.externalServiceId4k
|
|
||||||
: media.externalServiceId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
logger.info('Sent request to Radarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending request to Radarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
errorMessage: e.message,
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
throw new Error(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendToSonarr(): Promise<void> {
|
|
||||||
if (
|
|
||||||
this.status === MediaRequestStatus.APPROVED &&
|
|
||||||
this.type === MediaType.TV
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
const settings = getSettings();
|
|
||||||
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
|
||||||
logger.warn(
|
|
||||||
'No Sonarr server configured, skipping request processing',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sonarrSettings = settings.sonarr.find(
|
|
||||||
(sonarr) => sonarr.isDefault && sonarr.is4k === this.is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.serverId !== null &&
|
|
||||||
this.serverId >= 0 &&
|
|
||||||
sonarrSettings?.id !== this.serverId
|
|
||||||
) {
|
|
||||||
sonarrSettings = settings.sonarr.find(
|
|
||||||
(sonarr) => sonarr.id === this.serverId
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`Request has an override server: ${sonarrSettings?.name}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sonarrSettings) {
|
|
||||||
logger.warn(
|
|
||||||
`There is no default ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Sonarr server configured. Did you set any of your ${
|
|
||||||
this.is4k ? '4K ' : ''
|
|
||||||
}Sonarr servers as default?`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
logger.warn('Media already exists, marking request as APPROVED', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await requestRepository.update(this.id, {
|
|
||||||
status: MediaRequestStatus.APPROVED,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const sonarr = new SonarrAPI({
|
|
||||||
apiKey: sonarrSettings.apiKey,
|
|
||||||
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
|
||||||
});
|
|
||||||
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
|
||||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
|
||||||
|
|
||||||
if (!tvdbId) {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await mediaRepository.remove(media);
|
|
||||||
await requestRepository.remove(this);
|
|
||||||
throw new Error('TVDB ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
|
||||||
|
|
||||||
// Change series type to anime if the anime keyword is present on tmdb
|
|
||||||
if (
|
|
||||||
series.keywords.results.some(
|
|
||||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootFolder =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
|
||||||
? sonarrSettings.activeAnimeDirectory
|
|
||||||
: sonarrSettings.activeDirectory;
|
|
||||||
let qualityProfile =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
|
||||||
? sonarrSettings.activeAnimeProfileId
|
|
||||||
: sonarrSettings.activeProfileId;
|
|
||||||
let languageProfile =
|
|
||||||
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
|
||||||
? sonarrSettings.activeAnimeLanguageProfileId
|
|
||||||
: sonarrSettings.activeLanguageProfileId;
|
|
||||||
let tags =
|
|
||||||
seriesType === 'anime'
|
|
||||||
? sonarrSettings.animeTags
|
|
||||||
? [...sonarrSettings.animeTags]
|
|
||||||
: []
|
|
||||||
: sonarrSettings.tags
|
|
||||||
? [...sonarrSettings.tags]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== rootFolder
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.profileId && this.profileId !== qualityProfile) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.languageProfileId &&
|
|
||||||
this.languageProfileId !== languageProfile
|
|
||||||
) {
|
|
||||||
languageProfile = this.languageProfileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override language profile ID: ${languageProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sonarrSettings.tagRequests) {
|
|
||||||
let userTag = (await sonarr.getTags()).find((v) =>
|
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
|
||||||
);
|
|
||||||
if (!userTag) {
|
|
||||||
logger.info(`Requester has no active tag. Creating new`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
newTag:
|
|
||||||
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
userTag = await sonarr.createTag({
|
|
||||||
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (userTag.id) {
|
|
||||||
if (!tags?.find((v) => v === userTag?.id)) {
|
|
||||||
tags?.push(userTag.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Requester has no tag and failed to add one`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
userId: this.requestedBy.id,
|
|
||||||
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
|
||||||
profileId: qualityProfile,
|
|
||||||
languageProfileId: languageProfile,
|
|
||||||
rootFolderPath: rootFolder,
|
|
||||||
title: series.name,
|
|
||||||
tvdbid: tvdbId,
|
|
||||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
|
||||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
|
||||||
seriesType,
|
|
||||||
tags,
|
|
||||||
monitored: true,
|
|
||||||
searchNow: !sonarrSettings.preventSearch,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run this asynchronously so we don't wait for it on the UI side
|
|
||||||
sonarr
|
|
||||||
.addSeries(sonarrSeriesOptions)
|
|
||||||
.then(async (sonarrSeries) => {
|
|
||||||
// We grab media again here to make sure we have the latest version of it
|
|
||||||
const media = await mediaRepository.findOne({
|
|
||||||
where: { id: this.media.id },
|
|
||||||
relations: { requests: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!media) {
|
|
||||||
throw new Error('Media data not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFields = {
|
|
||||||
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
|
||||||
sonarrSeries.id,
|
|
||||||
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
|
||||||
sonarrSeries.titleSlug,
|
|
||||||
[this.is4k ? 'serviceId4k' : 'serviceId']: sonarrSettings?.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mediaRepository.update({ id: this.media.id }, updateFields);
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
await requestRepository.update(
|
|
||||||
{ id: this.id },
|
|
||||||
{ status: MediaRequestStatus.FAILED }
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
sonarrSeriesOptions,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.sendNotification(media, Notification.MEDIA_FAILED);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
sonarr.clearCache({
|
|
||||||
tvdbId,
|
|
||||||
externalId: this.is4k
|
|
||||||
? media.externalServiceId4k
|
|
||||||
: media.externalServiceId,
|
|
||||||
title: series.name,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
logger.info('Sent request to Sonarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending request to Sonarr', {
|
|
||||||
label: 'Media Request',
|
|
||||||
errorMessage: e.message,
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
throw new Error(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendNotification(media: Media, type: Notification) {
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series';
|
||||||
let event: string | undefined;
|
let event: string | undefined;
|
||||||
let notifyAdmin = true;
|
let notifyAdmin = true;
|
||||||
let notifySystem = true;
|
let notifySystem = true;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_DECLINED:
|
case Notification.MEDIA_DECLINED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
event = `New ${this.is4k ? '4K ' : ''}${mediaType} Request`;
|
event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_REQUESTED:
|
case Notification.MEDIA_AUTO_REQUESTED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
entity.is4k ? '4K ' : ''
|
||||||
}${mediaType} Request Automatically Submitted`;
|
}${mediaType} Request Automatically Submitted`;
|
||||||
notifyAdmin = false;
|
notifyAdmin = false;
|
||||||
notifySystem = false;
|
notifySystem = false;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
event = `${
|
event = `${
|
||||||
this.is4k ? '4K ' : ''
|
entity.is4k ? '4K ' : ''
|
||||||
}${mediaType} Request Automatically Approved`;
|
}${mediaType} Request Automatically Approved`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_FAILED:
|
case Notification.MEDIA_FAILED:
|
||||||
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.type === MediaType.MOVIE) {
|
if (entity.type === MediaType.MOVIE) {
|
||||||
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
|
||||||
notificationManager.sendNotification(type, {
|
notificationManager.sendNotification(type, {
|
||||||
media,
|
media,
|
||||||
request: this,
|
request: entity,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
notifySystem,
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${movie.title}${
|
subject: `${movie.title}${
|
||||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
@@ -1401,14 +770,14 @@ export class MediaRequest {
|
|||||||
}),
|
}),
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
});
|
});
|
||||||
} else if (this.type === MediaType.TV) {
|
} else if (entity.type === MediaType.TV) {
|
||||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
notificationManager.sendNotification(type, {
|
notificationManager.sendNotification(type, {
|
||||||
media,
|
media,
|
||||||
request: this,
|
request: entity,
|
||||||
notifyAdmin,
|
notifyAdmin,
|
||||||
notifySystem,
|
notifySystem,
|
||||||
notifyUser: notifyAdmin ? undefined : this.requestedBy,
|
notifyUser: notifyAdmin ? undefined : entity.requestedBy,
|
||||||
event,
|
event,
|
||||||
subject: `${tv.name}${
|
subject: `${tv.name}${
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
@@ -1422,7 +791,7 @@ export class MediaRequest {
|
|||||||
extra: [
|
extra: [
|
||||||
{
|
{
|
||||||
name: 'Requested Seasons',
|
name: 'Requested Seasons',
|
||||||
value: this.seasons
|
value: entity.seasons
|
||||||
.map((season) => season.seasonNumber)
|
.map((season) => season.seasonNumber)
|
||||||
.join(', '),
|
.join(', '),
|
||||||
},
|
},
|
||||||
@@ -1433,8 +802,8 @@ export class MediaRequest {
|
|||||||
logger.error('Something went wrong sending media notification(s)', {
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
requestId: this.id,
|
requestId: entity.id,
|
||||||
mediaId: this.media.id,
|
mediaId: entity.media.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class OverrideRule {
|
class OverrideRule {
|
||||||
@@ -38,10 +33,14 @@ class OverrideRule {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public tags?: string;
|
public tags?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<OverrideRule>) {
|
constructor(init?: Partial<OverrideRule>) {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import {
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
Column,
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -28,10 +22,14 @@ class Season {
|
|||||||
})
|
})
|
||||||
public media: Promise<Media>;
|
public media: Promise<Media>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Season>) {
|
constructor(init?: Partial<Season>) {
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
AfterRemove,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
ManyToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -27,27 +19,19 @@ class SeasonRequest {
|
|||||||
})
|
})
|
||||||
public request: MediaRequest;
|
public request: MediaRequest;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<SeasonRequest>) {
|
constructor(init?: Partial<SeasonRequest>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterRemove()
|
|
||||||
public async handleRemoveParent(): Promise<void> {
|
|
||||||
const mediaRequestRepository = getRepository(MediaRequest);
|
|
||||||
const requestToBeDeleted = await mediaRequestRepository.findOneOrFail({
|
|
||||||
where: { id: this.request.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestToBeDeleted.seasons.length === 0) {
|
|
||||||
await mediaRequestRepository.delete({ id: this.request.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SeasonRequest;
|
export default SeasonRequest;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { hasPermission, Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { AfterDate } from '@server/utils/dateHelpers';
|
import { AfterDate } from '@server/utils/dateHelpers';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -16,14 +17,12 @@ import { default as generatePassword } from 'secure-random-password';
|
|||||||
import {
|
import {
|
||||||
AfterLoad,
|
AfterLoad,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Not,
|
Not,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
OneToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
RelationCount,
|
RelationCount,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import Issue from './Issue';
|
import Issue from './Issue';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
@@ -98,6 +97,12 @@ export class User {
|
|||||||
@Column()
|
@Column()
|
||||||
public avatar: string;
|
public avatar: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
public avatarETag?: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
public avatarVersion?: string | null;
|
||||||
|
|
||||||
@RelationCount((user: User) => user.requests)
|
@RelationCount((user: User) => user.requests)
|
||||||
public requestCount: number;
|
public requestCount: number;
|
||||||
|
|
||||||
@@ -132,10 +137,14 @@ export class User {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true })
|
||||||
public createdIssues: Issue[];
|
public createdIssues: Issue[];
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
public warnings: string[] = [];
|
public warnings: string[] = [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@@ -18,9 +19,19 @@ export class UserPushSubscription {
|
|||||||
@Column()
|
@Column()
|
||||||
public p256dh: string;
|
public p256dh: string;
|
||||||
|
|
||||||
@Column({ unique: true })
|
@Column()
|
||||||
public auth: string;
|
public auth: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public userAgent: string;
|
||||||
|
|
||||||
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<UserPushSubscription>) {
|
constructor(init?: Partial<UserPushSubscription>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,14 @@ import Media from '@server/entity/Media';
|
|||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||||
|
|
||||||
@@ -56,10 +55,14 @@ export class Watchlist implements WatchlistItem {
|
|||||||
})
|
})
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@DbAwareColumn({
|
||||||
|
type: 'datetime',
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
onUpdate: 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Watchlist>) {
|
constructor(init?: Partial<Watchlist>) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord';
|
|||||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
@@ -35,8 +36,6 @@ import * as OpenApiValidator from 'express-openapi-validator';
|
|||||||
import type { Store } from 'express-session';
|
import type { Store } from 'express-session';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import next from 'next';
|
import next from 'next';
|
||||||
import dns from 'node:dns';
|
|
||||||
import net from 'node:net';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import YAML from 'yamljs';
|
import YAML from 'yamljs';
|
||||||
@@ -74,15 +73,6 @@ app
|
|||||||
const settings = await getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings);
|
restartFlag.initializeSettings(settings);
|
||||||
|
|
||||||
// Check if we force IPv4 first
|
|
||||||
if (
|
|
||||||
process.env.forceIpv4First === 'true' ||
|
|
||||||
settings.network.forceIpv4First
|
|
||||||
) {
|
|
||||||
dns.setDefaultResultOrder('ipv4first');
|
|
||||||
net.setDefaultAutoSelectFamily(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register HTTP proxy
|
// Register HTTP proxy
|
||||||
if (settings.network.proxy.enabled) {
|
if (settings.network.proxy.enabled) {
|
||||||
await createCustomProxyAgent(settings.network.proxy);
|
await createCustomProxyAgent(settings.network.proxy);
|
||||||
@@ -114,6 +104,7 @@ app
|
|||||||
new DiscordAgent(),
|
new DiscordAgent(),
|
||||||
new EmailAgent(),
|
new EmailAgent(),
|
||||||
new GotifyAgent(),
|
new GotifyAgent(),
|
||||||
|
new NtfyAgent(),
|
||||||
new LunaSeaAgent(),
|
new LunaSeaAgent(),
|
||||||
new PushbulletAgent(),
|
new PushbulletAgent(),
|
||||||
new PushoverAgent(),
|
new PushoverAgent(),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export interface BlacklistItem {
|
|||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
title?: string;
|
title?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
user: User;
|
user?: User;
|
||||||
|
blacklistedTags?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
|||||||
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: NonFunctionProperties<MediaRequest>[];
|
results: (NonFunctionProperties<MediaRequest> & {
|
||||||
|
profileName?: string;
|
||||||
|
canRemove?: boolean;
|
||||||
|
})[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface PublicSettingsResponse {
|
|||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
@@ -45,6 +46,7 @@ export interface PublicSettingsResponse {
|
|||||||
locale: string;
|
locale: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheItem {
|
export interface CacheItem {
|
||||||
|
|||||||
184
server/job/blacklistedTagsProcessor.ts
Normal file
184
server/job/blacklistedTagsProcessor.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { SortOptions } from '@server/api/themoviedb';
|
||||||
|
import { SortOptionsIterable } from '@server/api/themoviedb';
|
||||||
|
import type {
|
||||||
|
TmdbSearchMovieResponse,
|
||||||
|
TmdbSearchTvResponse,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import dataSource from '@server/datasource';
|
||||||
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import type {
|
||||||
|
RunnableScanner,
|
||||||
|
StatusBase,
|
||||||
|
} from '@server/lib/scanners/baseScanner';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
const TMDB_API_DELAY_MS = 250;
|
||||||
|
class AbortTransaction extends Error {}
|
||||||
|
|
||||||
|
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||||
|
private running = false;
|
||||||
|
private progress = 0;
|
||||||
|
private total = 0;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dataSource.transaction(async (em) => {
|
||||||
|
await this.cleanBlacklist(em);
|
||||||
|
await this.createBlacklistEntries(em);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AbortTransaction) {
|
||||||
|
logger.info('Aborting job: Process Blacklisted Tags', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public status(): StatusBase {
|
||||||
|
return {
|
||||||
|
running: this.running,
|
||||||
|
progress: this.progress,
|
||||||
|
total: this.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
this.progress = 0;
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBlacklistEntries(em: EntityManager) {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
const blacklistedTags = settings.main.blacklistedTags;
|
||||||
|
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||||
|
|
||||||
|
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||||
|
|
||||||
|
if (blacklistedTags.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The maximum number of queries we're expected to execute
|
||||||
|
this.total =
|
||||||
|
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
|
||||||
|
|
||||||
|
for (const type of [MediaType.MOVIE, MediaType.TV]) {
|
||||||
|
const getDiscover =
|
||||||
|
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
|
||||||
|
|
||||||
|
// Iterate for each tag
|
||||||
|
for (const tag of blacklistedTagsArr) {
|
||||||
|
let queryMax = pageLimit * SortOptionsIterable.length;
|
||||||
|
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
||||||
|
|
||||||
|
for (let query = 0; query < queryMax; query++) {
|
||||||
|
const page: number = fixedSortMode
|
||||||
|
? query + 1
|
||||||
|
: (query % pageLimit) + 1;
|
||||||
|
const sortBy: SortOptions | undefined = fixedSortMode
|
||||||
|
? undefined
|
||||||
|
: SortOptionsIterable[query % SortOptionsIterable.length];
|
||||||
|
|
||||||
|
if (!this.running) {
|
||||||
|
throw new AbortTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getDiscover({
|
||||||
|
page,
|
||||||
|
sortBy,
|
||||||
|
keywords: tag,
|
||||||
|
});
|
||||||
|
await this.processResults(response, tag, type, em);
|
||||||
|
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||||
|
|
||||||
|
this.progress++;
|
||||||
|
if (page === 1 && response.total_pages <= queryMax) {
|
||||||
|
// We will finish the tag with less queries than expected, move progress accordingly
|
||||||
|
this.progress += queryMax - response.total_pages;
|
||||||
|
fixedSortMode = true;
|
||||||
|
queryMax = response.total_pages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processResults(
|
||||||
|
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
|
||||||
|
keywordId: string,
|
||||||
|
mediaType: MediaType,
|
||||||
|
em: EntityManager
|
||||||
|
) {
|
||||||
|
const blacklistRepository = em.getRepository(Blacklist);
|
||||||
|
|
||||||
|
for (const entry of response.results) {
|
||||||
|
const blacklistEntry = await blacklistRepository.findOne({
|
||||||
|
where: { tmdbId: entry.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blacklistEntry) {
|
||||||
|
// Don't mark manual blacklists with tags
|
||||||
|
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
|
||||||
|
if (
|
||||||
|
blacklistEntry.blacklistedTags &&
|
||||||
|
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
|
||||||
|
) {
|
||||||
|
await blacklistRepository.update(blacklistEntry.id, {
|
||||||
|
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Media wasn't previously blacklisted, add it to the blacklist
|
||||||
|
await Blacklist.addToBlacklist(
|
||||||
|
{
|
||||||
|
blacklistRequest: {
|
||||||
|
mediaType,
|
||||||
|
title: 'title' in entry ? entry.title : entry.name,
|
||||||
|
tmdbId: entry.id,
|
||||||
|
blacklistedTags: `,${keywordId},`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
em
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanBlacklist(em: EntityManager) {
|
||||||
|
// Remove blacklist and media entries blacklisted by tags
|
||||||
|
const mediaRepository = em.getRepository(Media);
|
||||||
|
const mediaToRemove = await mediaRepository
|
||||||
|
.createQueryBuilder('media')
|
||||||
|
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||||
|
.where(`blist.blacklistedTags IS NOT NULL`)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Batch removes so the query doesn't get too large
|
||||||
|
for (let i = 0; i < mediaToRemove.length; i += 500) {
|
||||||
|
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
|
||||||
|
|
||||||
|
export default blacklistedTagsProcessor;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
|
||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
@@ -21,7 +22,7 @@ interface ScheduledJob {
|
|||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
@@ -237,5 +238,21 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'process-blacklisted-tags',
|
||||||
|
name: 'Process Blacklisted Tags',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'days',
|
||||||
|
cronSchedule: jobs['process-blacklisted-tags'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Process Blacklisted Tags', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
blacklistedTagsProcessor.run();
|
||||||
|
}),
|
||||||
|
running: () => blacklistedTagsProcessor.status().running,
|
||||||
|
cancelFn: () => blacklistedTagsProcessor.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { getRepository } from '@server/datasource';
|
|||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import MediaRequest from '@server/entity/MediaRequest';
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
import type Season from '@server/entity/Season';
|
import type Season from '@server/entity/Season';
|
||||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
@@ -42,7 +41,7 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Starting availability sync...`, {
|
logger.info(`Starting availability sync...`, {
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
|
||||||
@@ -404,6 +403,34 @@ class AvailabilitySync {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists &&
|
||||||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists4k &&
|
||||||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, true, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Figure out how to run seasonUpdater for each season
|
// TODO: Figure out how to run seasonUpdater for each season
|
||||||
|
|
||||||
if ([...finalSeasons.values()].includes(false)) {
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
@@ -423,32 +450,16 @@ class AvailabilitySync {
|
|||||||
mediaServerType
|
mediaServerType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!showExists &&
|
|
||||||
(media.status === MediaStatus.AVAILABLE ||
|
|
||||||
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
await this.mediaUpdater(media, false, mediaServerType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!showExists4k &&
|
|
||||||
(media.status4k === MediaStatus.AVAILABLE ||
|
|
||||||
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
await this.mediaUpdater(media, true, mediaServerType);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.error('Failed to complete availability sync.', {
|
logger.error('Failed to complete availability sync.', {
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
logger.info(`Availability sync complete.`, {
|
logger.info(`Availability sync complete.`, {
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
});
|
});
|
||||||
this.running = false;
|
this.running = false;
|
||||||
}
|
}
|
||||||
@@ -466,6 +477,10 @@ class AvailabilitySync {
|
|||||||
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
{ status4k: MediaStatus.AVAILABLE },
|
{ status4k: MediaStatus.AVAILABLE },
|
||||||
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
||||||
|
{ seasons: { status: MediaStatus.AVAILABLE } },
|
||||||
|
{ seasons: { status: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||||
|
{ seasons: { status4k: MediaStatus.AVAILABLE } },
|
||||||
|
{ seasons: { status4k: MediaStatus.PARTIALLY_AVAILABLE } },
|
||||||
];
|
];
|
||||||
|
|
||||||
let mediaPage: Media[];
|
let mediaPage: Media[];
|
||||||
@@ -480,98 +495,66 @@ class AvailabilitySync {
|
|||||||
} while (mediaPage.length > 0);
|
} while (mediaPage.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private findMediaStatus(
|
|
||||||
requests: MediaRequest[],
|
|
||||||
is4k: boolean
|
|
||||||
): MediaStatus {
|
|
||||||
const filteredRequests = requests.filter(
|
|
||||||
(request) => request.is4k === is4k
|
|
||||||
);
|
|
||||||
|
|
||||||
let mediaStatus: MediaStatus;
|
|
||||||
|
|
||||||
if (
|
|
||||||
filteredRequests.some(
|
|
||||||
(request) => request.status === MediaRequestStatus.APPROVED
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
mediaStatus = MediaStatus.PROCESSING;
|
|
||||||
} else if (
|
|
||||||
filteredRequests.some(
|
|
||||||
(request) => request.status === MediaRequestStatus.PENDING
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
mediaStatus = MediaStatus.PENDING;
|
|
||||||
} else {
|
|
||||||
mediaStatus = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mediaStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async mediaUpdater(
|
private async mediaUpdater(
|
||||||
media: Media,
|
media: Media,
|
||||||
is4k: boolean,
|
is4k: boolean,
|
||||||
mediaServerType: MediaServerType
|
mediaServerType: MediaServerType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all related requests only if
|
// If media type is tv, check if a season is processing
|
||||||
// the related media has an available status
|
// to see if we need to keep the external metadata
|
||||||
const requests = await requestRepository
|
let isMediaProcessing = false;
|
||||||
.createQueryBuilder('request')
|
|
||||||
.leftJoinAndSelect('request.media', 'media')
|
|
||||||
.where('(media.id = :id)', {
|
|
||||||
id: media.id,
|
|
||||||
})
|
|
||||||
.andWhere(
|
|
||||||
`(request.is4k = :is4k AND media.${
|
|
||||||
is4k ? 'status4k' : 'status'
|
|
||||||
} IN (:...mediaStatus))`,
|
|
||||||
{
|
|
||||||
mediaStatus: [
|
|
||||||
MediaStatus.AVAILABLE,
|
|
||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
|
||||||
],
|
|
||||||
is4k: is4k,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Check if a season is processing or pending to
|
|
||||||
// make sure we set the media to the correct status
|
|
||||||
let mediaStatus = MediaStatus.UNKNOWN;
|
|
||||||
|
|
||||||
if (media.mediaType === 'tv') {
|
if (media.mediaType === 'tv') {
|
||||||
mediaStatus = this.findMediaStatus(requests, is4k);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const request = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoinAndSelect('request.media', 'media')
|
||||||
|
.where('(media.id = :id)', {
|
||||||
|
id: media.id,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
'(request.is4k = :is4k AND request.status = :requestStatus)',
|
||||||
|
{
|
||||||
|
requestStatus: MediaRequestStatus.APPROVED,
|
||||||
|
is4k: is4k,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
isMediaProcessing = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
// Set the non-4K or 4K media to deleted
|
||||||
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
// and change related columns to null if media
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
// is not processing
|
||||||
? media[is4k ? 'serviceId4k' : 'serviceId']
|
media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||||
: null;
|
media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing
|
||||||
|
? media[is4k ? 'serviceId4k' : 'serviceId']
|
||||||
|
: null;
|
||||||
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
||||||
: null;
|
: null;
|
||||||
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
||||||
: null;
|
: null;
|
||||||
if (mediaServerType === MediaServerType.PLEX) {
|
if (mediaServerType === MediaServerType.PLEX) {
|
||||||
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
||||||
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
: null;
|
||||||
: null;
|
|
||||||
} else if (
|
} else if (
|
||||||
mediaServerType === MediaServerType.JELLYFIN ||
|
mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
mediaServerType === MediaServerType.EMBY
|
mediaServerType === MediaServerType.EMBY
|
||||||
) {
|
) {
|
||||||
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
mediaStatus === MediaStatus.PROCESSING
|
isMediaProcessing
|
||||||
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -586,18 +569,11 @@ class AvailabilitySync {
|
|||||||
: mediaServerType === MediaServerType.JELLYFIN
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'jellyfin'
|
? 'jellyfin'
|
||||||
: 'emby'
|
: 'emby'
|
||||||
} instance. Status will be changed to unknown.`,
|
} instance. Status will be changed to deleted.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
|
|
||||||
await mediaRepository.save({ media, ...media });
|
await mediaRepository.save(media);
|
||||||
|
|
||||||
// Only delete media request if type is movie.
|
|
||||||
// Type tv request deletion is handled
|
|
||||||
// in the season request entity
|
|
||||||
if (requests.length > 0 && media.mediaType === 'movie') {
|
|
||||||
await requestRepository.remove(requests);
|
|
||||||
}
|
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
||||||
@@ -605,7 +581,7 @@ class AvailabilitySync {
|
|||||||
} [TMDB ID ${media.tmdbId}].`,
|
} [TMDB ID ${media.tmdbId}].`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -618,61 +594,44 @@ class AvailabilitySync {
|
|||||||
mediaServerType: MediaServerType
|
mediaServerType: MediaServerType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
|
||||||
|
|
||||||
|
// Filter out only the values that are false
|
||||||
|
// (media that should be deleted)
|
||||||
const seasonsPendingRemoval = new Map(
|
const seasonsPendingRemoval = new Map(
|
||||||
// Disabled linter as only the value is needed from the filter
|
// Disabled linter as only the value is needed from the filter
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
[...seasons].filter(([_, exists]) => !exists)
|
[...seasons].filter(([_, exists]) => !exists)
|
||||||
);
|
);
|
||||||
|
// Retrieve the season keys to pass into our log
|
||||||
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
||||||
|
|
||||||
// let isSeasonRemoved = false;
|
// let isSeasonRemoved = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Need to check and see if there are any related season
|
|
||||||
// requests. If they are, we will need to delete them.
|
|
||||||
const seasonRequests = await seasonRequestRepository
|
|
||||||
.createQueryBuilder('seasonRequest')
|
|
||||||
.leftJoinAndSelect('seasonRequest.request', 'request')
|
|
||||||
.leftJoinAndSelect('request.media', 'media')
|
|
||||||
.where('(media.id = :id)', { id: media.id })
|
|
||||||
.andWhere(
|
|
||||||
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
|
||||||
{
|
|
||||||
seasonNumbers: seasonKeys,
|
|
||||||
is4k: is4k,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
for (const mediaSeason of media.seasons) {
|
for (const mediaSeason of media.seasons) {
|
||||||
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
||||||
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.status === MediaStatus.AVAILABLE) {
|
if (media.status === MediaStatus.AVAILABLE && !is4k) {
|
||||||
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'Availability Sync' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (media.status4k === MediaStatus.AVAILABLE) {
|
if (media.status4k === MediaStatus.AVAILABLE && is4k) {
|
||||||
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'Availability Sync' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mediaRepository.save({ media, ...media });
|
media.lastSeasonChange = new Date();
|
||||||
|
await mediaRepository.save(media);
|
||||||
if (seasonRequests.length > 0) {
|
|
||||||
await seasonRequestRepository.remove(seasonRequests);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
||||||
@@ -685,7 +644,7 @@ class AvailabilitySync {
|
|||||||
: mediaServerType === MediaServerType.JELLYFIN
|
: mediaServerType === MediaServerType.JELLYFIN
|
||||||
? 'jellyfin'
|
? 'jellyfin'
|
||||||
: 'emby'
|
: 'emby'
|
||||||
} instance. Status will be changed to unknown.`,
|
} instance. Status will be changed to deleted.`,
|
||||||
{ label: 'AvailabilitySync' }
|
{ label: 'AvailabilitySync' }
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -695,7 +654,7 @@ class AvailabilitySync {
|
|||||||
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -709,7 +668,9 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
// Check for availability in all of the available radarr servers
|
// Check for availability in all of the available radarr servers
|
||||||
// If any find the media, we will assume the media exists
|
// If any find the media, we will assume the media exists
|
||||||
for (const server of this.radarrServers) {
|
for (const server of this.radarrServers.filter(
|
||||||
|
(server) => server.is4k === is4k
|
||||||
|
)) {
|
||||||
const radarrAPI = new RadarrAPI({
|
const radarrAPI = new RadarrAPI({
|
||||||
apiKey: server.apiKey,
|
apiKey: server.apiKey,
|
||||||
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
||||||
@@ -718,20 +679,24 @@ class AvailabilitySync {
|
|||||||
try {
|
try {
|
||||||
let radarr: RadarrMovie | undefined;
|
let radarr: RadarrMovie | undefined;
|
||||||
|
|
||||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
radarr = await radarrAPI.getMovie({
|
radarr = await radarrAPI.getMovie({
|
||||||
id: media.externalServiceId,
|
id: media.externalServiceId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
if (media.externalServiceId4k && is4k) {
|
||||||
radarr = await radarrAPI.getMovie({
|
radarr = await radarrAPI.getMovie({
|
||||||
id: media.externalServiceId4k,
|
id: media.externalServiceId4k,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radarr && radarr.hasFile) {
|
if (radarr && radarr.hasFile) {
|
||||||
existsInRadarr = true;
|
const resolution =
|
||||||
|
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||||
|
const is4kMovie =
|
||||||
|
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||||
|
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
if (!ex.message.includes('404')) {
|
if (!ex.message.includes('404')) {
|
||||||
@@ -742,7 +707,7 @@ class AvailabilitySync {
|
|||||||
}] from Radarr.`,
|
}] from Radarr.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -761,7 +726,9 @@ class AvailabilitySync {
|
|||||||
|
|
||||||
// Check for availability in all of the available sonarr servers
|
// Check for availability in all of the available sonarr servers
|
||||||
// If any find the media, we will assume the media exists
|
// If any find the media, we will assume the media exists
|
||||||
for (const server of this.sonarrServers) {
|
for (const server of this.sonarrServers.filter((server) => {
|
||||||
|
return server.is4k === is4k;
|
||||||
|
})) {
|
||||||
const sonarrAPI = new SonarrAPI({
|
const sonarrAPI = new SonarrAPI({
|
||||||
apiKey: server.apiKey,
|
apiKey: server.apiKey,
|
||||||
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
||||||
@@ -770,13 +737,13 @@ class AvailabilitySync {
|
|||||||
try {
|
try {
|
||||||
let sonarr: SonarrSeries | undefined;
|
let sonarr: SonarrSeries | undefined;
|
||||||
|
|
||||||
if (!server.is4k && media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
||||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
||||||
sonarr.seasons;
|
sonarr.seasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (server.is4k && media.externalServiceId4k && is4k) {
|
if (media.externalServiceId4k && is4k) {
|
||||||
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
||||||
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
||||||
sonarr.seasons;
|
sonarr.seasons;
|
||||||
@@ -795,7 +762,7 @@ class AvailabilitySync {
|
|||||||
}] from Sonarr.`,
|
}] from Sonarr.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -841,7 +808,9 @@ class AvailabilitySync {
|
|||||||
// Check each sonarr instance to see if the media still exists
|
// Check each sonarr instance to see if the media still exists
|
||||||
// If found, we will assume the media exists and prevent removal
|
// If found, we will assume the media exists and prevent removal
|
||||||
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
||||||
for (const server of this.sonarrServers) {
|
for (const server of this.sonarrServers.filter(
|
||||||
|
(server) => server.is4k === is4k
|
||||||
|
)) {
|
||||||
let sonarrSeasons: SonarrSeason[] | undefined;
|
let sonarrSeasons: SonarrSeason[] | undefined;
|
||||||
|
|
||||||
if (media.externalServiceId && !is4k) {
|
if (media.externalServiceId && !is4k) {
|
||||||
@@ -916,7 +885,7 @@ class AvailabilitySync {
|
|||||||
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
||||||
{
|
{
|
||||||
errorMessage: ex.message,
|
errorMessage: ex.message,
|
||||||
label: 'AvailabilitySync',
|
label: 'Availability Sync',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1105,4 +1074,5 @@ class AvailabilitySync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availabilitySync = new AvailabilitySync();
|
const availabilitySync = new AvailabilitySync();
|
||||||
|
|
||||||
export default availabilitySync;
|
export default availabilitySync;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
import axios from 'axios';
|
||||||
import rateLimit from '@server/utils/rateLimit';
|
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { promises } from 'fs';
|
import { promises } from 'fs';
|
||||||
import mime from 'mime/lite';
|
import mime from 'mime/lite';
|
||||||
@@ -131,33 +131,29 @@ class ImageProxy {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetch: typeof fetch;
|
private axios;
|
||||||
private cacheVersion;
|
private cacheVersion;
|
||||||
private key;
|
private key;
|
||||||
private baseUrl;
|
|
||||||
private headers: HeadersInit | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
key: string,
|
key: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: {
|
options: {
|
||||||
cacheVersion?: number;
|
cacheVersion?: number;
|
||||||
rateLimitOptions?: RateLimitOptions;
|
rateLimitOptions?: rateLimitOptions;
|
||||||
headers?: HeadersInit;
|
headers?: Record<string, string>;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
this.cacheVersion = options.cacheVersion ?? 1;
|
this.cacheVersion = options.cacheVersion ?? 1;
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.key = key;
|
this.key = key;
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
headers: options.headers,
|
||||||
|
});
|
||||||
|
|
||||||
if (options.rateLimitOptions) {
|
if (options.rateLimitOptions) {
|
||||||
this.fetch = rateLimit(fetch, {
|
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||||
...options.rateLimitOptions,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.fetch = fetch;
|
|
||||||
}
|
}
|
||||||
this.headers = options.headers || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImage(
|
public async getImage(
|
||||||
@@ -193,14 +189,34 @@ class ImageProxy {
|
|||||||
public async clearCachedImage(path: string) {
|
public async clearCachedImage(path: string) {
|
||||||
// find cacheKey
|
// find cacheKey
|
||||||
const cacheKey = this.getCacheKey(path);
|
const cacheKey = this.getCacheKey(path);
|
||||||
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promises.access(directory);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') {
|
||||||
|
logger.debug(
|
||||||
|
`Cache directory '${cacheKey}' does not exist; nothing to clear.`,
|
||||||
|
{
|
||||||
|
label: 'Image Cache',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
logger.error('Error checking cache directory existence', {
|
||||||
|
label: 'Image Cache',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
|
||||||
const files = await promises.readdir(directory);
|
const files = await promises.readdir(directory);
|
||||||
|
|
||||||
await promises.rm(directory, { recursive: true });
|
await promises.rm(directory, { recursive: true });
|
||||||
|
|
||||||
logger.info(`Cleared ${files[0]} from cache 'avatar'`, {
|
logger.debug(`Cleared ${files[0]} from cache 'avatar'`, {
|
||||||
label: 'Image Cache',
|
label: 'Image Cache',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -249,34 +265,22 @@ class ImageProxy {
|
|||||||
): Promise<ImageResponse | null> {
|
): Promise<ImageResponse | null> {
|
||||||
try {
|
try {
|
||||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||||
const href =
|
const response = await this.axios.get(path, {
|
||||||
this.baseUrl +
|
responseType: 'arraybuffer',
|
||||||
(this.baseUrl.length > 0
|
|
||||||
? this.baseUrl.endsWith('/')
|
|
||||||
? ''
|
|
||||||
: '/'
|
|
||||||
: '') +
|
|
||||||
(path.startsWith('/') ? path.slice(1) : path);
|
|
||||||
const response = await this.fetch(href, {
|
|
||||||
headers: this.headers || undefined,
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
const extension = mime.getExtension(
|
const buffer = Buffer.from(response.data, 'binary');
|
||||||
response.headers.get('content-type') ?? ''
|
|
||||||
);
|
const contentType = response.headers['content-type'] || '';
|
||||||
|
const extension = mime.getExtension(contentType) || '';
|
||||||
|
|
||||||
let maxAge = Number(
|
let maxAge = Number(
|
||||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!maxAge) maxAge = 86400;
|
if (!maxAge) maxAge = 86400;
|
||||||
const expireAt = Date.now() + maxAge * 1000;
|
const expireAt = Date.now() + maxAge * 1000;
|
||||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||||
|
|
||||||
await this.writeToCacheDir(
|
await this.writeToCacheDir(
|
||||||
directory,
|
directory,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -110,6 +111,8 @@ class DiscordAgent
|
|||||||
): DiscordRichEmbed {
|
): DiscordRichEmbed {
|
||||||
const { applicationUrl } = getSettings().main;
|
const { applicationUrl } = getSettings().main;
|
||||||
|
|
||||||
|
const appUrl =
|
||||||
|
applicationUrl || `http://localhost:${process.env.port || 5055}`;
|
||||||
let color = EmbedColors.DARK_PURPLE;
|
let color = EmbedColors.DARK_PURPLE;
|
||||||
const fields: Field[] = [];
|
const fields: Field[] = [];
|
||||||
|
|
||||||
@@ -124,7 +127,7 @@ class DiscordAgent
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case Notification.MEDIA_PENDING:
|
case Notification.MEDIA_PENDING:
|
||||||
color = EmbedColors.ORANGE;
|
color = EmbedColors.ORANGE;
|
||||||
status = 'Pending Approval';
|
status = `[Pending Approval](${appUrl}/requests)`;
|
||||||
break;
|
break;
|
||||||
case Notification.MEDIA_APPROVED:
|
case Notification.MEDIA_APPROVED:
|
||||||
case Notification.MEDIA_AUTO_APPROVED:
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
@@ -295,39 +298,23 @@ class DiscordAgent
|
|||||||
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await axios.post(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
username: settings.options.botUsername
|
||||||
headers: {
|
? settings.options.botUsername
|
||||||
'Content-Type': 'application/json',
|
: getSettings().main.applicationTitle,
|
||||||
},
|
avatar_url: settings.options.botAvatarUrl,
|
||||||
body: JSON.stringify({
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
username: settings.options.botUsername
|
content: userMentions.join(' '),
|
||||||
? settings.options.botUsername
|
} as DiscordWebhookPayload);
|
||||||
: getSettings().main.applicationTitle,
|
|
||||||
avatar_url: settings.options.botAvatarUrl,
|
|
||||||
embeds: [this.buildEmbed(type, payload)],
|
|
||||||
content: userMentions.join(' '),
|
|
||||||
} as DiscordWebhookPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Discord notification', {
|
logger.error('Error sending Discord notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
|||||||
import type { NotificationAgentGotify } from '@server/lib/settings';
|
import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
@@ -30,7 +31,12 @@ class GotifyAgent
|
|||||||
public shouldSend(): boolean {
|
public shouldSend(): boolean {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
|
|
||||||
if (settings.enabled && settings.options.url && settings.options.token) {
|
if (
|
||||||
|
settings.enabled &&
|
||||||
|
settings.options.url &&
|
||||||
|
settings.options.token &&
|
||||||
|
settings.options.priority
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,15 +48,17 @@ class GotifyAgent
|
|||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): GotifyPayload {
|
): GotifyPayload {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||||
let priority = 0;
|
const settings = this.getSettings();
|
||||||
|
const priority = settings.options.priority ?? 1;
|
||||||
|
|
||||||
const title = payload.event
|
const title = payload.event
|
||||||
? `${payload.event} - ${payload.subject}`
|
? `${payload.event} - ${payload.subject}`
|
||||||
: payload.subject;
|
: payload.subject;
|
||||||
let message = payload.message ?? '';
|
|
||||||
|
let message = payload.message ? `${payload.message} \n\n` : '';
|
||||||
|
|
||||||
if (payload.request) {
|
if (payload.request) {
|
||||||
message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
message += `\n**Requested By:** ${payload.request.requestedBy.displayName} `;
|
||||||
|
|
||||||
let status = '';
|
let status = '';
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -73,29 +81,29 @@ class GotifyAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
message += `\nRequest Status: ${status}`;
|
message += `\n**Request Status:** ${status} `;
|
||||||
}
|
}
|
||||||
} else if (payload.comment) {
|
} else if (payload.comment) {
|
||||||
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message} `;
|
||||||
} else if (payload.issue) {
|
} else if (payload.issue) {
|
||||||
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
message += `\n\n**Reported By:** ${payload.issue.createdBy.displayName} `;
|
||||||
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
message += `\n**Issue Type:** ${
|
||||||
message += `\nIssue Status: ${
|
IssueTypeName[payload.issue.issueType]
|
||||||
|
} `;
|
||||||
|
message += `\n**Issue Status:** ${
|
||||||
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
}`;
|
} `;
|
||||||
|
|
||||||
if (type == Notification.ISSUE_CREATED) {
|
|
||||||
priority = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const extra of payload.extra ?? []) {
|
for (const extra of payload.extra ?? []) {
|
||||||
message += `\n\n**${extra.name}**\n${extra.value}`;
|
message += `\n\n**${extra.name}**\n${extra.value} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (applicationUrl && payload.media) {
|
if (applicationUrl && payload.media) {
|
||||||
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||||
message += `\n\nOpen in ${applicationTitle}(${actionUrl})`;
|
const displayUrl =
|
||||||
|
actionUrl.length > 40 ? `${actionUrl.slice(0, 41)}...` : actionUrl;
|
||||||
|
message += `\n\n**Open in ${applicationTitle}:** [${displayUrl}](${actionUrl}) `;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -132,32 +140,16 @@ class GotifyAgent
|
|||||||
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, notificationPayload);
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(notificationPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Gotify notification', {
|
logger.error('Error sending Gotify notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
|
|||||||
import type { NotificationAgentLunaSea } from '@server/lib/settings';
|
import type { NotificationAgentLunaSea } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
@@ -100,39 +101,28 @@ class LunaSeaAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await axios.post(
|
||||||
method: 'POST',
|
settings.options.webhookUrl,
|
||||||
headers: settings.options.profileName
|
this.buildPayload(type, payload),
|
||||||
|
settings.options.profileName
|
||||||
? {
|
? {
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(
|
||||||
|
`${settings.options.profileName}:`
|
||||||
|
).toString('base64')}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {
|
: undefined
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
Authorization: `Basic ${Buffer.from(
|
|
||||||
`${settings.options.profileName}:`
|
|
||||||
).toString('base64')}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending LunaSea notification', {
|
logger.error('Error sending LunaSea notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
164
server/lib/notifications/agents/ntfy.ts
Normal file
164
server/lib/notifications/agents/ntfy.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||||
|
import type { NotificationAgentNtfy } from '@server/lib/settings';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
import { BaseAgent } from './agent';
|
||||||
|
|
||||||
|
class NtfyAgent
|
||||||
|
extends BaseAgent<NotificationAgentNtfy>
|
||||||
|
implements NotificationAgent
|
||||||
|
{
|
||||||
|
protected getSettings(): NotificationAgentNtfy {
|
||||||
|
if (this.settings) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
return settings.notifications.agents.ntfy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||||
|
const { applicationUrl } = getSettings().main;
|
||||||
|
|
||||||
|
const topic = this.getSettings().options.topic;
|
||||||
|
const priority = 3;
|
||||||
|
|
||||||
|
const title = payload.event
|
||||||
|
? `${payload.event} - ${payload.subject}`
|
||||||
|
: payload.subject;
|
||||||
|
let message = payload.message ?? '';
|
||||||
|
|
||||||
|
if (payload.request) {
|
||||||
|
message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
|
||||||
|
|
||||||
|
let status = '';
|
||||||
|
switch (type) {
|
||||||
|
case Notification.MEDIA_PENDING:
|
||||||
|
status = 'Pending Approval';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_APPROVED:
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
status = 'Processing';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_AVAILABLE:
|
||||||
|
status = 'Available';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_DECLINED:
|
||||||
|
status = 'Declined';
|
||||||
|
break;
|
||||||
|
case Notification.MEDIA_FAILED:
|
||||||
|
status = 'Failed';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
message += `\nRequest Status: ${status}`;
|
||||||
|
}
|
||||||
|
} else if (payload.comment) {
|
||||||
|
message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
|
||||||
|
} else if (payload.issue) {
|
||||||
|
message += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
|
||||||
|
message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
|
||||||
|
message += `\nIssue Status: ${
|
||||||
|
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const extra of payload.extra ?? []) {
|
||||||
|
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attach = payload.image;
|
||||||
|
|
||||||
|
let click;
|
||||||
|
if (applicationUrl && payload.media) {
|
||||||
|
click = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topic,
|
||||||
|
priority,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
attach,
|
||||||
|
click,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(): boolean {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (settings.enabled && settings.options.url && settings.options.topic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = this.getSettings();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!payload.notifySystem ||
|
||||||
|
!hasNotificationType(type, settings.types ?? 0)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending ntfy notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let authHeader;
|
||||||
|
if (
|
||||||
|
settings.options.authMethodUsernamePassword &&
|
||||||
|
settings.options.username &&
|
||||||
|
settings.options.password
|
||||||
|
) {
|
||||||
|
const encodedAuth = Buffer.from(
|
||||||
|
`${settings.options.username}:${settings.options.password}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
authHeader = `Basic ${encodedAuth}`;
|
||||||
|
} else if (settings.options.authMethodToken) {
|
||||||
|
authHeader = `Bearer ${settings.options.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
settings.options.url,
|
||||||
|
this.buildPayload(type, payload),
|
||||||
|
authHeader
|
||||||
|
? {
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error sending ntfy notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e?.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NtfyAgent;
|
||||||
@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -122,34 +123,22 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(
|
||||||
method: 'POST',
|
endpoint,
|
||||||
headers: {
|
{ ...notificationPayload, channel_tag: settings.options.channelTag },
|
||||||
'Content-Type': 'application/json',
|
{
|
||||||
'Access-Token': settings.options.accessToken,
|
headers: {
|
||||||
},
|
'Access-Token': settings.options.accessToken,
|
||||||
body: JSON.stringify({
|
},
|
||||||
...notificationPayload,
|
}
|
||||||
channel_tag: settings.options.channelTag,
|
);
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -174,32 +163,19 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, notificationPayload, {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
|
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -235,32 +211,19 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, notificationPayload, {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Token': user.settings.pushbulletAccessToken,
|
'Access-Token': user.settings.pushbulletAccessToken,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { NotificationAgentPushover } from '@server/lib/settings';
|
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -51,15 +52,12 @@ class PushoverAgent
|
|||||||
imageUrl: string
|
imageUrl: string
|
||||||
): Promise<Partial<PushoverImagePayload>> {
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(imageUrl);
|
const response = await axios.get(imageUrl, {
|
||||||
if (!response.ok) {
|
responseType: 'arraybuffer',
|
||||||
throw new Error(response.statusText, { cause: response });
|
});
|
||||||
}
|
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
|
||||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
|
||||||
const contentType = (
|
const contentType = (
|
||||||
response.headers.get('Content-Type') ||
|
response.headers['Content-Type'] || response.headers['content-type']
|
||||||
response.headers.get('content-type')
|
|
||||||
)?.toString();
|
)?.toString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,17 +65,10 @@ class PushoverAgent
|
|||||||
attachment_type: contentType,
|
attachment_type: contentType,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error getting image payload', {
|
logger.error('Error getting image payload', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -210,35 +201,19 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
token: settings.options.accessToken,
|
||||||
'Content-Type': 'application/json',
|
user: settings.options.userToken,
|
||||||
},
|
sound: settings.options.sound,
|
||||||
body: JSON.stringify({
|
} as PushoverPayload);
|
||||||
...notificationPayload,
|
|
||||||
token: settings.options.accessToken,
|
|
||||||
user: settings.options.userToken,
|
|
||||||
sound: settings.options.sound,
|
|
||||||
} as PushoverPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -266,36 +241,20 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||||
'Content-Type': 'application/json',
|
user: payload.notifyUser.settings.pushoverUserKey,
|
||||||
},
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
body: JSON.stringify({
|
} as PushoverPayload);
|
||||||
...notificationPayload,
|
|
||||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
|
||||||
user: payload.notifyUser.settings.pushoverUserKey,
|
|
||||||
sound: payload.notifyUser.settings.pushoverSound,
|
|
||||||
} as PushoverPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -332,35 +291,19 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
token: user.settings.pushoverApplicationToken,
|
||||||
'Content-Type': 'application/json',
|
user: user.settings.pushoverUserKey,
|
||||||
},
|
} as PushoverPayload);
|
||||||
body: JSON.stringify({
|
|
||||||
...notificationPayload,
|
|
||||||
token: user.settings.pushoverApplicationToken,
|
|
||||||
user: user.settings.pushoverUserKey,
|
|
||||||
} as PushoverPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
|||||||
import type { NotificationAgentSlack } from '@server/lib/settings';
|
import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
import { BaseAgent } from './agent';
|
import { BaseAgent } from './agent';
|
||||||
@@ -237,32 +238,19 @@ class SlackAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await axios.post(
|
||||||
method: 'POST',
|
settings.options.webhookUrl,
|
||||||
headers: {
|
this.buildEmbed(type, payload)
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
},
|
|
||||||
body: JSON.stringify(this.buildEmbed(type, payload)),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Slack notification', {
|
logger.error('Error sending Slack notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { User } from '@server/entity/User';
|
|||||||
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
hasNotificationType,
|
hasNotificationType,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -176,35 +177,19 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
chat_id: settings.options.chatId,
|
||||||
'Content-Type': 'application/json',
|
message_thread_id: settings.options.messageThreadId,
|
||||||
},
|
disable_notification: !!settings.options.sendSilently,
|
||||||
body: JSON.stringify({
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
...notificationPayload,
|
|
||||||
chat_id: settings.options.chatId,
|
|
||||||
message_thread_id: settings.options.messageThreadId,
|
|
||||||
disable_notification: !!settings.options.sendSilently,
|
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -228,38 +213,22 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||||
'Content-Type': 'application/json',
|
message_thread_id:
|
||||||
},
|
payload.notifyUser.settings.telegramMessageThreadId,
|
||||||
body: JSON.stringify({
|
disable_notification:
|
||||||
...notificationPayload,
|
!!payload.notifyUser.settings.telegramSendSilently,
|
||||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
message_thread_id:
|
|
||||||
payload.notifyUser.settings.telegramMessageThreadId,
|
|
||||||
disable_notification:
|
|
||||||
!!payload.notifyUser.settings.telegramSendSilently,
|
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -293,36 +262,20 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await axios.post(endpoint, {
|
||||||
method: 'POST',
|
...notificationPayload,
|
||||||
headers: {
|
chat_id: user.settings.telegramChatId,
|
||||||
'Content-Type': 'application/json',
|
message_thread_id: user.settings.telegramMessageThreadId,
|
||||||
},
|
disable_notification: !!user.settings?.telegramSendSilently,
|
||||||
body: JSON.stringify({
|
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||||
...notificationPayload,
|
|
||||||
chat_id: user.settings.telegramChatId,
|
|
||||||
message_thread_id: user.settings.telegramMessageThreadId,
|
|
||||||
disable_notification: !!user.settings?.telegramSendSilently,
|
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MediaStatus } from '@server/constants/media';
|
|||||||
import type { NotificationAgentWebhook } from '@server/lib/settings';
|
import type { NotificationAgentWebhook } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { hasNotificationType, Notification } from '..';
|
import { hasNotificationType, Notification } from '..';
|
||||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
@@ -177,35 +178,26 @@ class WebhookAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await axios.post(
|
||||||
method: 'POST',
|
settings.options.webhookUrl,
|
||||||
headers: {
|
this.buildPayload(type, payload),
|
||||||
'Content-Type': 'application/json',
|
settings.options.authHeader
|
||||||
...(settings.options.authHeader
|
? {
|
||||||
? { Authorization: settings.options.authHeader }
|
headers: {
|
||||||
: {}),
|
Authorization: settings.options.authHeader,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
}
|
||||||
});
|
: undefined
|
||||||
if (!response.ok) {
|
);
|
||||||
throw new Error(response.statusText, { cause: response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error sending webhook notification', {
|
logger.error('Error sending webhook notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class WebPushAgent
|
|||||||
const allSubs = await userPushSubRepository
|
const allSubs = await userPushSubRepository
|
||||||
.createQueryBuilder('pushSub')
|
.createQueryBuilder('pushSub')
|
||||||
.leftJoinAndSelect('pushSub.user', 'user')
|
.leftJoinAndSelect('pushSub.user', 'user')
|
||||||
.where('pushSub.userId IN (:users)', {
|
.where('pushSub.userId IN (:...users)', {
|
||||||
users: manageUsers.map((user) => user.id),
|
users: manageUsers.map((user) => user.id),
|
||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -281,7 +281,9 @@ class BaseScanner<T> {
|
|||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: season.episodes > 0
|
: season.episodes > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !season.is4kOverride && season.processing
|
: !season.is4kOverride &&
|
||||||
|
season.processing &&
|
||||||
|
existingSeason.status !== MediaStatus.DELETED
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
: existingSeason.status;
|
: existingSeason.status;
|
||||||
|
|
||||||
@@ -294,7 +296,9 @@ class BaseScanner<T> {
|
|||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: this.enable4kShow && season.episodes4k > 0
|
: this.enable4kShow && season.episodes4k > 0
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: season.is4kOverride && season.processing
|
: season.is4kOverride &&
|
||||||
|
season.processing &&
|
||||||
|
existingSeason.status4k !== MediaStatus.DELETED
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
: existingSeason.status4k;
|
: existingSeason.status4k;
|
||||||
} else {
|
} else {
|
||||||
@@ -324,19 +328,25 @@ class BaseScanner<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We want to skip specials when checking if a show is available
|
||||||
const isAllStandardSeasons =
|
const isAllStandardSeasons =
|
||||||
seasons.length &&
|
seasons.length &&
|
||||||
seasons.every(
|
seasons
|
||||||
(season) =>
|
.filter((season) => season.seasonNumber !== 0)
|
||||||
season.episodes === season.totalEpisodes && season.episodes > 0
|
.every(
|
||||||
);
|
(season) =>
|
||||||
|
season.episodes === season.totalEpisodes && season.episodes > 0
|
||||||
|
);
|
||||||
|
|
||||||
const isAll4kSeasons =
|
const isAll4kSeasons =
|
||||||
seasons.length &&
|
seasons.length &&
|
||||||
seasons.every(
|
seasons
|
||||||
(season) =>
|
.filter((season) => season.seasonNumber !== 0)
|
||||||
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
|
.every(
|
||||||
);
|
(season) =>
|
||||||
|
season.episodes4k === season.totalEpisodes &&
|
||||||
|
season.episodes4k > 0
|
||||||
|
);
|
||||||
|
|
||||||
if (media) {
|
if (media) {
|
||||||
media.seasons = [...media.seasons, ...newSeasons];
|
media.seasons = [...media.seasons, ...newSeasons];
|
||||||
@@ -398,16 +408,23 @@ class BaseScanner<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the show is already available, and there are no new seasons, dont adjust
|
// If the show is already available, and there are no new seasons, dont adjust
|
||||||
// the status
|
// the status. Skip specials when performing availability check
|
||||||
const shouldStayAvailable =
|
const shouldStayAvailable =
|
||||||
media.status === MediaStatus.AVAILABLE &&
|
media.status === MediaStatus.AVAILABLE &&
|
||||||
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
|
newSeasons.filter(
|
||||||
.length === 0;
|
(season) =>
|
||||||
|
season.status !== MediaStatus.UNKNOWN &&
|
||||||
|
season.status !== MediaStatus.DELETED &&
|
||||||
|
season.seasonNumber !== 0
|
||||||
|
).length === 0;
|
||||||
const shouldStayAvailable4k =
|
const shouldStayAvailable4k =
|
||||||
media.status4k === MediaStatus.AVAILABLE &&
|
media.status4k === MediaStatus.AVAILABLE &&
|
||||||
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
|
newSeasons.filter(
|
||||||
.length === 0;
|
(season) =>
|
||||||
|
season.status4k !== MediaStatus.UNKNOWN &&
|
||||||
|
season.status4k !== MediaStatus.DELETED &&
|
||||||
|
season.seasonNumber !== 0
|
||||||
|
).length === 0;
|
||||||
media.status =
|
media.status =
|
||||||
isAllStandardSeasons || shouldStayAvailable
|
isAllStandardSeasons || shouldStayAvailable
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
@@ -417,11 +434,13 @@ class BaseScanner<T> {
|
|||||||
season.status === MediaStatus.AVAILABLE
|
season.status === MediaStatus.AVAILABLE
|
||||||
)
|
)
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !seasons.length ||
|
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||||
media.seasons.some(
|
media.seasons.some(
|
||||||
(season) => season.status === MediaStatus.PROCESSING
|
(season) => season.status === MediaStatus.PROCESSING
|
||||||
)
|
)
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
|
: media.status === MediaStatus.DELETED
|
||||||
|
? MediaStatus.DELETED
|
||||||
: MediaStatus.UNKNOWN;
|
: MediaStatus.UNKNOWN;
|
||||||
media.status4k =
|
media.status4k =
|
||||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||||
@@ -433,11 +452,13 @@ class BaseScanner<T> {
|
|||||||
season.status4k === MediaStatus.AVAILABLE
|
season.status4k === MediaStatus.AVAILABLE
|
||||||
)
|
)
|
||||||
? MediaStatus.PARTIALLY_AVAILABLE
|
? MediaStatus.PARTIALLY_AVAILABLE
|
||||||
: !seasons.length ||
|
: (!seasons.length && media.status4k !== MediaStatus.DELETED) ||
|
||||||
media.seasons.some(
|
media.seasons.some(
|
||||||
(season) => season.status4k === MediaStatus.PROCESSING
|
(season) => season.status4k === MediaStatus.PROCESSING
|
||||||
)
|
)
|
||||||
? MediaStatus.PROCESSING
|
? MediaStatus.PROCESSING
|
||||||
|
: media.status4k === MediaStatus.DELETED
|
||||||
|
? MediaStatus.DELETED
|
||||||
: MediaStatus.UNKNOWN;
|
: MediaStatus.UNKNOWN;
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
this.log(`Updating existing title: ${title}`);
|
this.log(`Updating existing title: ${title}`);
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
if (!metadata?.Id) {
|
if (!metadata?.Id) {
|
||||||
logger.debug('No Id metadata for this title. Skipping', {
|
logger.debug('No Id metadata for this title. Skipping', {
|
||||||
label: 'Plex Sync',
|
label: 'Jellyfin Sync',
|
||||||
ratingKey: jellyfinitem.Id,
|
jellyfinItemId: jellyfinitem.Id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,8 +204,8 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
if (!metadata?.Id) {
|
if (!metadata?.Id) {
|
||||||
logger.debug('No Id metadata for this title. Skipping', {
|
logger.debug('No Id metadata for this title. Skipping', {
|
||||||
label: 'Plex Sync',
|
label: 'Jellyfin Sync',
|
||||||
ratingKey: jellyfinitem.Id,
|
jellyfinItemId: jellyfinitem.Id,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,21 +122,24 @@ export interface MainSettings {
|
|||||||
tv: Quota;
|
tv: Quota;
|
||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
streamingRegion: string;
|
streamingRegion: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
|
blacklistedTags: string;
|
||||||
|
blacklistedTagsLimit: number;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
enableSpecialEpisodes: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkSettings {
|
export interface NetworkSettings {
|
||||||
csrfProtection: boolean;
|
csrfProtection: boolean;
|
||||||
forceIpv4First: boolean;
|
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
proxy: ProxySettings;
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
@@ -149,6 +152,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
|
hideBlacklisted: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
mediaServerLogin: boolean;
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
@@ -169,6 +173,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
userEmailRequired: boolean;
|
userEmailRequired: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationAgentConfig {
|
export interface NotificationAgentConfig {
|
||||||
@@ -254,6 +259,19 @@ export interface NotificationAgentGotify extends NotificationAgentConfig {
|
|||||||
options: {
|
options: {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationAgentNtfy extends NotificationAgentConfig {
|
||||||
|
options: {
|
||||||
|
url: string;
|
||||||
|
topic: string;
|
||||||
|
authMethodUsernamePassword?: boolean;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
authMethodToken?: boolean;
|
||||||
|
token?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +279,7 @@ export enum NotificationAgentKey {
|
|||||||
DISCORD = 'discord',
|
DISCORD = 'discord',
|
||||||
EMAIL = 'email',
|
EMAIL = 'email',
|
||||||
GOTIFY = 'gotify',
|
GOTIFY = 'gotify',
|
||||||
|
NTFY = 'ntfy',
|
||||||
PUSHBULLET = 'pushbullet',
|
PUSHBULLET = 'pushbullet',
|
||||||
PUSHOVER = 'pushover',
|
PUSHOVER = 'pushover',
|
||||||
SLACK = 'slack',
|
SLACK = 'slack',
|
||||||
@@ -273,6 +292,7 @@ interface NotificationAgents {
|
|||||||
discord: NotificationAgentDiscord;
|
discord: NotificationAgentDiscord;
|
||||||
email: NotificationAgentEmail;
|
email: NotificationAgentEmail;
|
||||||
gotify: NotificationAgentGotify;
|
gotify: NotificationAgentGotify;
|
||||||
|
ntfy: NotificationAgentNtfy;
|
||||||
lunasea: NotificationAgentLunaSea;
|
lunasea: NotificationAgentLunaSea;
|
||||||
pushbullet: NotificationAgentPushbullet;
|
pushbullet: NotificationAgentPushbullet;
|
||||||
pushover: NotificationAgentPushover;
|
pushover: NotificationAgentPushover;
|
||||||
@@ -302,7 +322,8 @@ export type JobId =
|
|||||||
| 'jellyfin-recently-added-scan'
|
| 'jellyfin-recently-added-scan'
|
||||||
| 'jellyfin-full-scan'
|
| 'jellyfin-full-scan'
|
||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync'
|
||||||
|
| 'process-blacklisted-tags';
|
||||||
|
|
||||||
export interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -343,16 +364,20 @@ class Settings {
|
|||||||
tv: {},
|
tv: {},
|
||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
|
hideBlacklisted: false,
|
||||||
localLogin: true,
|
localLogin: true,
|
||||||
mediaServerLogin: true,
|
mediaServerLogin: true,
|
||||||
newPlexLogin: true,
|
newPlexLogin: true,
|
||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
originalLanguage: '',
|
originalLanguage: '',
|
||||||
|
blacklistedTags: '',
|
||||||
|
blacklistedTagsLimit: 50,
|
||||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
enableSpecialEpisodes: false,
|
enableSpecialEpisodes: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
youtubeUrl: '',
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -463,6 +488,15 @@ class Settings {
|
|||||||
options: {
|
options: {
|
||||||
url: '',
|
url: '',
|
||||||
token: '',
|
token: '',
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ntfy: {
|
||||||
|
enabled: false,
|
||||||
|
types: 0,
|
||||||
|
options: {
|
||||||
|
url: '',
|
||||||
|
topic: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -504,11 +538,13 @@ class Settings {
|
|||||||
'image-cache-cleanup': {
|
'image-cache-cleanup': {
|
||||||
schedule: '0 0 5 * * *',
|
schedule: '0 0 5 * * *',
|
||||||
},
|
},
|
||||||
|
'process-blacklisted-tags': {
|
||||||
|
schedule: '0 30 1 */7 * *',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
network: {
|
network: {
|
||||||
csrfProtection: false,
|
csrfProtection: false,
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
forceIpv4First: false,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
hostname: '',
|
hostname: '',
|
||||||
@@ -588,6 +624,7 @@ class Settings {
|
|||||||
applicationTitle: this.data.main.applicationTitle,
|
applicationTitle: this.data.main.applicationTitle,
|
||||||
applicationUrl: this.data.main.applicationUrl,
|
applicationUrl: this.data.main.applicationUrl,
|
||||||
hideAvailable: this.data.main.hideAvailable,
|
hideAvailable: this.data.main.hideAvailable,
|
||||||
|
hideBlacklisted: this.data.main.hideBlacklisted,
|
||||||
localLogin: this.data.main.localLogin,
|
localLogin: this.data.main.localLogin,
|
||||||
mediaServerLogin: this.data.main.mediaServerLogin,
|
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||||
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||||
@@ -612,6 +649,7 @@ class Settings {
|
|||||||
userEmailRequired:
|
userEmailRequired:
|
||||||
this.data.notifications.agents.email.options.userEmailRequired,
|
this.data.notifications.agents.email.options.userEmailRequired,
|
||||||
newPlexLogin: this.data.main.newPlexLogin,
|
newPlexLogin: this.data.main.newPlexLogin,
|
||||||
|
youtubeUrl: this.data.main.youtubeUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||||
|
name = 'AddBlacklistTagsColumn1737320080282';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" ADD blacklistedTags character varying`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" DROP COLUMN blacklistedTags`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/migration/postgres/1743023615532-UpdateWebPush.ts
Normal file
29
server/migration/postgres/1743023615532-UpdateWebPush.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1743023615532 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1743023615532';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD "userAgent" character varying`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserAvatarCacheFields1743107707465
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUserAvatarCacheFields1743107707465';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user" ADD "avatarETag" character varying`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user" ADD "avatarVersion" character varying`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/migration/postgres/1745492376568-UpdateWebPush.ts
Normal file
17
server/migration/postgres/1745492376568-UpdateWebPush.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1745492376568 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1745492376568';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedtags" TO "blacklistedTags"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME COLUMN "blacklistedTags" TO "blacklistedtags"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
231
server/migration/postgres/1746811308203-FixIssueTimestamps.ts
Normal file
231
server/migration/postgres/1746811308203-FixIssueTimestamps.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class FixIssueTimestamps1746811308203 implements MigrationInterface {
|
||||||
|
name = 'FixIssueTimestamps1746811308203';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user_push_subscription"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "blacklist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "discover_slider"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue_comment"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "issue"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "blacklist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "user_push_subscription"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "media_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "season_request"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "override_rule"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "updatedAt" TYPE TIMESTAMP
|
||||||
|
USING "updatedAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "watchlist"
|
||||||
|
ALTER COLUMN "createdAt" TYPE TIMESTAMP
|
||||||
|
USING "createdAt" AT TIME ZONE 'UTC'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||||
|
name = 'AddBlacklistTagsColumn1737320080282';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
server/migration/sqlite/1743023610704-UpdateWebPush.ts
Normal file
203
server/migration/sqlite/1743023610704-UpdateWebPush.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1743023610704 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1743023610704';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "media"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserAvatarCacheFields1743107645301
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUserAvatarCacheFields1743107645301';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
server/migration/sqlite/1745492372230-UpdateWebPush.ts
Normal file
79
server/migration/sqlite/1745492372230-UpdateWebPush.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1745492372230 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1745492372230';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { checkAvatarChanged } from '@server/routes/avatarproxy';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
import axios from 'axios';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
@@ -216,6 +219,10 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getUserAvatarUrl(user: User): string {
|
||||||
|
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
authRoutes.post('/jellyfin', async (req, res, next) => {
|
authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
@@ -270,11 +277,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
select: { id: true, jellyfinDeviceId: true },
|
select: { id: true, jellyfinDeviceId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
let deviceId = '';
|
let deviceId = 'BOT_jellyseerr';
|
||||||
if (user) {
|
if (user && user.id === 1) {
|
||||||
deviceId = user.jellyfinDeviceId ?? '';
|
// Admin is always BOT_jellyseerr
|
||||||
} else {
|
deviceId = 'BOT_jellyseerr';
|
||||||
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
|
} else if (user && user.jellyfinDeviceId) {
|
||||||
|
deviceId = user.jellyfinDeviceId;
|
||||||
|
} else if (body.username) {
|
||||||
|
deviceId = Buffer.from(`BOT_jellyseerr_${body.username}`).toString(
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,12 +353,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinDeviceId: deviceId,
|
jellyfinDeviceId: deviceId,
|
||||||
jellyfinAuthToken: account.AccessToken,
|
jellyfinAuthToken: account.AccessToken,
|
||||||
permissions: Permission.ADMIN,
|
permissions: Permission.ADMIN,
|
||||||
avatar: `/avatarproxy/${account.User.Id}`,
|
|
||||||
userType:
|
userType:
|
||||||
body.serverType === MediaServerType.JELLYFIN
|
body.serverType === MediaServerType.JELLYFIN
|
||||||
? UserType.JELLYFIN
|
? UserType.JELLYFIN
|
||||||
: UserType.EMBY,
|
: UserType.EMBY,
|
||||||
});
|
});
|
||||||
|
user.avatar = getUserAvatarUrl(user);
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
} else {
|
} else {
|
||||||
@@ -375,7 +385,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
user.jellyfinDeviceId = deviceId;
|
user.jellyfinDeviceId = deviceId;
|
||||||
user.jellyfinAuthToken = account.AccessToken;
|
user.jellyfinAuthToken = account.AccessToken;
|
||||||
user.permissions = Permission.ADMIN;
|
user.permissions = Permission.ADMIN;
|
||||||
user.avatar = `/avatarproxy/${account.User.Id}`;
|
user.avatar = getUserAvatarUrl(user);
|
||||||
user.userType =
|
user.userType =
|
||||||
body.serverType === MediaServerType.JELLYFIN
|
body.serverType === MediaServerType.JELLYFIN
|
||||||
? UserType.JELLYFIN
|
? UserType.JELLYFIN
|
||||||
@@ -422,7 +432,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
user.avatar = `/avatarproxy/${account.User.Id}`;
|
user.avatar = getUserAvatarUrl(user);
|
||||||
user.jellyfinUsername = account.User.Name;
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
|
||||||
if (user.username === account.User.Name) {
|
if (user.username === account.User.Name) {
|
||||||
@@ -460,12 +470,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinUserId: account.User.Id,
|
jellyfinUserId: account.User.Id,
|
||||||
jellyfinDeviceId: deviceId,
|
jellyfinDeviceId: deviceId,
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: `/avatarproxy/${account.User.Id}`,
|
|
||||||
userType:
|
userType:
|
||||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
? UserType.JELLYFIN
|
? UserType.JELLYFIN
|
||||||
: UserType.EMBY,
|
: UserType.EMBY,
|
||||||
});
|
});
|
||||||
|
user.avatar = getUserAvatarUrl(user);
|
||||||
|
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
@@ -475,6 +485,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user && user.jellyfinUserId) {
|
||||||
|
try {
|
||||||
|
const { changed } = await checkAvatarChanged(user);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
user.avatar = getUserAvatarUrl(user);
|
||||||
|
await userRepository.save(user);
|
||||||
|
logger.debug('Avatar updated during login', {
|
||||||
|
userId: user.id,
|
||||||
|
jellyfinUserId: user.jellyfinUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling avatar during login', {
|
||||||
|
label: 'Auth',
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set logged in session
|
// Set logged in session
|
||||||
if (req.session) {
|
if (req.session) {
|
||||||
req.session.userId = user?.id;
|
req.session.userId = user?.id;
|
||||||
@@ -486,7 +516,9 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
case ApiErrorCode.InvalidUrl:
|
case ApiErrorCode.InvalidUrl:
|
||||||
logger.error(
|
logger.error(
|
||||||
`The provided ${
|
`The provided ${
|
||||||
process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
|
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? ServerType.JELLYFIN
|
||||||
|
: ServerType.EMBY
|
||||||
} is invalid or the server is not reachable.`,
|
} is invalid or the server is not reachable.`,
|
||||||
{
|
{
|
||||||
label: 'Auth',
|
label: 'Auth',
|
||||||
@@ -689,17 +721,79 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/logout', (req, res, next) => {
|
authRoutes.post('/logout', async (req, res, next) => {
|
||||||
req.session?.destroy((err) => {
|
try {
|
||||||
if (err) {
|
const userId = req.session?.userId;
|
||||||
return next({
|
if (!userId) {
|
||||||
status: 500,
|
return res.status(200).json({ status: 'ok' });
|
||||||
message: 'Something went wrong.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({ status: 'ok' });
|
const settings = getSettings();
|
||||||
});
|
const isJellyfinOrEmby =
|
||||||
|
settings.main.mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
settings.main.mediaServerType === MediaServerType.EMBY;
|
||||||
|
|
||||||
|
if (isJellyfinOrEmby) {
|
||||||
|
const user = await getRepository(User)
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect(['user.jellyfinUserId', 'user.jellyfinDeviceId'])
|
||||||
|
.where('user.id = :id', { id: userId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (user?.jellyfinUserId && user.jellyfinDeviceId) {
|
||||||
|
try {
|
||||||
|
const baseUrl = getHostname();
|
||||||
|
try {
|
||||||
|
await axios.delete(`${baseUrl}/Devices`, {
|
||||||
|
params: { Id: user.jellyfinDeviceId },
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="jellyseerr", Version="${getAppVersion()}", Token="${
|
||||||
|
settings.jellyfin.apiKey
|
||||||
|
}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete Jellyfin device', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: user.id,
|
||||||
|
jellyfinUserId: user.jellyfinUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to delete Jellyfin device', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: user.id,
|
||||||
|
jellyfinUserId: user.jellyfinUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session?.destroy((err: Error | null) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Failed to destroy session', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: err.message,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||||
|
}
|
||||||
|
logger.info('Successfully logged out user', {
|
||||||
|
label: 'Auth',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during logout process', {
|
||||||
|
label: 'Auth',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
userId: req.session?.userId,
|
||||||
|
});
|
||||||
|
next({ status: 500, message: 'Error during logout process.' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/reset-password', async (req, res, next) => {
|
authRoutes.post('/reset-password', async (req, res, next) => {
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import { getSettings } from '@server/lib/settings';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
import axios from 'axios';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
let _avatarImageProxy: ImageProxy | null = null;
|
let _avatarImageProxy: ImageProxy | null = null;
|
||||||
|
|
||||||
async function initAvatarImageProxy() {
|
async function initAvatarImageProxy() {
|
||||||
if (!_avatarImageProxy) {
|
if (!_avatarImageProxy) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
@@ -20,7 +23,7 @@ async function initAvatarImageProxy() {
|
|||||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const deviceId = admin?.jellyfinDeviceId;
|
const deviceId = admin?.jellyfinDeviceId || 'BOT_jellyseerr';
|
||||||
const authToken = getSettings().jellyfin.apiKey;
|
const authToken = getSettings().jellyfin.apiKey;
|
||||||
_avatarImageProxy = new ImageProxy('avatar', '', {
|
_avatarImageProxy = new ImageProxy('avatar', '', {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -31,6 +34,83 @@ async function initAvatarImageProxy() {
|
|||||||
return _avatarImageProxy;
|
return _avatarImageProxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getJellyfinAvatarUrl(userId: string) {
|
||||||
|
const settings = getSettings();
|
||||||
|
return settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? `${getHostname()}/UserImage?UserId=${userId}`
|
||||||
|
: `${getHostname()}/Users/${userId}/Images/Primary?quality=90`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeImageHash(buffer: Buffer): string {
|
||||||
|
return createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAvatarChanged(
|
||||||
|
user: User
|
||||||
|
): Promise<{ changed: boolean; etag?: string }> {
|
||||||
|
try {
|
||||||
|
if (!user || !user.jellyfinUserId) {
|
||||||
|
return { changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId);
|
||||||
|
|
||||||
|
let headResponse;
|
||||||
|
try {
|
||||||
|
headResponse = await axios.head(jellyfinAvatarUrl);
|
||||||
|
if (headResponse.status !== 200) {
|
||||||
|
return { changed: false };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
let remoteVersion: string;
|
||||||
|
if (settings.main.mediaServerType === MediaServerType.JELLYFIN) {
|
||||||
|
const remoteLastModifiedStr = headResponse.headers['last-modified'] || '';
|
||||||
|
remoteVersion = (
|
||||||
|
Date.parse(remoteLastModifiedStr) || Date.now()
|
||||||
|
).toString();
|
||||||
|
} else if (settings.main.mediaServerType === MediaServerType.EMBY) {
|
||||||
|
remoteVersion =
|
||||||
|
headResponse.headers['etag']?.replace(/"/g, '') ||
|
||||||
|
Date.now().toString();
|
||||||
|
} else {
|
||||||
|
remoteVersion = Date.now().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.avatarVersion && user.avatarVersion === remoteVersion) {
|
||||||
|
return { changed: false, etag: user.avatarETag ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarImageCache = await initAvatarImageProxy();
|
||||||
|
await avatarImageCache.clearCachedImage(jellyfinAvatarUrl);
|
||||||
|
const imageData = await avatarImageCache.getImage(
|
||||||
|
jellyfinAvatarUrl,
|
||||||
|
gravatarUrl(user.email || 'none', { default: 'mm', size: 200 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const newHash = computeImageHash(imageData.imageBuffer);
|
||||||
|
|
||||||
|
const hasChanged = user.avatarETag !== newHash;
|
||||||
|
|
||||||
|
user.avatarVersion = remoteVersion;
|
||||||
|
if (hasChanged) {
|
||||||
|
user.avatarETag = newHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getRepository(User).save(user);
|
||||||
|
|
||||||
|
return { changed: hasChanged, etag: newHash };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking avatar changes', {
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
return { changed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:jellyfinUserId', async (req, res) => {
|
router.get('/:jellyfinUserId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) {
|
||||||
@@ -46,6 +126,10 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
|||||||
|
|
||||||
const avatarImageCache = await initAvatarImageProxy();
|
const avatarImageCache = await initAvatarImageProxy();
|
||||||
|
|
||||||
|
const userEtag = req.headers['if-none-match'];
|
||||||
|
|
||||||
|
const versionParam = req.query.v;
|
||||||
|
|
||||||
const user = await getRepository(User).findOne({
|
const user = await getRepository(User).findOne({
|
||||||
where: { jellyfinUserId: req.params.jellyfinUserId },
|
where: { jellyfinUserId: req.params.jellyfinUserId },
|
||||||
});
|
});
|
||||||
@@ -55,13 +139,7 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
|||||||
size: 200,
|
size: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setttings = getSettings();
|
const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId);
|
||||||
const jellyfinAvatarUrl =
|
|
||||||
setttings.main.mediaServerType === MediaServerType.JELLYFIN
|
|
||||||
? `${getHostname()}/UserImage?UserId=${req.params.jellyfinUserId}`
|
|
||||||
: `${getHostname()}/Users/${
|
|
||||||
req.params.jellyfinUserId
|
|
||||||
}/Images/Primary?quality=90`;
|
|
||||||
|
|
||||||
let imageData = await avatarImageCache.getImage(
|
let imageData = await avatarImageCache.getImage(
|
||||||
jellyfinAvatarUrl,
|
jellyfinAvatarUrl,
|
||||||
@@ -73,10 +151,15 @@ router.get('/:jellyfinUserId', async (req, res) => {
|
|||||||
imageData = await avatarImageCache.getImage(fallbackUrl);
|
imageData = await avatarImageCache.getImage(fallbackUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) {
|
||||||
|
return res.status(304).end();
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': `image/${imageData.meta.extension}`,
|
'Content-Type': `image/${imageData.meta.extension}`,
|
||||||
'Content-Length': imageData.imageBuffer.length,
|
'Content-Length': imageData.imageBuffer.length,
|
||||||
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
|
||||||
|
ETag: `"${imageData.meta.etag}"`,
|
||||||
'OS-Cache-Key': imageData.meta.cacheKey,
|
'OS-Cache-Key': imageData.meta.cacheKey,
|
||||||
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,39 +19,54 @@ export const blacklistAdd = z.object({
|
|||||||
user: z.coerce.number(),
|
user: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blacklistGet = z.object({
|
||||||
|
take: z.coerce.number().int().positive().default(25),
|
||||||
|
skip: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
search: z.string().optional(),
|
||||||
|
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
blacklistRoutes.get(
|
blacklistRoutes.get(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
const { take, skip, search, filter } = blacklistGet.parse(req.query);
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
||||||
const search = (req.query.search as string) ?? '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = getRepository(Blacklist)
|
let query = getRepository(Blacklist)
|
||||||
.createQueryBuilder('blacklist')
|
.createQueryBuilder('blacklist')
|
||||||
.leftJoinAndSelect('blacklist.user', 'user');
|
.leftJoinAndSelect('blacklist.user', 'user')
|
||||||
|
.where('1 = 1'); // Allow use of andWhere later
|
||||||
|
|
||||||
if (search.length > 0) {
|
switch (filter) {
|
||||||
query = query.where('blacklist.title like :title', {
|
case 'manual':
|
||||||
|
query = query.andWhere('blacklist.blacklistedTags IS NULL');
|
||||||
|
break;
|
||||||
|
case 'blacklistedTags':
|
||||||
|
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query = query.andWhere('blacklist.title like :title', {
|
||||||
title: `%${search}%`,
|
title: `%${search}%`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [blacklistedItems, itemsCount] = await query
|
const [blacklistedItems, itemsCount] = await query
|
||||||
.orderBy('blacklist.createdAt', 'DESC')
|
.orderBy('blacklist.createdAt', 'DESC')
|
||||||
.take(pageSize)
|
.take(take)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(itemsCount / pageSize),
|
pages: Math.ceil(itemsCount / take),
|
||||||
pageSize,
|
pageSize: take,
|
||||||
results: itemsCount,
|
results: itemsCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / take) + 1,
|
||||||
},
|
},
|
||||||
results: blacklistedItems,
|
results: blacklistedItems,
|
||||||
} as BlacklistResultsResponse);
|
} as BlacklistResultsResponse);
|
||||||
|
|||||||
@@ -72,16 +72,25 @@ const QueryFilterOptions = z.object({
|
|||||||
watchProviders: z.coerce.string().optional(),
|
watchProviders: z.coerce.string().optional(),
|
||||||
watchRegion: z.coerce.string().optional(),
|
watchRegion: z.coerce.string().optional(),
|
||||||
status: z.coerce.string().optional(),
|
status: z.coerce.string().optional(),
|
||||||
|
certification: z.coerce.string().optional(),
|
||||||
|
certificationGte: z.coerce.string().optional(),
|
||||||
|
certificationLte: z.coerce.string().optional(),
|
||||||
|
certificationCountry: z.coerce.string().optional(),
|
||||||
|
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||||
|
const ApiQuerySchema = QueryFilterOptions.omit({
|
||||||
|
certificationMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
discoverRoutes.get('/movies', async (req, res, next) => {
|
discoverRoutes.get('/movies', async (req, res, next) => {
|
||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = QueryFilterOptions.parse(req.query);
|
const query = ApiQuerySchema.parse(req.query);
|
||||||
const keywords = query.keywords;
|
const keywords = query.keywords;
|
||||||
|
|
||||||
const data = await tmdb.getDiscoverMovies({
|
const data = await tmdb.getDiscoverMovies({
|
||||||
page: Number(query.page),
|
page: Number(query.page),
|
||||||
sortBy: query.sortBy as SortOptions,
|
sortBy: query.sortBy as SortOptions,
|
||||||
@@ -104,6 +113,10 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
|||||||
voteCountLte: query.voteCountLte,
|
voteCountLte: query.voteCountLte,
|
||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
|
certification: query.certification,
|
||||||
|
certificationGte: query.certificationGte,
|
||||||
|
certificationLte: query.certificationLte,
|
||||||
|
certificationCountry: query.certificationCountry,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
@@ -362,7 +375,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
const tmdb = createTmdbWithRegionLanguage(req.user);
|
const tmdb = createTmdbWithRegionLanguage(req.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = QueryFilterOptions.parse(req.query);
|
const query = ApiQuerySchema.parse(req.query);
|
||||||
const keywords = query.keywords;
|
const keywords = query.keywords;
|
||||||
const data = await tmdb.getDiscoverTv({
|
const data = await tmdb.getDiscoverTv({
|
||||||
page: Number(query.page),
|
page: Number(query.page),
|
||||||
@@ -387,6 +400,10 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
|||||||
watchProviders: query.watchProviders,
|
watchProviders: query.watchProviders,
|
||||||
watchRegion: query.watchRegion,
|
watchRegion: query.watchRegion,
|
||||||
withStatus: query.status,
|
withStatus: query.status,
|
||||||
|
certification: query.certification,
|
||||||
|
certificationGte: query.certificationGte,
|
||||||
|
certificationLte: query.certificationLte,
|
||||||
|
certificationCountry: query.certificationCountry,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getRelatedMedia(
|
const media = await Media.getRelatedMedia(
|
||||||
|
|||||||
@@ -3,19 +3,36 @@ import logger from '@server/logger';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||||
rateLimitOptions: {
|
rateLimitOptions: {
|
||||||
|
maxRequests: 20,
|
||||||
|
maxRPS: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
||||||
|
rateLimitOptions: {
|
||||||
|
maxRequests: 20,
|
||||||
maxRPS: 50,
|
maxRPS: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
router.get('/:type/*', async (req, res) => {
|
||||||
* Image Proxy
|
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||||
*/
|
|
||||||
router.get('/*', async (req, res) => {
|
|
||||||
const imagePath = req.path.replace('/image', '');
|
|
||||||
try {
|
try {
|
||||||
const imageData = await tmdbImageProxy.getImage(imagePath);
|
let imageData;
|
||||||
|
if (req.params.type === 'tmdb') {
|
||||||
|
imageData = await tmdbImageProxy.getImage(imagePath);
|
||||||
|
} else if (req.params.type === 'tvdb') {
|
||||||
|
imageData = await tvdbImageProxy.getImage(imagePath);
|
||||||
|
} else {
|
||||||
|
logger.error('Unsupported image type', {
|
||||||
|
imagePath,
|
||||||
|
type: req.params.type,
|
||||||
|
});
|
||||||
|
res.status(400).send('Unsupported image type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': `image/${imageData.meta.extension}`,
|
'Content-Type': `image/${imageData.meta.extension}`,
|
||||||
|
|||||||
@@ -401,6 +401,48 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/certifications/movie',
|
||||||
|
isAuthenticated(),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certifications = await tmdb.getMovieCertifications();
|
||||||
|
|
||||||
|
return res.status(200).json(certifications);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong retrieving movie certifications', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve movie certifications.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/certifications/tv', isAuthenticated(), async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const certifications = await tmdb.getTvCertifications();
|
||||||
|
|
||||||
|
return res.status(200).json(certifications);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Something went wrong retrieving TV certifications', {
|
||||||
|
label: 'API',
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Unable to retrieve TV certifications.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Jellyseerr API',
|
api: 'Jellyseerr API',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import TheMovieDb from '@server/api/themoviedb';
|
|||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type {
|
import type {
|
||||||
MediaResultsResponse,
|
MediaResultsResponse,
|
||||||
@@ -101,6 +102,7 @@ mediaRoutes.post<
|
|||||||
isAuthenticated(Permission.MANAGE_REQUESTS),
|
isAuthenticated(Permission.MANAGE_REQUESTS),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: Number(req.params.id) },
|
where: { id: Number(req.params.id) },
|
||||||
@@ -115,11 +117,25 @@ mediaRoutes.post<
|
|||||||
switch (req.params.status) {
|
switch (req.params.status) {
|
||||||
case 'available':
|
case 'available':
|
||||||
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||||
|
|
||||||
if (media.mediaType === MediaType.TV) {
|
if (media.mediaType === MediaType.TV) {
|
||||||
// Mark all seasons available
|
const expectedSeasons = req.body.seasons ?? [];
|
||||||
media.seasons.forEach((season) => {
|
|
||||||
|
for (const expectedSeason of expectedSeasons) {
|
||||||
|
let season = media.seasons.find(
|
||||||
|
(s) => s.seasonNumber === expectedSeason?.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!season) {
|
||||||
|
// Create the season if it doesn't exist
|
||||||
|
season = seasonRepository.create({
|
||||||
|
seasonNumber: expectedSeason?.seasonNumber,
|
||||||
|
});
|
||||||
|
media.seasons.push(season);
|
||||||
|
}
|
||||||
|
|
||||||
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'partial':
|
case 'partial':
|
||||||
@@ -237,19 +253,6 @@ mediaRoutes.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
// check if the movie exists
|
|
||||||
try {
|
|
||||||
await (service as RadarrAPI).getMovie({
|
|
||||||
id: parseInt(
|
|
||||||
is4k
|
|
||||||
? (media.externalServiceSlug4k as string)
|
|
||||||
: (media.externalServiceSlug as string)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return res.status(204).send();
|
|
||||||
}
|
|
||||||
// remove the movie
|
|
||||||
await (service as RadarrAPI).removeMovie(
|
await (service as RadarrAPI).removeMovie(
|
||||||
parseInt(
|
parseInt(
|
||||||
is4k
|
is4k
|
||||||
@@ -264,13 +267,6 @@ mediaRoutes.delete(
|
|||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
}
|
}
|
||||||
// check if the series exists
|
|
||||||
try {
|
|
||||||
await (service as SonarrAPI).getSeriesByTvdbId(tvdbId);
|
|
||||||
} catch {
|
|
||||||
return res.status(204).send();
|
|
||||||
}
|
|
||||||
// remove the series
|
|
||||||
await (service as SonarrAPI).removeSerie(tvdbId);
|
await (service as SonarrAPI).removeSerie(tvdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
const requestedBy = req.query.requestedBy
|
const requestedBy = req.query.requestedBy
|
||||||
? Number(req.query.requestedBy)
|
? Number(req.query.requestedBy)
|
||||||
: null;
|
: null;
|
||||||
|
const mediaType = (req.query.mediaType as MediaType | 'all') || 'all';
|
||||||
|
|
||||||
let statusFilter: MediaRequestStatus[];
|
let statusFilter: MediaRequestStatus[];
|
||||||
|
|
||||||
switch (req.query.filter) {
|
switch (req.query.filter) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
case 'processing':
|
case 'processing':
|
||||||
case 'available':
|
|
||||||
statusFilter = [MediaRequestStatus.APPROVED];
|
statusFilter = [MediaRequestStatus.APPROVED];
|
||||||
break;
|
break;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
@@ -59,12 +59,18 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
case 'failed':
|
case 'failed':
|
||||||
statusFilter = [MediaRequestStatus.FAILED];
|
statusFilter = [MediaRequestStatus.FAILED];
|
||||||
break;
|
break;
|
||||||
|
case 'completed':
|
||||||
|
case 'available':
|
||||||
|
case 'deleted':
|
||||||
|
statusFilter = [MediaRequestStatus.COMPLETED];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
statusFilter = [
|
statusFilter = [
|
||||||
MediaRequestStatus.PENDING,
|
MediaRequestStatus.PENDING,
|
||||||
MediaRequestStatus.APPROVED,
|
MediaRequestStatus.APPROVED,
|
||||||
MediaRequestStatus.DECLINED,
|
MediaRequestStatus.DECLINED,
|
||||||
MediaRequestStatus.FAILED,
|
MediaRequestStatus.FAILED,
|
||||||
|
MediaRequestStatus.COMPLETED,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +89,9 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
case 'deleted':
|
||||||
|
mediaStatusFilter = [MediaStatus.DELETED];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
mediaStatusFilter = [
|
mediaStatusFilter = [
|
||||||
MediaStatus.UNKNOWN,
|
MediaStatus.UNKNOWN,
|
||||||
@@ -90,6 +99,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
MediaStatus.PROCESSING,
|
MediaStatus.PROCESSING,
|
||||||
MediaStatus.PARTIALLY_AVAILABLE,
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
MediaStatus.AVAILABLE,
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.DELETED,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,21 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (mediaType) {
|
||||||
|
case 'all':
|
||||||
|
break;
|
||||||
|
case 'movie':
|
||||||
|
query = query.andWhere('request.type = :type', {
|
||||||
|
type: MediaType.MOVIE,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'tv':
|
||||||
|
query = query.andWhere('request.type = :type', {
|
||||||
|
type: MediaType.TV,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const [requests, requestCount] = await query
|
const [requests, requestCount] = await query
|
||||||
.orderBy(sortFilter, sortDirection)
|
.orderBy(sortFilter, sortDirection)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
@@ -189,7 +214,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// add profile names to the media requests, with undefined if not found
|
// add profile names to the media requests, with undefined if not found
|
||||||
const requestsWithProfileNames = requests.map((r) => {
|
let mappedRequests = requests.map((r) => {
|
||||||
switch (r.type) {
|
switch (r.type) {
|
||||||
case MediaType.MOVIE: {
|
case MediaType.MOVIE: {
|
||||||
const profileName = radarrServers
|
const profileName = radarrServers
|
||||||
@@ -212,6 +237,36 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// add canRemove prop if user has permission
|
||||||
|
if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) {
|
||||||
|
mappedRequests = mappedRequests.map((r) => {
|
||||||
|
switch (r.type) {
|
||||||
|
case MediaType.MOVIE: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
// check if the radarr server for this request is configured
|
||||||
|
canRemove: radarrServers.some(
|
||||||
|
(server) =>
|
||||||
|
server.id ===
|
||||||
|
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case MediaType.TV: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
// check if the sonarr server for this request is configured
|
||||||
|
canRemove: sonarrServers.some(
|
||||||
|
(server) =>
|
||||||
|
server.id ===
|
||||||
|
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(requestCount / pageSize),
|
pages: Math.ceil(requestCount / pageSize),
|
||||||
@@ -219,7 +274,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
results: requestCount,
|
results: requestCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: requestsWithProfileNames,
|
results: mappedRequests,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
@@ -268,7 +323,7 @@ requestRoutes.get('/count', async (_req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const query = requestRepository
|
const query = requestRepository
|
||||||
.createQueryBuilder('request')
|
.createQueryBuilder('request')
|
||||||
.leftJoinAndSelect('request.media', 'media');
|
.innerJoinAndSelect('request.media', 'media');
|
||||||
|
|
||||||
const totalCount = await query.getCount();
|
const totalCount = await query.getCount();
|
||||||
|
|
||||||
@@ -462,7 +517,8 @@ requestRoutes.put<{ requestId: string }>(
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.is4k === request.is4k &&
|
r.is4k === request.is4k &&
|
||||||
r.id !== request.id &&
|
r.id !== request.id &&
|
||||||
r.status !== MediaRequestStatus.DECLINED
|
r.status !== MediaRequestStatus.DECLINED &&
|
||||||
|
r.status !== MediaRequestStatus.COMPLETED
|
||||||
)
|
)
|
||||||
.reduce((seasons, r) => {
|
.reduce((seasons, r) => {
|
||||||
const combinedSeasons = r.seasons.map(
|
const combinedSeasons = r.seasons.map(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord';
|
|||||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||||
|
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||||
import SlackAgent from '@server/lib/notifications/agents/slack';
|
import SlackAgent from '@server/lib/notifications/agents/slack';
|
||||||
@@ -413,4 +414,38 @@ notificationRoutes.post('/gotify/test', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notificationRoutes.get('/ntfy', (_req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
res.status(200).json(settings.notifications.agents.ntfy);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationRoutes.post('/ntfy', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
settings.notifications.agents.ntfy = req.body;
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
res.status(200).json(settings.notifications.agents.ntfy);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationRoutes.post('/ntfy/test', async (req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'User information is missing from the request.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ntfyAgent = new NtfyAgent(req.body);
|
||||||
|
if (await sendTestNotification(ntfyAgent, req.user)) {
|
||||||
|
return res.status(204).send();
|
||||||
|
} else {
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'Failed to send ntfy notification.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default notificationRoutes;
|
export default notificationRoutes;
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ router.get('/', async (req, res, next) => {
|
|||||||
: Math.max(10, includeIds.length);
|
: Math.max(10, includeIds.length);
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||||
|
const sortDirection =
|
||||||
|
(req.query.sortDirection as string) === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
let query = getRepository(User).createQueryBuilder('user');
|
let query = getRepository(User).createQueryBuilder('user');
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
@@ -56,28 +59,31 @@ router.get('/', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (req.query.sort) {
|
switch (req.query.sort) {
|
||||||
|
case 'created':
|
||||||
|
query = query.orderBy('user.createdAt', sortDirection);
|
||||||
|
break;
|
||||||
case 'updated':
|
case 'updated':
|
||||||
query = query.orderBy('user.updatedAt', 'DESC');
|
query = query.orderBy('user.updatedAt', sortDirection);
|
||||||
break;
|
break;
|
||||||
case 'displayname':
|
case 'displayname':
|
||||||
query = query
|
query = query
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||||
"user"."email"
|
"user"."email"
|
||||||
|
ELSE
|
||||||
|
LOWER(user.jellyfinUsername)
|
||||||
|
END)
|
||||||
ELSE
|
ELSE
|
||||||
LOWER(user.jellyfinUsername)
|
LOWER(user.plexUsername)
|
||||||
END)
|
END)
|
||||||
ELSE
|
ELSE
|
||||||
LOWER(user.jellyfinUsername)
|
LOWER(user.username)
|
||||||
END)
|
END`,
|
||||||
ELSE
|
|
||||||
LOWER(user.username)
|
|
||||||
END`,
|
|
||||||
'displayname_sort_key'
|
'displayname_sort_key'
|
||||||
)
|
)
|
||||||
.orderBy('displayname_sort_key', 'ASC');
|
.orderBy('displayname_sort_key', sortDirection);
|
||||||
break;
|
break;
|
||||||
case 'requests':
|
case 'requests':
|
||||||
query = query
|
query = query
|
||||||
@@ -87,10 +93,16 @@ router.get('/', async (req, res, next) => {
|
|||||||
.from(MediaRequest, 'request')
|
.from(MediaRequest, 'request')
|
||||||
.where('request.requestedBy.id = user.id');
|
.where('request.requestedBy.id = user.id');
|
||||||
}, 'request_count')
|
}, 'request_count')
|
||||||
.orderBy('request_count', 'DESC');
|
.orderBy('request_count', sortDirection);
|
||||||
|
break;
|
||||||
|
case 'usertype':
|
||||||
|
query = query.orderBy('user.userType', sortDirection);
|
||||||
|
break;
|
||||||
|
case 'role':
|
||||||
|
query = query.orderBy('user.permissions', sortDirection);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
query = query.orderBy('user.id', 'ASC');
|
query = query.orderBy('user.id', sortDirection);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,13 +196,15 @@ router.post<
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
auth: string;
|
auth: string;
|
||||||
|
userAgent: string;
|
||||||
}
|
}
|
||||||
>('/registerPushSubscription', async (req, res, next) => {
|
>('/registerPushSubscription', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
const existingSubs = await userPushSubRepository.find({
|
const existingSubs = await userPushSubRepository.find({
|
||||||
where: { auth: req.body.auth },
|
relations: { user: true },
|
||||||
|
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSubs.length > 0) {
|
if (existingSubs.length > 0) {
|
||||||
@@ -205,6 +219,7 @@ router.post<
|
|||||||
auth: req.body.auth,
|
auth: req.body.auth,
|
||||||
endpoint: req.body.endpoint,
|
endpoint: req.body.endpoint,
|
||||||
p256dh: req.body.p256dh,
|
p256dh: req.body.p256dh,
|
||||||
|
userAgent: req.body.userAgent,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +234,79 @@ router.post<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get<{ userId: number }>(
|
||||||
|
'/:userId/pushSubscriptions',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSubs = await userPushSubRepository.find({
|
||||||
|
relations: { user: true },
|
||||||
|
where: { user: { id: req.params.userId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(userPushSubs);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'User subscriptions not found.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get<{ userId: number; endpoint: string }>(
|
||||||
|
'/:userId/pushSubscription/:endpoint',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
user: { id: req.params.userId },
|
||||||
|
endpoint: req.params.endpoint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(userPushSub);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'User subscription not found.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete<{ userId: number; endpoint: string }>(
|
||||||
|
'/:userId/pushSubscription/:endpoint',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
user: { id: req.params.userId },
|
||||||
|
endpoint: req.params.endpoint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userPushSubRepository.remove(userPushSub);
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong deleting the user push subcription', {
|
||||||
|
label: 'API',
|
||||||
|
endpoint: req.params.endpoint,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'User push subcription not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ApiError } from '@server/types/error';
|
|||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
|
import { Not } from 'typeorm';
|
||||||
import { canMakePermissionsChange } from '.';
|
import { canMakePermissionsChange } from '.';
|
||||||
|
|
||||||
const isOwnProfile = (): Middleware => {
|
const isOwnProfile = (): Middleware => {
|
||||||
@@ -125,8 +126,9 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email, id: Not(user.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (oldEmail !== user.email && existingUser) {
|
if (oldEmail !== user.email && existingUser) {
|
||||||
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
||||||
}
|
}
|
||||||
@@ -419,7 +421,9 @@ userSettingsRoutes.post<{ username: string; password: string }>(
|
|||||||
|
|
||||||
const hostname = getHostname();
|
const hostname = getHostname();
|
||||||
const deviceId = Buffer.from(
|
const deviceId = Buffer.from(
|
||||||
`BOT_jellyseerr_${req.user.username ?? ''}`
|
req.user?.id === 1
|
||||||
|
? 'BOT_jellyseerr'
|
||||||
|
: `BOT_jellyseerr_${req.user.username ?? ''}`
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
|
|
||||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
|
|||||||
818
server/subscriber/MediaRequestSubscriber.ts
Normal file
818
server/subscriber/MediaRequestSubscriber.ts
Normal file
@@ -0,0 +1,818 @@
|
|||||||
|
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
|
||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import type {
|
||||||
|
AddSeriesOptions,
|
||||||
|
SonarrSeries,
|
||||||
|
} from '@server/api/servarr/sonarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { isEqual, truncate } from 'lodash';
|
||||||
|
import type {
|
||||||
|
EntityManager,
|
||||||
|
EntitySubscriberInterface,
|
||||||
|
InsertEvent,
|
||||||
|
RemoveEvent,
|
||||||
|
UpdateEvent,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { EventSubscriber } from 'typeorm';
|
||||||
|
|
||||||
|
@EventSubscriber()
|
||||||
|
export class MediaRequestSubscriber
|
||||||
|
implements EntitySubscriberInterface<MediaRequest>
|
||||||
|
{
|
||||||
|
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||||
|
if (
|
||||||
|
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const movie = await tmdb.getMovie({
|
||||||
|
movieId: entity.media.tmdbId,
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||||
|
notifyAdmin: false,
|
||||||
|
notifySystem: true,
|
||||||
|
notifyUser: entity.requestedBy,
|
||||||
|
subject: `${movie.title}${
|
||||||
|
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(movie.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
media: entity.media,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
|
request: entity,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyAvailableSeries(entity: MediaRequest) {
|
||||||
|
// Find all seasons in the related media entity
|
||||||
|
// and see if they are available, then we can check
|
||||||
|
// if the request contains the same seasons
|
||||||
|
const requestedSeasons =
|
||||||
|
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||||
|
const availableSeasons = entity.media.seasons.filter(
|
||||||
|
(season) =>
|
||||||
|
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||||
|
requestedSeasons.includes(season.seasonNumber)
|
||||||
|
);
|
||||||
|
const isMediaAvailable =
|
||||||
|
availableSeasons.length > 0 &&
|
||||||
|
availableSeasons.length === requestedSeasons.length;
|
||||||
|
|
||||||
|
if (isMediaAvailable) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||||
|
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||||
|
subject: `${tv.name}${
|
||||||
|
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||||
|
}`,
|
||||||
|
message: truncate(tv.overview, {
|
||||||
|
length: 500,
|
||||||
|
separator: /\s/,
|
||||||
|
omission: '…',
|
||||||
|
}),
|
||||||
|
notifyAdmin: false,
|
||||||
|
notifySystem: true,
|
||||||
|
notifyUser: entity.requestedBy,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||||
|
media: entity.media,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
name: 'Requested Seasons',
|
||||||
|
value: entity.seasons
|
||||||
|
.map((season) => season.seasonNumber)
|
||||||
|
.join(', '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
request: entity,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending media notification(s)', {
|
||||||
|
label: 'Notifications',
|
||||||
|
errorMessage: e.message,
|
||||||
|
mediaId: entity.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToRadarr(entity: MediaRequest): Promise<void> {
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
entity.type === MediaType.MOVIE
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.radarr.length === 0 && !settings.radarr[0]) {
|
||||||
|
logger.info(
|
||||||
|
'No Radarr server configured, skipping request processing',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radarrSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.isDefault && radarr.is4k === entity.is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.serverId !== null &&
|
||||||
|
entity.serverId >= 0 &&
|
||||||
|
radarrSettings?.id !== entity.serverId
|
||||||
|
) {
|
||||||
|
radarrSettings = settings.radarr.find(
|
||||||
|
(radarr) => radarr.id === entity.serverId
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Request has an override server: ${radarrSettings?.name}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!radarrSettings) {
|
||||||
|
logger.warn(
|
||||||
|
`There is no default ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Radarr server configured. Did you set any of your ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Radarr servers as default?`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.rootFolder &&
|
||||||
|
entity.rootFolder !== '' &&
|
||||||
|
entity.rootFolder !== radarrSettings.activeDirectory
|
||||||
|
) {
|
||||||
|
rootFolder = entity.rootFolder;
|
||||||
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.profileId &&
|
||||||
|
entity.profileId !== radarrSettings.activeProfileId
|
||||||
|
) {
|
||||||
|
qualityProfile = entity.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) {
|
||||||
|
tags = entity.tags;
|
||||||
|
logger.info(`Request has override tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const radarr = new RadarrAPI({
|
||||||
|
apiKey: radarrSettings.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
|
||||||
|
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radarrSettings.tagRequests) {
|
||||||
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await radarr.createTag({
|
||||||
|
label:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
radarrServer: radarrSettings.hostname + ':' + radarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
entity.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radarrMovieOptions: RadarrMovieOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
qualityProfileId: qualityProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
minimumAvailability: radarrSettings.minimumAvailability,
|
||||||
|
title: movie.title,
|
||||||
|
tmdbId: movie.id,
|
||||||
|
year: Number(movie.release_date.slice(0, 4)),
|
||||||
|
monitored: true,
|
||||||
|
tags,
|
||||||
|
searchNow: !radarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run entity asynchronously so we don't wait for it on the UI side
|
||||||
|
radarr
|
||||||
|
.addMovie(radarrMovieOptions)
|
||||||
|
.then(async (radarrMovie) => {
|
||||||
|
// We grab media again here to make sure we have the latest version of it
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
radarrMovie.id;
|
||||||
|
media[
|
||||||
|
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||||
|
] = radarrMovie.titleSlug;
|
||||||
|
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
radarrSettings?.id;
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(entity);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
radarrMovieOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaRequest.sendNotification(
|
||||||
|
entity,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_FAILED
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
radarr.clearCache({
|
||||||
|
tmdbId: movie.id,
|
||||||
|
externalId: entity.is4k
|
||||||
|
? media.externalServiceId4k
|
||||||
|
: media.externalServiceId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('Sent request to Radarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Radarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToSonarr(entity: MediaRequest): Promise<void> {
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
entity.type === MediaType.TV
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.sonarr.length === 0 && !settings.sonarr[0]) {
|
||||||
|
logger.warn(
|
||||||
|
'No Sonarr server configured, skipping request processing',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sonarrSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.serverId !== null &&
|
||||||
|
entity.serverId >= 0 &&
|
||||||
|
sonarrSettings?.id !== entity.serverId
|
||||||
|
) {
|
||||||
|
sonarrSettings = settings.sonarr.find(
|
||||||
|
(sonarr) => sonarr.id === entity.serverId
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Request has an override server: ${sonarrSettings?.name}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sonarrSettings) {
|
||||||
|
logger.warn(
|
||||||
|
`There is no default ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Sonarr server configured. Did you set any of your ${
|
||||||
|
entity.is4k ? '4K ' : ''
|
||||||
|
}Sonarr servers as default?`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
logger.warn('Media already exists, marking request as APPROVED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
entity.status = MediaRequestStatus.APPROVED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const sonarr = new SonarrAPI({
|
||||||
|
apiKey: sonarrSettings.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
|
||||||
|
});
|
||||||
|
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
|
||||||
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
|
||||||
|
if (!tvdbId) {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
await mediaRepository.remove(media);
|
||||||
|
await requestRepository.remove(entity);
|
||||||
|
throw new Error('TVDB ID not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let seriesType: SonarrSeries['seriesType'] = 'standard';
|
||||||
|
|
||||||
|
// Change series type to anime if the anime keyword is present on tmdb
|
||||||
|
if (
|
||||||
|
series.keywords.results.some(
|
||||||
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
seriesType = sonarrSettings.animeSeriesType ?? 'anime';
|
||||||
|
}
|
||||||
|
|
||||||
|
let rootFolder =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
|
||||||
|
? sonarrSettings.activeAnimeDirectory
|
||||||
|
: sonarrSettings.activeDirectory;
|
||||||
|
let qualityProfile =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
|
||||||
|
? sonarrSettings.activeAnimeProfileId
|
||||||
|
: sonarrSettings.activeProfileId;
|
||||||
|
let languageProfile =
|
||||||
|
seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId
|
||||||
|
? sonarrSettings.activeAnimeLanguageProfileId
|
||||||
|
: sonarrSettings.activeLanguageProfileId;
|
||||||
|
let tags =
|
||||||
|
seriesType === 'anime'
|
||||||
|
? sonarrSettings.animeTags
|
||||||
|
? [...sonarrSettings.animeTags]
|
||||||
|
: []
|
||||||
|
: sonarrSettings.tags
|
||||||
|
? [...sonarrSettings.tags]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.rootFolder &&
|
||||||
|
entity.rootFolder !== '' &&
|
||||||
|
entity.rootFolder !== rootFolder
|
||||||
|
) {
|
||||||
|
rootFolder = entity.rootFolder;
|
||||||
|
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.profileId && entity.profileId !== qualityProfile) {
|
||||||
|
qualityProfile = entity.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity.languageProfileId &&
|
||||||
|
entity.languageProfileId !== languageProfile
|
||||||
|
) {
|
||||||
|
languageProfile = entity.languageProfileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override language profile ID: ${languageProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.tags && !isEqual(entity.tags, tags)) {
|
||||||
|
tags = entity.tags;
|
||||||
|
logger.info(`Request has override tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sonarrSettings.tagRequests) {
|
||||||
|
let userTag = (await sonarr.getTags()).find((v) =>
|
||||||
|
v.label.startsWith(entity.requestedBy.id + ' - ')
|
||||||
|
);
|
||||||
|
if (!userTag) {
|
||||||
|
logger.info(`Requester has no active tag. Creating new`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
newTag:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
userTag = await sonarr.createTag({
|
||||||
|
label:
|
||||||
|
entity.requestedBy.id + ' - ' + entity.requestedBy.displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userTag.id) {
|
||||||
|
if (!tags?.find((v) => v === userTag?.id)) {
|
||||||
|
tags?.push(userTag.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Requester has no tag and failed to add one`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
userId: entity.requestedBy.id,
|
||||||
|
sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
|
profileId: qualityProfile,
|
||||||
|
languageProfileId: languageProfile,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
title: series.name,
|
||||||
|
tvdbid: tvdbId,
|
||||||
|
seasons: entity.seasons.map((season) => season.seasonNumber),
|
||||||
|
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||||
|
seriesType,
|
||||||
|
tags,
|
||||||
|
monitored: true,
|
||||||
|
searchNow: !sonarrSettings.preventSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run entity asynchronously so we don't wait for it on the UI side
|
||||||
|
sonarr
|
||||||
|
.addSeries(sonarrSeriesOptions)
|
||||||
|
.then(async (sonarrSeries) => {
|
||||||
|
// We grab media again here to make sure we have the latest version of it
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new Error('Media data not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
||||||
|
sonarrSeries.id;
|
||||||
|
media[
|
||||||
|
entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
|
||||||
|
] = sonarrSeries.titleSlug;
|
||||||
|
media[entity.is4k ? 'serviceId4k' : 'serviceId'] =
|
||||||
|
sonarrSettings?.id;
|
||||||
|
await mediaRepository.save(media);
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
requestRepository.save(entity);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
sonarrSeriesOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaRequest.sendNotification(
|
||||||
|
entity,
|
||||||
|
media,
|
||||||
|
Notification.MEDIA_FAILED
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sonarr.clearCache({
|
||||||
|
tvdbId,
|
||||||
|
externalId: entity.is4k
|
||||||
|
? media.externalServiceId4k
|
||||||
|
: media.externalServiceId,
|
||||||
|
title: series.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('Sent request to Sonarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong sending request to Sonarr', {
|
||||||
|
label: 'Media Request',
|
||||||
|
errorMessage: e.message,
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
throw new Error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateParentStatus(entity: MediaRequest): Promise<void> {
|
||||||
|
const mediaRepository = getRepository(Media);
|
||||||
|
const media = await mediaRepository.findOne({
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
if (!media) {
|
||||||
|
logger.error('Media data not found', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
mediaId: entity.media.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
if (
|
||||||
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
|
// Do not update the status if the item is already partially available or available
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.MOVIE &&
|
||||||
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the media type is TV, and we are declining a request,
|
||||||
|
* we must check if its the only pending request and that
|
||||||
|
* there the current media status is just pending (meaning no
|
||||||
|
* other requests have yet to be approved)
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.TV &&
|
||||||
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
|
media.requests.filter(
|
||||||
|
(request) => request.status === MediaRequestStatus.PENDING
|
||||||
|
).length === 0 &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
||||||
|
) {
|
||||||
|
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
||||||
|
mediaRepository.save(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve child seasons if parent is approved
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.TV &&
|
||||||
|
entity.status === MediaRequestStatus.APPROVED
|
||||||
|
) {
|
||||||
|
entity.seasons.forEach((season) => {
|
||||||
|
season.status = MediaRequestStatus.APPROVED;
|
||||||
|
seasonRequestRepository.save(season);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRemoveParentUpdate(
|
||||||
|
manager: EntityManager,
|
||||||
|
entity: MediaRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const fullMedia = await manager.findOneOrFail(Media, {
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
relations: { requests: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullMedia) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||||
|
fullMedia.status !== MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
fullMedia.status = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!fullMedia.requests.some((request) => request.is4k) &&
|
||||||
|
fullMedia.status4k !== MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
fullMedia.status4k = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.save(fullMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
this.sendToSonarr(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||||
|
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||||
|
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
if (event.entity.media.mediaType === MediaType.TV) {
|
||||||
|
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public afterInsert(event: InsertEvent<MediaRequest>): void {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
this.sendToSonarr(event.entity as MediaRequest);
|
||||||
|
|
||||||
|
this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleRemoveParentUpdate(
|
||||||
|
event.manager as EntityManager,
|
||||||
|
event.entity as MediaRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public listenTo(): typeof MediaRequest {
|
||||||
|
return MediaRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import TheMovieDb from '@server/api/themoviedb';
|
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -8,172 +7,12 @@ import { getRepository } from '@server/datasource';
|
|||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import Season from '@server/entity/Season';
|
import Season from '@server/entity/Season';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
import logger from '@server/logger';
|
|
||||||
import { truncate } from 'lodash';
|
|
||||||
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm';
|
||||||
import { EventSubscriber, In, Not } from 'typeorm';
|
import { EventSubscriber } from 'typeorm';
|
||||||
|
|
||||||
@EventSubscriber()
|
@EventSubscriber()
|
||||||
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
||||||
private async notifyAvailableMovie(
|
|
||||||
entity: Media,
|
|
||||||
dbEntity: Media,
|
|
||||||
is4k: boolean
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
entity[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
|
||||||
dbEntity[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
if (entity.mediaType === MediaType.MOVIE) {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
const relatedRequests = await requestRepository.find({
|
|
||||||
where: {
|
|
||||||
media: {
|
|
||||||
id: entity.id,
|
|
||||||
},
|
|
||||||
is4k,
|
|
||||||
status: Not(MediaRequestStatus.DECLINED),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relatedRequests.length > 0) {
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
|
||||||
|
|
||||||
relatedRequests.forEach((request) => {
|
|
||||||
notificationManager.sendNotification(
|
|
||||||
Notification.MEDIA_AVAILABLE,
|
|
||||||
{
|
|
||||||
event: `${is4k ? '4K ' : ''}Movie Request Now Available`,
|
|
||||||
notifyAdmin: false,
|
|
||||||
notifySystem: true,
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
subject: `${movie.title}${
|
|
||||||
movie.release_date
|
|
||||||
? ` (${movie.release_date.slice(0, 4)})`
|
|
||||||
: ''
|
|
||||||
}`,
|
|
||||||
message: truncate(movie.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
media: entity,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
request,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
|
||||||
label: 'Notifications',
|
|
||||||
errorMessage: e.message,
|
|
||||||
mediaId: entity.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async notifyAvailableSeries(
|
|
||||||
entity: Media,
|
|
||||||
dbEntity: Media,
|
|
||||||
is4k: boolean
|
|
||||||
) {
|
|
||||||
const seasonRepository = getRepository(Season);
|
|
||||||
const newAvailableSeasons = entity.seasons
|
|
||||||
.filter(
|
|
||||||
(season) =>
|
|
||||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
)
|
|
||||||
.map((season) => season.seasonNumber);
|
|
||||||
const oldSeasonIds = dbEntity.seasons.map((season) => season.id);
|
|
||||||
const oldSeasons = await seasonRepository.findBy({ id: In(oldSeasonIds) });
|
|
||||||
const oldAvailableSeasons = oldSeasons
|
|
||||||
.filter(
|
|
||||||
(season) =>
|
|
||||||
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
|
||||||
)
|
|
||||||
.map((season) => season.seasonNumber);
|
|
||||||
|
|
||||||
const changedSeasons = newAvailableSeasons.filter(
|
|
||||||
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (changedSeasons.length > 0) {
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
const processedSeasons: number[] = [];
|
|
||||||
|
|
||||||
for (const changedSeasonNumber of changedSeasons) {
|
|
||||||
const requests = await requestRepository.find({
|
|
||||||
where: {
|
|
||||||
media: {
|
|
||||||
id: entity.id,
|
|
||||||
},
|
|
||||||
is4k,
|
|
||||||
status: Not(MediaRequestStatus.DECLINED),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const request = requests.find(
|
|
||||||
(request) =>
|
|
||||||
// Check if the season is complete AND it contains the current season that was just marked available
|
|
||||||
request.seasons.every((season) =>
|
|
||||||
newAvailableSeasons.includes(season.seasonNumber)
|
|
||||||
) &&
|
|
||||||
request.seasons.some(
|
|
||||||
(season) => season.seasonNumber === changedSeasonNumber
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
|
||||||
processedSeasons.push(
|
|
||||||
...request.seasons.map((season) => season.seasonNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
|
||||||
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
|
|
||||||
subject: `${tv.name}${
|
|
||||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
|
||||||
}`,
|
|
||||||
message: truncate(tv.overview, {
|
|
||||||
length: 500,
|
|
||||||
separator: /\s/,
|
|
||||||
omission: '…',
|
|
||||||
}),
|
|
||||||
notifyAdmin: false,
|
|
||||||
notifySystem: true,
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
||||||
media: entity,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Requested Seasons',
|
|
||||||
value: request.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
request,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending media notification(s)', {
|
|
||||||
label: 'Notifications',
|
|
||||||
errorMessage: e.message,
|
|
||||||
mediaId: entity.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
private async updateChildRequestStatus(event: Media, is4k: boolean) {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
@@ -192,57 +31,101 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public beforeUpdate(event: UpdateEvent<Media>): void {
|
private async updateRelatedMediaRequest(
|
||||||
|
event: Media,
|
||||||
|
databaseEvent: Media,
|
||||||
|
is4k: boolean
|
||||||
|
) {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
|
||||||
|
const relatedRequests = await requestRepository.find({
|
||||||
|
relations: {
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
media: { id: event.id },
|
||||||
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
is4k,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check the media entity status and if available
|
||||||
|
// or deleted, set the related request to completed
|
||||||
|
if (relatedRequests.length > 0) {
|
||||||
|
const completedRequests: MediaRequest[] = [];
|
||||||
|
|
||||||
|
for (const request of relatedRequests) {
|
||||||
|
let shouldComplete = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event[request.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE ||
|
||||||
|
event[request.is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.DELETED) &&
|
||||||
|
event.mediaType === MediaType.MOVIE
|
||||||
|
) {
|
||||||
|
shouldComplete = true;
|
||||||
|
} else if (event.mediaType === 'tv') {
|
||||||
|
const allSeasonResults = await Promise.all(
|
||||||
|
request.seasons.map(async (requestSeason) => {
|
||||||
|
const matchingSeason = event.seasons.find(
|
||||||
|
(mediaSeason) =>
|
||||||
|
mediaSeason.seasonNumber === requestSeason.seasonNumber
|
||||||
|
);
|
||||||
|
const matchingOldSeason = databaseEvent.seasons.find(
|
||||||
|
(oldSeason) =>
|
||||||
|
oldSeason.seasonNumber === requestSeason.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingSeason) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeasonStatus =
|
||||||
|
matchingSeason[request.is4k ? 'status4k' : 'status'];
|
||||||
|
const previousSeasonStatus =
|
||||||
|
matchingOldSeason?.[request.is4k ? 'status4k' : 'status'];
|
||||||
|
|
||||||
|
const hasStatusChanged =
|
||||||
|
currentSeasonStatus !== previousSeasonStatus;
|
||||||
|
|
||||||
|
const shouldUpdate =
|
||||||
|
(hasStatusChanged ||
|
||||||
|
requestSeason.status === MediaRequestStatus.COMPLETED) &&
|
||||||
|
(currentSeasonStatus === MediaStatus.AVAILABLE ||
|
||||||
|
currentSeasonStatus === MediaStatus.DELETED);
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
requestSeason.status = MediaRequestStatus.COMPLETED;
|
||||||
|
await seasonRequestRepository.save(requestSeason);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSeasonsReady = allSeasonResults.every((result) => result);
|
||||||
|
shouldComplete = allSeasonsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldComplete) {
|
||||||
|
request.status = MediaRequestStatus.COMPLETED;
|
||||||
|
completedRequests.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestRepository.save(completedRequests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async beforeUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||||
if (!event.entity) {
|
if (!event.entity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.MOVIE &&
|
|
||||||
event.entity.status === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
this.notifyAvailableMovie(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.MOVIE &&
|
|
||||||
event.entity.status4k === MediaStatus.AVAILABLE
|
|
||||||
) {
|
|
||||||
this.notifyAvailableMovie(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.TV &&
|
|
||||||
(event.entity.status === MediaStatus.AVAILABLE ||
|
|
||||||
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
this.notifyAvailableSeries(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
event.entity.mediaType === MediaType.TV &&
|
|
||||||
(event.entity.status4k === MediaStatus.AVAILABLE ||
|
|
||||||
event.entity.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
|
||||||
) {
|
|
||||||
this.notifyAvailableSeries(
|
|
||||||
event.entity as Media,
|
|
||||||
event.databaseEntity,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.entity.status === MediaStatus.AVAILABLE &&
|
event.entity.status === MediaStatus.AVAILABLE &&
|
||||||
event.databaseEntity.status === MediaStatus.PENDING
|
event.databaseEntity.status === MediaStatus.PENDING
|
||||||
@@ -256,6 +139,65 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
|
|||||||
) {
|
) {
|
||||||
this.updateChildRequestStatus(event.entity as Media, true);
|
this.updateChildRequestStatus(event.entity as Media, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manually load related seasons into databaseEntity
|
||||||
|
// for seasonStatusCheck in afterUpdate
|
||||||
|
const seasons = await event.manager
|
||||||
|
.getRepository(Season)
|
||||||
|
.createQueryBuilder('season')
|
||||||
|
.leftJoin('season.media', 'media')
|
||||||
|
.where('media.id = :id', { id: event.databaseEntity.id })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
event.databaseEntity.seasons = seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async afterUpdate(event: UpdateEvent<Media>): Promise<void> {
|
||||||
|
if (!event.entity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
MediaStatus.PARTIALLY_AVAILABLE,
|
||||||
|
MediaStatus.AVAILABLE,
|
||||||
|
MediaStatus.DELETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
const seasonStatusCheck = (is4k: boolean) => {
|
||||||
|
return event.entity?.seasons?.some((season: Season, index: number) => {
|
||||||
|
const previousSeason = event.databaseEntity.seasons[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
season[is4k ? 'status4k' : 'status'] !==
|
||||||
|
previousSeason?.[is4k ? 'status4k' : 'status']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event.entity.status !== event.databaseEntity?.status ||
|
||||||
|
(event.entity.mediaType === MediaType.TV &&
|
||||||
|
seasonStatusCheck(false))) &&
|
||||||
|
validStatuses.includes(event.entity.status)
|
||||||
|
) {
|
||||||
|
this.updateRelatedMediaRequest(
|
||||||
|
event.entity as Media,
|
||||||
|
event.databaseEntity as Media,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(event.entity.status4k !== event.databaseEntity?.status4k ||
|
||||||
|
(event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) &&
|
||||||
|
validStatuses.includes(event.entity.status4k)
|
||||||
|
) {
|
||||||
|
this.updateRelatedMediaRequest(
|
||||||
|
event.entity as Media,
|
||||||
|
event.databaseEntity as Media,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public listenTo(): typeof Media {
|
public listenTo(): typeof Media {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { ProxySettings } from '@server/lib/settings';
|
import type { ProxySettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||||
|
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
@@ -53,17 +56,29 @@ export default async function createCustomProxyAgent(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const proxyUrl =
|
||||||
|
(proxySettings.useSsl ? 'https://' : 'http://') +
|
||||||
|
proxySettings.hostname +
|
||||||
|
':' +
|
||||||
|
proxySettings.port;
|
||||||
|
|
||||||
const proxyAgent = new ProxyAgent({
|
const proxyAgent = new ProxyAgent({
|
||||||
uri:
|
uri: proxyUrl,
|
||||||
(proxySettings.useSsl ? 'https://' : 'http://') +
|
|
||||||
proxySettings.hostname +
|
|
||||||
':' +
|
|
||||||
proxySettings.port,
|
|
||||||
token,
|
token,
|
||||||
keepAliveTimeout: 5000,
|
keepAliveTimeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||||
|
|
||||||
|
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl);
|
||||||
|
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl);
|
||||||
|
axios.interceptors.request.use((config) => {
|
||||||
|
if (config.url && skipUrl(config.url)) {
|
||||||
|
config.httpAgent = false;
|
||||||
|
config.httpsAgent = false;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to connect to the proxy: ' + e.message, {
|
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||||
label: 'Proxy',
|
label: 'Proxy',
|
||||||
@@ -73,15 +88,8 @@ export default async function createCustomProxyAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://www.google.com', { method: 'HEAD' });
|
await axios.head('https://www.google.com');
|
||||||
if (res.ok) {
|
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
|
||||||
logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' });
|
|
||||||
} else {
|
|
||||||
logger.error('Proxy responded, but with a non-OK status: ' + res.status, {
|
|
||||||
label: 'Proxy',
|
|
||||||
});
|
|
||||||
setGlobalDispatcher(defaultAgent);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
|
'Failed to connect to the proxy: ' + e.message + ': ' + e.cause,
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
export type RateLimitOptions = {
|
|
||||||
maxRPS: number;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
|
|
||||||
queue: {
|
|
||||||
args: Parameters<T>;
|
|
||||||
resolve: (value: U) => void;
|
|
||||||
reject: (reason?: unknown) => void;
|
|
||||||
}[];
|
|
||||||
lastTimestamps: number[];
|
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rateLimitById: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a rate limit to a function so it doesn't exceed a maximum number of requests per second. Function calls exceeding the rate will be delayed.
|
|
||||||
* @param fn The function to rate limit
|
|
||||||
* @param options.maxRPS Maximum number of Requests Per Second
|
|
||||||
* @param options.id An ID to share between rate limits, so it uses the same request queue.
|
|
||||||
* @returns The function with a rate limit
|
|
||||||
*/
|
|
||||||
export default function rateLimit<
|
|
||||||
T extends (...args: Parameters<T>) => Promise<U>,
|
|
||||||
U
|
|
||||||
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
|
|
||||||
const state: RateLimiteState<T, U> = (rateLimitById[
|
|
||||||
options.id || ''
|
|
||||||
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
|
|
||||||
if (options.id) {
|
|
||||||
rateLimitById[options.id] = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const processQueue = () => {
|
|
||||||
// remove old timestamps
|
|
||||||
state.lastTimestamps = state.lastTimestamps.filter(
|
|
||||||
(timestamp) => Date.now() - timestamp < 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
if (state.lastTimestamps.length < options.maxRPS) {
|
|
||||||
// process requests if RPS not exceeded
|
|
||||||
const item = state.queue.shift();
|
|
||||||
if (!item) return;
|
|
||||||
state.lastTimestamps.push(Date.now());
|
|
||||||
const { args, resolve, reject } = item;
|
|
||||||
fn(...args)
|
|
||||||
.then(resolve)
|
|
||||||
.catch(reject);
|
|
||||||
processQueue();
|
|
||||||
} else {
|
|
||||||
// rerun once the oldest item in queue is older than 1s
|
|
||||||
if (state.timeout) clearTimeout(state.timeout);
|
|
||||||
state.timeout = setTimeout(
|
|
||||||
processQueue,
|
|
||||||
1000 - (Date.now() - state.lastTimestamps[0])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (...args: Parameters<T>): Promise<U> => {
|
|
||||||
return new Promise<U>((resolve, reject) => {
|
|
||||||
state.queue.push({ args, resolve, reject });
|
|
||||||
processQueue();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,7 @@ class RestartFlag {
|
|||||||
return (
|
return (
|
||||||
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
|
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled
|
||||||
this.networkSettings.forceIpv4First !== networkSettings.forceIpv4First
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/assets/extlogos/ntfy.svg
Normal file
1
src/assets/extlogos/ntfy.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><g fill="currentColor"><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" transform="scale(.26458)"/><path d="M88.2 95.309H64.92c-1.601 0-2.91 1.236-2.91 2.746l.022 18.602-.435 2.506 6.231-1.881H88.2c1.6 0 2.91-1.236 2.91-2.747v-16.48c0-1.51-1.31-2.746-2.91-2.746z" transform="translate(-51.147 -81.516)"/><path d="M50.4 46.883c-9.168 0-17.023 7.214-17.023 16.387v.007l.09 71.37-2.303 16.992 31.313-8.319h77.841c9.17 0 17.024-7.224 17.024-16.396V63.27c0-9.17-7.85-16.383-17.016-16.387h-.008zm0 11.566h89.926c3.222.004 5.45 2.347 5.45 4.82v63.655c0 2.475-2.232 4.82-5.457 4.82h-79.54l-15.908 4.807.162-.938-.088-72.343c0-2.476 2.23-4.82 5.455-4.82z" transform="scale(.26458)"/><path d="M62.57 116.77v-1.312l3.28-1.459q.159-.068.306-.102.158-.045.283-.068l.271-.022v-.09q-.136-.012-.271-.046-.125-.023-.283-.057-.147-.045-.306-.113l-3.28-1.459v-1.323l5.068 2.319v1.413z" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/><path d="M62.309 110.31v1.903l3.437 1.53.022.007-.022.008-3.437 1.53v1.892l.37-.17 5.221-2.39v-1.75zm.525.817 4.541 2.08v1.076l-4.541 2.078v-.732l3.12-1.389.003-.002a1.56 1.56 0 0 1 .258-.086h.006l.008-.002c.094-.027.176-.047.246-.06l.498-.041v-.574l-.24-.02a1.411 1.411 0 0 1-.231-.04l-.008-.001-.008-.002a9.077 9.077 0 0 1-.263-.053 2.781 2.781 0 0 1-.266-.097l-.004-.002-3.119-1.39z" transform="matrix(1.45366 0 0 1.72815 -75.122 -171.953)"/><path d="M69.171 117.754h5.43v1.278h-5.43Z" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/><path d="M68.908 117.492v1.802h5.955v-1.802zm.526.524h4.904v.754h-4.904z" transform="matrix(1.44935 0 0 1.66414 -74.104 -166.906)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
@@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
FunnelIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
@@ -23,6 +25,7 @@ import type {
|
|||||||
} from '@server/interfaces/api/blacklistInterfaces';
|
} from '@server/interfaces/api/blacklistInterfaces';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
@@ -41,8 +44,17 @@ const messages = defineMessages('components.Blacklist', {
|
|||||||
blacklistdate: 'date',
|
blacklistdate: 'date',
|
||||||
blacklistedby: '{date} by {user}',
|
blacklistedby: '{date} by {user}',
|
||||||
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
||||||
|
filterManual: 'Manual',
|
||||||
|
filterBlacklistedTags: 'Blacklisted Tags',
|
||||||
|
showAllBlacklisted: 'Show All Blacklisted Media',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
enum Filter {
|
||||||
|
ALL = 'all',
|
||||||
|
MANUAL = 'manual',
|
||||||
|
BLACKLISTEDTAGS = 'blacklistedTags',
|
||||||
|
}
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
@@ -51,6 +63,7 @@ const Blacklist = () => {
|
|||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||||
useDebouncedState('');
|
useDebouncedState('');
|
||||||
|
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -63,9 +76,11 @@ const Blacklist = () => {
|
|||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<BlacklistResultsResponse>(
|
} = useSWR<BlacklistResultsResponse>(
|
||||||
`/api/v1/blacklist/?take=${currentPageSize}
|
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
|
||||||
&skip=${pageIndex * currentPageSize}
|
pageIndex * currentPageSize
|
||||||
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
|
}&filter=${currentFilter}${
|
||||||
|
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
||||||
|
}`,
|
||||||
{
|
{
|
||||||
refreshInterval: 0,
|
refreshInterval: 0,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -93,19 +108,52 @@ const Blacklist = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
||||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
<MagnifyingGlassIcon className="h-6 w-6" />
|
<FunnelIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<select
|
||||||
type="text"
|
id="filter"
|
||||||
className="rounded-r-only"
|
name="filter"
|
||||||
value={searchFilter}
|
onChange={(e) => {
|
||||||
onChange={(e) => searchItem(e)}
|
setCurrentFilter(e.target.value as Filter);
|
||||||
/>
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: router.query.userId
|
||||||
|
? { userId: router.query.userId }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={currentFilter}
|
||||||
|
className="rounded-r-only"
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{intl.formatMessage(globalMessages.all)}
|
||||||
|
</option>
|
||||||
|
<option value="manual">
|
||||||
|
{intl.formatMessage(messages.filterManual)}
|
||||||
|
</option>
|
||||||
|
<option value="blacklistedTags">
|
||||||
|
{intl.formatMessage(messages.filterBlacklistedTags)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
|
<MagnifyingGlassIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => searchItem(e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +164,16 @@ const Blacklist = () => {
|
|||||||
<span className="text-2xl text-gray-400">
|
<span className="text-2xl text-gray-400">
|
||||||
{intl.formatMessage(globalMessages.noresults)}
|
{intl.formatMessage(globalMessages.noresults)}
|
||||||
</span>
|
</span>
|
||||||
|
{currentFilter !== Filter.ALL && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.showAllBlacklisted)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.results.map((item: BlacklistItem) => {
|
data.results.map((item: BlacklistItem) => {
|
||||||
@@ -238,11 +296,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
|
try {
|
||||||
method: 'DELETE',
|
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
||||||
@@ -252,7 +308,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
</span>,
|
</span>,
|
||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} else {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
@@ -353,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
numeric="auto"
|
numeric="auto"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
user: (
|
user: item.user ? (
|
||||||
<Link href={`/users/${item.user.id}`}>
|
<Link href={`/users/${item.user.id}`}>
|
||||||
<span className="group flex items-center truncate">
|
<span className="group flex items-center truncate">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -370,6 +426,14 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : item.blacklistedTags ? (
|
||||||
|
<span className="ml-1">
|
||||||
|
<BlacklistedTagsBadge data={item} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1 truncate text-sm font-semibold">
|
||||||
|
???
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
@@ -7,6 +8,7 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
|
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||||
import type { Blacklist } from '@server/entity/Blacklist';
|
import type { Blacklist } from '@server/entity/Blacklist';
|
||||||
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -38,11 +40,9 @@ const BlacklistBlock = ({
|
|||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
|
try {
|
||||||
method: 'DELETE',
|
await axios.delete('/api/v1/blacklist/' + tmdbId);
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 204) {
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
||||||
@@ -52,7 +52,7 @@ const BlacklistBlock = ({
|
|||||||
</span>,
|
</span>,
|
||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} else {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
@@ -78,22 +78,33 @@ const BlacklistBlock = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||||
<div className="white mb-1 flex flex-nowrap">
|
<div className="white mb-1 flex flex-nowrap">
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
{data.user ? (
|
||||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
<>
|
||||||
</Tooltip>
|
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
||||||
<span className="w-40 truncate md:w-auto">
|
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||||
<Link
|
</Tooltip>
|
||||||
href={
|
<span className="w-40 truncate md:w-auto">
|
||||||
data.user.id === user?.id
|
<Link
|
||||||
? '/profile'
|
href={
|
||||||
: `/users/${data.user.id}`
|
data.user.id === user?.id
|
||||||
}
|
? '/profile'
|
||||||
>
|
: `/users/${data.user.id}`
|
||||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
}
|
||||||
{data.user.displayName}
|
>
|
||||||
|
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||||
|
{data.user.displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</>
|
||||||
</span>
|
) : data.blacklistedTags ? (
|
||||||
|
<>
|
||||||
|
<span className="w-40 truncate md:w-auto">
|
||||||
|
{intl.formatMessage(messages.blacklistedby)}:
|
||||||
|
</span>
|
||||||
|
<BlacklistedTagsBadge data={data} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -44,12 +45,8 @@ const BlacklistModal = ({
|
|||||||
if (!show) return;
|
if (!show) return;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await fetch(`/api/v1/${type}/${tmdbId}`);
|
const response = await axios.get(`/api/v1/${type}/${tmdbId}`);
|
||||||
if (!response.ok) {
|
setData(response.data);
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
setData(result);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/components/BlacklistedTagsBadge/index.tsx
Normal file
62
src/components/BlacklistedTagsBadge/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { TagIcon } from '@heroicons/react/20/solid';
|
||||||
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings', {
|
||||||
|
blacklistedTagsText: 'Blacklisted Tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BlacklistedTagsBadgeProps {
|
||||||
|
data: BlacklistItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||||
|
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
|
||||||
|
useState<string>('Loading...');
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data.blacklistedTags) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||||
|
Promise.all(
|
||||||
|
keywordIds.map(async (keywordId) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return data.name;
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then((keywords) => {
|
||||||
|
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||||
|
});
|
||||||
|
}, [data.blacklistedTags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={tagNamesBlacklistedFor}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeType="dark"
|
||||||
|
className="items-center border border-red-500 !text-red-400"
|
||||||
|
>
|
||||||
|
<TagIcon className="mr-1 h-4" />
|
||||||
|
{intl.formatMessage(messages.blacklistedTagsText)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlacklistedTagsBadge;
|
||||||
402
src/components/BlacklistedTagsSelector/index.tsx
Normal file
402
src/components/BlacklistedTagsSelector/index.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import Modal from '@app/components/Common/Modal';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import CopyButton from '@app/components/Settings/CopyButton';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings', {
|
||||||
|
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
|
||||||
|
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
|
||||||
|
copyBlacklistedTagsEmpty: 'Nothing to copy',
|
||||||
|
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
|
||||||
|
clearBlacklistedTagsConfirm:
|
||||||
|
'Are you sure you want to clear the blacklisted tags?',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
searchKeywords: 'Search keywords…',
|
||||||
|
starttyping: 'Starting typing to search.',
|
||||||
|
nooptions: 'No results.',
|
||||||
|
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
|
||||||
|
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
|
||||||
|
valueRequired: 'You must provide a value.',
|
||||||
|
noSpecialCharacters:
|
||||||
|
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
|
||||||
|
invalidKeyword: '{keywordId} is not a TMDB keyword.',
|
||||||
|
});
|
||||||
|
|
||||||
|
type SingleVal = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagsSelectorProps = {
|
||||||
|
defaultValue?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlacklistedTagsSelector = ({
|
||||||
|
defaultValue,
|
||||||
|
}: BlacklistedTagsSelectorProps) => {
|
||||||
|
const { setFieldValue } = useFormikContext();
|
||||||
|
const [value, setValue] = useState<string | undefined>(defaultValue);
|
||||||
|
const intl = useIntl();
|
||||||
|
const [selectorValue, setSelectorValue] =
|
||||||
|
useState<MultiValue<SingleVal> | null>(null);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(value: MultiValue<SingleVal> | null) => {
|
||||||
|
const strVal = value?.map((v) => v.value).join(',');
|
||||||
|
setSelectorValue(value);
|
||||||
|
setValue(strVal);
|
||||||
|
setFieldValue('blacklistedTags', strVal);
|
||||||
|
},
|
||||||
|
[setSelectorValue, setValue, setFieldValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyDisabled = value === null || value?.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledKeywordSelector
|
||||||
|
value={selectorValue}
|
||||||
|
onChange={update}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
components={{
|
||||||
|
DropdownIndicator: undefined,
|
||||||
|
IndicatorSeparator: undefined,
|
||||||
|
ClearIndicator: VerifyClearIndicator,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyButton
|
||||||
|
textToCopy={value ?? ''}
|
||||||
|
disabled={copyDisabled}
|
||||||
|
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
|
||||||
|
tooltipContent={intl.formatMessage(
|
||||||
|
copyDisabled
|
||||||
|
? messages.copyBlacklistedTagsEmpty
|
||||||
|
: messages.copyBlacklistedTagsTip
|
||||||
|
)}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
/>
|
||||||
|
<BlacklistedTagsImportButton setSelector={update} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseSelectorMultiProps = {
|
||||||
|
defaultValue?: string;
|
||||||
|
value: MultiValue<SingleVal> | null;
|
||||||
|
onChange: (value: MultiValue<SingleVal> | null) => void;
|
||||||
|
components?: Partial<typeof components>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ControlledKeywordSelector = ({
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
components,
|
||||||
|
value,
|
||||||
|
}: BaseSelectorMultiProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaultKeywords = async (): Promise<void> => {
|
||||||
|
if (!defaultValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = await Promise.all(
|
||||||
|
defaultValue.split(',').map(async (keywordId) => {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(
|
||||||
|
keywords.map((keyword) => ({
|
||||||
|
label: keyword.name,
|
||||||
|
value: keyword.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultKeywords();
|
||||||
|
}, [defaultValue, onChange]);
|
||||||
|
|
||||||
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
|
const { data } = await axios.get<TmdbKeywordSearchResponse>(
|
||||||
|
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`keyword-select-blacklistedTags`}
|
||||||
|
inputId="data"
|
||||||
|
isMulti
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
noOptionsMessage={({ inputValue }) =>
|
||||||
|
inputValue === ''
|
||||||
|
? intl.formatMessage(messages.starttyping)
|
||||||
|
: intl.formatMessage(messages.nooptions)
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
loadOptions={loadKeywordOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||||
|
onChange={onChange}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagsImportButtonProps = {
|
||||||
|
setSelector: (value: MultiValue<SingleVal>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlacklistedTagsImportButton = ({
|
||||||
|
setSelector,
|
||||||
|
}: BlacklistedTagsImportButtonProps) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const onConfirm = useCallback(async () => {
|
||||||
|
if (formRef.current) {
|
||||||
|
if (await formRef.current.submitForm()) {
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setShow(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
enter="transition-opacity duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
|
||||||
|
okText="Confirm"
|
||||||
|
onOk={onConfirm}
|
||||||
|
onCancel={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
||||||
|
</Modal>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
>
|
||||||
|
<button className="input-action" onClick={onClick} type="button">
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
|
||||||
|
|
||||||
|
const BlacklistedTagImportForm = forwardRef<
|
||||||
|
Partial<HTMLFormElement>,
|
||||||
|
BlacklistedTagImportFormProps
|
||||||
|
>((props, ref) => {
|
||||||
|
const { setSelector } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
const [formValue, setFormValue] = useState('');
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm: handleSubmit,
|
||||||
|
formValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validate = async () => {
|
||||||
|
if (formValue.length === 0) {
|
||||||
|
setErrors([intl.formatMessage(messages.valueRequired)]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^(?:\d+,)*\d+$/.test(formValue)) {
|
||||||
|
setErrors([intl.formatMessage(messages.noSpecialCharacters)]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = await Promise.allSettled(
|
||||||
|
formValue.split(',').map(async (keywordId) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
label: data.name,
|
||||||
|
value: data.id,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw intl.formatMessage(messages.invalidKeyword, { keywordId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = keywords.filter(
|
||||||
|
(res) => res.status === 'rejected'
|
||||||
|
) as PromiseRejectedResult[];
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setErrors(failures.map((failure) => `${failure.reason}`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelector(
|
||||||
|
(keywords as PromiseFulfilledResult<SingleVal>[]).map(
|
||||||
|
(keyword) => keyword.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setErrors([]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = validate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="value">
|
||||||
|
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="value"
|
||||||
|
value={formValue}
|
||||||
|
onChange={(e) => setFormValue(e.target.value)}
|
||||||
|
className="h-20"
|
||||||
|
/>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<div key={error}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const VerifyClearIndicator = <
|
||||||
|
Option,
|
||||||
|
IsMuti extends boolean,
|
||||||
|
Group extends GroupBase<Option>
|
||||||
|
>(
|
||||||
|
props: ClearIndicatorProps<Option, IsMuti, Group>
|
||||||
|
) => {
|
||||||
|
const { clearValue } = props;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const openForm = useCallback(() => {
|
||||||
|
setShow(true);
|
||||||
|
}, [setShow]);
|
||||||
|
|
||||||
|
const openFormKey = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
if (show) return;
|
||||||
|
|
||||||
|
if (event.key === 'Enter' || event.key === 'Space') {
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setShow, show]
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptForm = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
clearValue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
window.addEventListener('keydown', acceptForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', acceptForm);
|
||||||
|
}, [show, acceptForm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openForm}
|
||||||
|
onKeyDown={openFormKey}
|
||||||
|
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
|
||||||
|
>
|
||||||
|
<components.CrossIcon />
|
||||||
|
</button>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
enter="transition-opacity duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
|
||||||
|
okText={intl.formatMessage(messages.yes)}
|
||||||
|
cancelText={intl.formatMessage(messages.no)}
|
||||||
|
onOk={clearValue}
|
||||||
|
onCancel={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<form />{' '}
|
||||||
|
{/* Form prevents accidentally saving settings when pressing enter */}
|
||||||
|
</Modal>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlacklistedTagsSelector;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user